Compare commits
2 Commits
jwt/v0.10.
...
hws/v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f98bbce2d | |||
| 4c5af63ea2 |
2
hws/.gitignore
vendored
2
hws/.gitignore
vendored
@@ -17,3 +17,5 @@ coverage.html
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
.claude/
|
||||
|
||||
21
hws/LICENSE
Normal file
21
hws/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 haelnorr
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
119
hws/README.md
Normal file
119
hws/README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# hws
|
||||
|
||||
[](https://pkg.go.dev/git.haelnorr.com/h/golib/hws)
|
||||
|
||||
A lightweight, opinionated HTTP web server framework for Go built on top of the standard library's `net/http`.
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Built on Go 1.22+ routing patterns with method and path matching
|
||||
- 🎯 Structured error handling with customizable error pages
|
||||
- 📝 Integrated logging with zerolog via hlog
|
||||
- 🔧 Middleware support with predictable execution order
|
||||
- 🗜️ GZIP compression support
|
||||
- 🔒 Safe static file serving (prevents directory listing)
|
||||
- ⚙️ Environment variable configuration
|
||||
- ⏱️ Request timing and logging middleware
|
||||
- 💚 Graceful shutdown support
|
||||
- 🏥 Built-in health check endpoint
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get git.haelnorr.com/h/golib/hws
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load configuration from environment variables
|
||||
config, _ := hws.ConfigFromEnv()
|
||||
|
||||
// Create server
|
||||
server, _ := hws.NewServer(config)
|
||||
|
||||
// Define routes
|
||||
routes := []hws.Route{
|
||||
{
|
||||
Path: "/",
|
||||
Method: hws.MethodGET,
|
||||
Handler: http.HandlerFunc(homeHandler),
|
||||
},
|
||||
{
|
||||
Path: "/api/users/{id}",
|
||||
Method: hws.MethodGET,
|
||||
Handler: http.HandlerFunc(getUserHandler),
|
||||
},
|
||||
}
|
||||
|
||||
// Add routes and middleware
|
||||
server.AddRoutes(routes...)
|
||||
server.AddMiddleware()
|
||||
|
||||
// Start server
|
||||
ctx := context.Background()
|
||||
server.Start(ctx)
|
||||
|
||||
// Wait for server to be ready
|
||||
<-server.Ready()
|
||||
}
|
||||
|
||||
func homeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("Hello, World!"))
|
||||
}
|
||||
|
||||
func getUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
w.Write([]byte("User ID: " + id))
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Comprehensive documentation is available in the [Wiki](https://git.haelnorr.com/h/golib/wiki/hws).
|
||||
|
||||
### Key Topics
|
||||
|
||||
- [Configuration](https://git.haelnorr.com/h/golib/wiki/hws#configuration)
|
||||
- [Routing](https://git.haelnorr.com/h/golib/wiki/hws#routing)
|
||||
- [Middleware](https://git.haelnorr.com/h/golib/wiki/hws#middleware)
|
||||
- [Error Handling](https://git.haelnorr.com/h/golib/wiki/hws#error-handling)
|
||||
- [Logging](https://git.haelnorr.com/h/golib/wiki/hws#logging)
|
||||
- [Static Files](https://git.haelnorr.com/h/golib/wiki/hws#static-files)
|
||||
- [Graceful Shutdown](https://git.haelnorr.com/h/golib/wiki/hws#graceful-shutdown)
|
||||
- [Complete Examples](https://git.haelnorr.com/h/golib/wiki/hws#complete-production-example)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HWS_HOST` | Host to listen on | `127.0.0.1` |
|
||||
| `HWS_PORT` | Port to listen on | `3000` |
|
||||
| `HWS_TRUSTED_HOST` | Trusted hostname/domain | Same as Host |
|
||||
| `HWS_GZIP` | Enable GZIP compression | `false` |
|
||||
| `HWS_READ_HEADER_TIMEOUT` | Header read timeout (seconds) | `2` |
|
||||
| `HWS_WRITE_TIMEOUT` | Write timeout (seconds) | `10` |
|
||||
| `HWS_IDLE_TIMEOUT` | Idle connection timeout (seconds) | `120` |
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## Related Projects
|
||||
|
||||
- [hwsauth](https://git.haelnorr.com/h/golib/hwsauth) - JWT authentication middleware for hws
|
||||
- [hlog](https://git.haelnorr.com/h/golib/hlog) - Structured logging with zerolog
|
||||
- [jwt](https://git.haelnorr.com/h/golib/jwt) - JWT token generation and validation
|
||||
@@ -18,7 +18,7 @@ func Test_Server_Addr(t *testing.T) {
|
||||
Port: 8080,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
addr := server.Addr()
|
||||
assert.Equal(t, "192.168.1.1:8080", addr)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func Test_Server_Addr(t *testing.T) {
|
||||
func Test_Server_Handler(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
server := createTestServer(t, &buf)
|
||||
|
||||
|
||||
// Add routes first
|
||||
handler := testHandler
|
||||
err := server.AddRoutes(hws.Route{
|
||||
@@ -35,16 +35,16 @@ func Test_Server_Handler(t *testing.T) {
|
||||
Handler: handler,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
// Get the handler
|
||||
h := server.Handler()
|
||||
require.NotNil(t, h)
|
||||
|
||||
|
||||
// Test the handler directly with httptest
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
|
||||
assert.Equal(t, 200, rr.Code)
|
||||
assert.Equal(t, "hello world", rr.Body.String())
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func Test_Server_Handler(t *testing.T) {
|
||||
func Test_LoggerIgnorePaths_Integration(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
server := createTestServer(t, &buf)
|
||||
|
||||
|
||||
// Add routes
|
||||
err := server.AddRoutes(hws.Route{
|
||||
Path: "/test",
|
||||
@@ -64,28 +64,28 @@ func Test_LoggerIgnorePaths_Integration(t *testing.T) {
|
||||
Handler: testHandler,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
// Set paths to ignore
|
||||
server.LoggerIgnorePaths("/ignore", "/healthz")
|
||||
|
||||
|
||||
err = server.AddMiddleware()
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
// Test that ignored path doesn't generate logs
|
||||
buf.Reset()
|
||||
req := httptest.NewRequest("GET", "/ignore", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(rr, req)
|
||||
|
||||
|
||||
// Buffer should be empty for ignored path
|
||||
assert.Empty(t, buf.String())
|
||||
|
||||
|
||||
// Test that non-ignored path generates logs
|
||||
buf.Reset()
|
||||
req = httptest.NewRequest("GET", "/test", nil)
|
||||
rr = httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(rr, req)
|
||||
|
||||
|
||||
// Buffer should have logs for non-ignored path
|
||||
assert.NotEmpty(t, buf.String())
|
||||
}
|
||||
@@ -93,12 +93,12 @@ func Test_LoggerIgnorePaths_Integration(t *testing.T) {
|
||||
func Test_WrappedWriter(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
server := createTestServer(t, &buf)
|
||||
|
||||
|
||||
// Add routes with different status codes
|
||||
err := server.AddRoutes(
|
||||
hws.Route{
|
||||
Path: "/ok",
|
||||
Method: hws.MethodGET,
|
||||
Path: "/ok",
|
||||
Method: hws.MethodGET,
|
||||
Handler: testHandler,
|
||||
},
|
||||
hws.Route{
|
||||
@@ -111,16 +111,16 @@ func Test_WrappedWriter(t *testing.T) {
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
err = server.AddMiddleware()
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
// Test OK status
|
||||
req := httptest.NewRequest("GET", "/ok", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(rr, req)
|
||||
assert.Equal(t, 200, rr.Code)
|
||||
|
||||
|
||||
// Test Created status
|
||||
req = httptest.NewRequest("POST", "/created", nil)
|
||||
rr = httptest.NewRecorder()
|
||||
@@ -149,7 +149,7 @@ func Test_Start_Errors(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = server.Start(nil)
|
||||
err = server.Start(t.Context())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Context cannot be nil")
|
||||
})
|
||||
@@ -163,10 +163,10 @@ func Test_Shutdown_Errors(t *testing.T) {
|
||||
startTestServer(t, server)
|
||||
<-server.Ready()
|
||||
|
||||
err := server.Shutdown(nil)
|
||||
err := server.Shutdown(t.Context())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Context cannot be nil")
|
||||
|
||||
|
||||
// Clean up
|
||||
server.Shutdown(t.Context())
|
||||
})
|
||||
@@ -199,7 +199,7 @@ func Test_WaitUntilReady_ContextCancelled(t *testing.T) {
|
||||
|
||||
// Start should return with context error since timeout is so short
|
||||
err = server.Start(ctx)
|
||||
|
||||
|
||||
// The error could be nil if server started very quickly, or context.DeadlineExceeded
|
||||
// This tests the ctx.Err() path in waitUntilReady
|
||||
if err != nil {
|
||||
|
||||
21
hwsauth/LICENSE.md
Normal file
21
hwsauth/LICENSE.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# MIT License
|
||||
|
||||
Copyright (c) 2026 haelnorr
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
129
hwsauth/README.md
Normal file
129
hwsauth/README.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# hwsauth
|
||||
|
||||
[](https://pkg.go.dev/git.haelnorr.com/h/golib/hwsauth)
|
||||
|
||||
JWT-based authentication middleware for the [hws](https://git.haelnorr.com/h/golib/hws) web framework.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 JWT-based authentication with access and refresh tokens
|
||||
- 🔄 Automatic token rotation and refresh
|
||||
- 🎯 Generic over user model and transaction types
|
||||
- 💾 ORM-agnostic transaction handling (works with GORM, Bun, sqlx, etc.)
|
||||
- ⚙️ Environment variable configuration
|
||||
- 🛡️ Middleware for protecting routes
|
||||
- 🔒 SSL cookie security support
|
||||
- 📦 Type-safe with Go generics
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get git.haelnorr.com/h/golib/hwsauth
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"git.haelnorr.com/h/golib/hwsauth"
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
UserID int
|
||||
Username string
|
||||
Email string
|
||||
}
|
||||
|
||||
func (u User) ID() int {
|
||||
return u.UserID
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Load configuration from environment variables
|
||||
cfg, _ := hwsauth.ConfigFromEnv()
|
||||
|
||||
// Create database connection
|
||||
db, _ := sql.Open("postgres", "postgres://...")
|
||||
|
||||
// Define transaction creation
|
||||
beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
|
||||
return db.BeginTx(ctx, nil)
|
||||
}
|
||||
|
||||
// Define user loading function
|
||||
loadUser := func(ctx context.Context, tx *sql.Tx, id int) (User, error) {
|
||||
var user User
|
||||
err := tx.QueryRowContext(ctx,
|
||||
"SELECT id, username, email FROM users WHERE id = $1", id).
|
||||
Scan(&user.UserID, &user.Username, &user.Email)
|
||||
return user, err
|
||||
}
|
||||
|
||||
// Create HWS server
|
||||
server := hws.NewServer(":8080", logger)
|
||||
|
||||
// Create authenticator
|
||||
auth, _ := hwsauth.NewAuthenticator[User, *sql.Tx](
|
||||
cfg,
|
||||
loadUser,
|
||||
server,
|
||||
beginTx,
|
||||
logger,
|
||||
errorPageFunc,
|
||||
)
|
||||
|
||||
// Add authentication middleware
|
||||
server.AddMiddleware(auth.Authenticate())
|
||||
|
||||
// Optionally ignore public paths
|
||||
auth.IgnorePaths("/", "/login", "/register", "/static")
|
||||
|
||||
// Protect routes
|
||||
protectedHandler := auth.LoginReq(http.HandlerFunc(dashboardHandler))
|
||||
server.AddRoute("GET", "/dashboard", protectedHandler)
|
||||
|
||||
server.Start()
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Comprehensive documentation is available in the [Wiki](https://git.haelnorr.com/h/golib/wiki/hwsauth).
|
||||
|
||||
### Key Topics
|
||||
|
||||
- [Configuration](https://git.haelnorr.com/h/golib/wiki/hwsauth#configuration)
|
||||
- [User Model](https://git.haelnorr.com/h/golib/wiki/hwsauth#user-model)
|
||||
- [Authentication Flow](https://git.haelnorr.com/h/golib/wiki/hwsauth#authentication-flow)
|
||||
- [Login & Logout](https://git.haelnorr.com/h/golib/wiki/hwsauth#login-logout)
|
||||
- [Route Protection](https://git.haelnorr.com/h/golib/wiki/hwsauth#route-protection)
|
||||
- [Token Refresh](https://git.haelnorr.com/h/golib/wiki/hwsauth#token-refresh)
|
||||
- [Using with ORMs](https://git.haelnorr.com/h/golib/wiki/hwsauth#using-with-orms)
|
||||
- [Security Best Practices](https://git.haelnorr.com/h/golib/wiki/hwsauth#security-best-practices)
|
||||
|
||||
## Supported ORMs
|
||||
|
||||
- database/sql (standard library)
|
||||
- GORM
|
||||
- Bun
|
||||
- sqlx
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## Related Projects
|
||||
|
||||
- [hws](https://git.haelnorr.com/h/golib/hws) - The web server framework
|
||||
- [jwt](https://git.haelnorr.com/h/golib/jwt) - JWT token generation and validation
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
)
|
||||
|
||||
// Check the cookies for token strings and attempt to authenticate them
|
||||
func (auth *Authenticator[T]) getAuthenticatedUser(
|
||||
tx DBTransaction,
|
||||
func (auth *Authenticator[T, TX]) getAuthenticatedUser(
|
||||
tx TX,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) (authenticatedModel[T], error) {
|
||||
@@ -20,10 +20,10 @@ func (auth *Authenticator[T]) getAuthenticatedUser(
|
||||
return authenticatedModel[T]{}, errors.New("No token strings provided")
|
||||
}
|
||||
// Attempt to parse the access token
|
||||
aT, err := auth.tokenGenerator.ValidateAccess(tx, atStr)
|
||||
aT, err := auth.tokenGenerator.ValidateAccess(jwt.DBTransaction(tx), atStr)
|
||||
if err != nil {
|
||||
// Access token invalid, attempt to parse refresh token
|
||||
rT, err := auth.tokenGenerator.ValidateRefresh(tx, rtStr)
|
||||
rT, err := auth.tokenGenerator.ValidateRefresh(jwt.DBTransaction(tx), rtStr)
|
||||
if err != nil {
|
||||
return authenticatedModel[T]{}, errors.Wrap(err, "auth.tokenGenerator.ValidateRefresh")
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func (auth *Authenticator[T]) getAuthenticatedUser(
|
||||
}
|
||||
|
||||
// Access token valid
|
||||
model, err := auth.load(tx, aT.SUB)
|
||||
model, err := auth.load(r.Context(), tx, aT.SUB)
|
||||
if err != nil {
|
||||
return authenticatedModel[T]{}, errors.Wrap(err, "auth.load")
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
package hwsauth
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/golib/jwt"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Authenticator[T Model] struct {
|
||||
type Authenticator[T Model, TX DBTransaction] struct {
|
||||
tokenGenerator *jwt.TokenGenerator
|
||||
load LoadFunc[T]
|
||||
conn DBConnection
|
||||
load LoadFunc[T, TX]
|
||||
beginTx BeginTX
|
||||
ignoredPaths []string
|
||||
logger *zerolog.Logger
|
||||
server *hws.Server
|
||||
@@ -25,22 +23,22 @@ type Authenticator[T Model] struct {
|
||||
// If cfg is nil or any required fields are not set, default values will be used or an error returned.
|
||||
// Required fields: SecretKey (no default)
|
||||
// If SSL is true, TrustedHost is also required.
|
||||
func NewAuthenticator[T Model](
|
||||
func NewAuthenticator[T Model, TX DBTransaction](
|
||||
cfg *Config,
|
||||
load LoadFunc[T],
|
||||
load LoadFunc[T, TX],
|
||||
server *hws.Server,
|
||||
conn DBConnection,
|
||||
beginTx BeginTX,
|
||||
logger *zerolog.Logger,
|
||||
errorPage hws.ErrorPageFunc,
|
||||
) (*Authenticator[T], error) {
|
||||
) (*Authenticator[T, TX], error) {
|
||||
if load == nil {
|
||||
return nil, errors.New("No function to load model supplied")
|
||||
}
|
||||
if server == nil {
|
||||
return nil, errors.New("No hws.Server provided")
|
||||
}
|
||||
if conn == nil {
|
||||
return nil, errors.New("No database connection supplied")
|
||||
if beginTx == nil {
|
||||
return nil, errors.New("No beginTx function provided")
|
||||
}
|
||||
if logger == nil {
|
||||
return nil, errors.New("No logger provided")
|
||||
@@ -72,13 +70,6 @@ func NewAuthenticator[T Model](
|
||||
cfg.LandingPage = "/profile"
|
||||
}
|
||||
|
||||
// Cast DBConnection to *sql.DB
|
||||
// DBConnection is satisfied by *sql.DB, so this cast should be safe for standard usage
|
||||
sqlDB, ok := conn.(*sql.DB)
|
||||
if !ok {
|
||||
return nil, errors.New("DBConnection must be *sql.DB for JWT token generation")
|
||||
}
|
||||
|
||||
// Configure JWT table
|
||||
tableConfig := jwt.DefaultTableConfig()
|
||||
if cfg.JWTTableName != "" {
|
||||
@@ -92,22 +83,21 @@ func NewAuthenticator[T Model](
|
||||
FreshExpireAfter: cfg.TokenFreshTime,
|
||||
TrustedHost: cfg.TrustedHost,
|
||||
SecretKey: cfg.SecretKey,
|
||||
DBConn: sqlDB,
|
||||
DBType: jwt.DatabaseType{
|
||||
Type: cfg.DatabaseType,
|
||||
Version: cfg.DatabaseVersion,
|
||||
},
|
||||
TableConfig: tableConfig,
|
||||
})
|
||||
}, beginTx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "jwt.CreateGenerator")
|
||||
}
|
||||
|
||||
auth := Authenticator[T]{
|
||||
auth := Authenticator[T, TX]{
|
||||
tokenGenerator: tokenGen,
|
||||
load: load,
|
||||
server: server,
|
||||
conn: conn,
|
||||
beginTx: beginTx,
|
||||
logger: logger,
|
||||
errorPage: errorPage,
|
||||
SSL: cfg.SSL,
|
||||
|
||||
@@ -6,22 +6,31 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Config holds the configuration settings for the authenticator.
|
||||
// All time-based settings are in minutes.
|
||||
type Config struct {
|
||||
SSL bool // ENV HWSAUTH_SSL: Flag for SSL Mode (default: false)
|
||||
TrustedHost string // ENV HWSAUTH_TRUSTED_HOST: Full server address to accept as trusted SSL host (required if SSL is true)
|
||||
SecretKey string // ENV HWSAUTH_SECRET_KEY: Secret key for signing tokens (required)
|
||||
SSL bool // ENV HWSAUTH_SSL: Enable SSL secure cookies (default: false)
|
||||
TrustedHost string // ENV HWSAUTH_TRUSTED_HOST: Full server address for SSL (required if SSL is true)
|
||||
SecretKey string // ENV HWSAUTH_SECRET_KEY: Secret key for signing JWT tokens (required)
|
||||
AccessTokenExpiry int64 // ENV HWSAUTH_ACCESS_TOKEN_EXPIRY: Access token expiry in minutes (default: 5)
|
||||
RefreshTokenExpiry int64 // ENV HWSAUTH_REFRESH_TOKEN_EXPIRY: Refresh token expiry in minutes (default: 1440)
|
||||
TokenFreshTime int64 // ENV HWSAUTH_TOKEN_FRESH_TIME: Time for tokens to stay fresh in minutes (default: 5)
|
||||
LandingPage string // ENV HWSAUTH_LANDING_PAGE: Path of the desired landing page for logged in users (default: "/profile")
|
||||
TokenFreshTime int64 // ENV HWSAUTH_TOKEN_FRESH_TIME: Token fresh time in minutes (default: 5)
|
||||
LandingPage string // ENV HWSAUTH_LANDING_PAGE: Redirect destination for authenticated users (default: "/profile")
|
||||
DatabaseType string // ENV HWSAUTH_DATABASE_TYPE: Database type (postgres, mysql, sqlite, mariadb) (default: "postgres")
|
||||
DatabaseVersion string // ENV HWSAUTH_DATABASE_VERSION: Database version (default: "15")
|
||||
JWTTableName string // ENV HWSAUTH_JWT_TABLE_NAME: JWT blacklist table name (default: "jwtblacklist")
|
||||
DatabaseVersion string // ENV HWSAUTH_DATABASE_VERSION: Database version string (default: "15")
|
||||
JWTTableName string // ENV HWSAUTH_JWT_TABLE_NAME: Custom JWT blacklist table name (default: "jwtblacklist")
|
||||
}
|
||||
|
||||
// ConfigFromEnv loads configuration from environment variables.
|
||||
//
|
||||
// Required environment variables:
|
||||
// - HWSAUTH_SECRET_KEY: Secret key for JWT signing
|
||||
// - HWSAUTH_TRUSTED_HOST: Required if HWSAUTH_SSL is true
|
||||
//
|
||||
// Returns an error if required variables are missing or invalid.
|
||||
func ConfigFromEnv() (*Config, error) {
|
||||
ssl := env.Bool("HWSAUTH_SSL", false)
|
||||
trustedHost := env.String("HWS_TRUSTED_HOST", "")
|
||||
trustedHost := env.String("HWSAUTH_TRUSTED_HOST", "")
|
||||
if ssl && trustedHost == "" {
|
||||
return nil, errors.New("SSL is enabled and no HWS_TRUSTED_HOST set")
|
||||
}
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
package hwsauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"git.haelnorr.com/h/golib/jwt"
|
||||
)
|
||||
|
||||
// DBTransaction represents a database transaction that can be committed or rolled back.
|
||||
// This interface can be implemented by standard library sql.Tx, or by ORM transactions
|
||||
// from libraries like bun, gorm, sqlx, etc.
|
||||
type DBTransaction interface {
|
||||
Commit() error
|
||||
Rollback() error
|
||||
}
|
||||
// This is an alias to jwt.DBTransaction.
|
||||
//
|
||||
// Standard library *sql.Tx implements this interface automatically.
|
||||
// ORM transactions (GORM, Bun, etc.) should also implement this interface.
|
||||
type DBTransaction = jwt.DBTransaction
|
||||
|
||||
// DBConnection represents a database connection that can begin transactions.
|
||||
// This interface can be implemented by standard library sql.DB, or by ORM connections
|
||||
// from libraries like bun, gorm, sqlx, etc.
|
||||
type DBConnection interface {
|
||||
BeginTx(ctx context.Context, opts *sql.TxOptions) (DBTransaction, error)
|
||||
}
|
||||
|
||||
// Ensure *sql.Tx implements DBTransaction
|
||||
var _ DBTransaction = (*sql.Tx)(nil)
|
||||
|
||||
// Ensure *sql.DB implements DBConnection
|
||||
var _ DBConnection = (*sql.DB)(nil)
|
||||
// BeginTX is a function type for creating database transactions.
|
||||
// This is an alias to jwt.BeginTX.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
|
||||
// return db.BeginTx(ctx, nil)
|
||||
// }
|
||||
type BeginTX = jwt.BeginTX
|
||||
|
||||
212
hwsauth/doc.go
Normal file
212
hwsauth/doc.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// Package hwsauth provides JWT-based authentication middleware for the hws web framework.
|
||||
//
|
||||
// # Overview
|
||||
//
|
||||
// hwsauth integrates with the hws web server to provide secure, stateless authentication
|
||||
// using JSON Web Tokens (JWT). It supports both access and refresh tokens, automatic
|
||||
// token rotation, and flexible transaction handling compatible with any database or ORM.
|
||||
//
|
||||
// # Key Features
|
||||
//
|
||||
// - JWT-based authentication with access and refresh tokens
|
||||
// - Automatic token rotation and refresh
|
||||
// - Generic over user model and transaction types
|
||||
// - ORM-agnostic transaction handling
|
||||
// - Environment variable configuration
|
||||
// - Middleware for protecting routes
|
||||
// - Context-based user retrieval
|
||||
// - Optional SSL cookie security
|
||||
//
|
||||
// # Quick Start
|
||||
//
|
||||
// First, define your user model:
|
||||
//
|
||||
// type User struct {
|
||||
// UserID int
|
||||
// Username string
|
||||
// Email string
|
||||
// }
|
||||
//
|
||||
// func (u User) ID() int {
|
||||
// return u.UserID
|
||||
// }
|
||||
//
|
||||
// Configure the authenticator using environment variables or programmatically:
|
||||
//
|
||||
// // Option 1: Load from environment variables
|
||||
// cfg, err := hwsauth.ConfigFromEnv()
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// // Option 2: Create config manually
|
||||
// cfg := &hwsauth.Config{
|
||||
// SSL: true,
|
||||
// TrustedHost: "https://example.com",
|
||||
// SecretKey: "your-secret-key",
|
||||
// AccessTokenExpiry: 5, // 5 minutes
|
||||
// RefreshTokenExpiry: 1440, // 1 day
|
||||
// TokenFreshTime: 5, // 5 minutes
|
||||
// LandingPage: "/dashboard",
|
||||
// }
|
||||
//
|
||||
// Create the authenticator:
|
||||
//
|
||||
// // Define how to begin transactions
|
||||
// beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
|
||||
// return db.BeginTx(ctx, nil)
|
||||
// }
|
||||
//
|
||||
// // Define how to load users from the database
|
||||
// loadUser := func(ctx context.Context, tx *sql.Tx, id int) (User, error) {
|
||||
// var user User
|
||||
// err := tx.QueryRowContext(ctx, "SELECT id, username, email FROM users WHERE id = ?", id).
|
||||
// Scan(&user.UserID, &user.Username, &user.Email)
|
||||
// return user, err
|
||||
// }
|
||||
//
|
||||
// // Create the authenticator
|
||||
// auth, err := hwsauth.NewAuthenticator[User, *sql.Tx](
|
||||
// cfg,
|
||||
// loadUser,
|
||||
// server,
|
||||
// beginTx,
|
||||
// logger,
|
||||
// errorPage,
|
||||
// )
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// # Middleware
|
||||
//
|
||||
// Use the Authenticate middleware to protect routes:
|
||||
//
|
||||
// // Apply to all routes
|
||||
// server.AddMiddleware(auth.Authenticate())
|
||||
//
|
||||
// // Ignore specific paths
|
||||
// auth.IgnorePaths("/login", "/register", "/public")
|
||||
//
|
||||
// Use route guards for specific protection requirements:
|
||||
//
|
||||
// // LoginReq: Requires user to be authenticated
|
||||
// protectedHandler := auth.LoginReq(myHandler)
|
||||
//
|
||||
// // LogoutReq: Redirects authenticated users (for login/register pages)
|
||||
// loginHandler := auth.LogoutReq(loginPageHandler)
|
||||
//
|
||||
// // FreshReq: Requires fresh authentication (for sensitive operations)
|
||||
// changePasswordHandler := auth.FreshReq(changePasswordHandler)
|
||||
//
|
||||
// # Login and Logout
|
||||
//
|
||||
// To log a user in:
|
||||
//
|
||||
// func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// // Validate credentials...
|
||||
// user := getUserFromDatabase(username)
|
||||
//
|
||||
// // Log the user in (sets JWT cookies)
|
||||
// err := auth.Login(w, r, user, rememberMe)
|
||||
// if err != nil {
|
||||
// // Handle error
|
||||
// }
|
||||
//
|
||||
// http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||
// }
|
||||
//
|
||||
// To log a user out:
|
||||
//
|
||||
// func logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// tx, _ := db.BeginTx(r.Context(), nil)
|
||||
// defer tx.Rollback()
|
||||
//
|
||||
// err := auth.Logout(tx, w, r)
|
||||
// if err != nil {
|
||||
// // Handle error
|
||||
// }
|
||||
//
|
||||
// tx.Commit()
|
||||
// http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
// }
|
||||
//
|
||||
// # Retrieving the Current User
|
||||
//
|
||||
// Access the authenticated user from the request context:
|
||||
//
|
||||
// func dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// user := auth.CurrentModel(r.Context())
|
||||
// if user.ID() == 0 {
|
||||
// // User not authenticated
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// fmt.Fprintf(w, "Welcome, %s!", user.Username)
|
||||
// }
|
||||
//
|
||||
// # ORM Support
|
||||
//
|
||||
// hwsauth works with any ORM that implements the DBTransaction interface.
|
||||
//
|
||||
// GORM Example:
|
||||
//
|
||||
// beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
|
||||
// return gormDB.WithContext(ctx).Begin().Statement.ConnPool.(*sql.Tx), nil
|
||||
// }
|
||||
//
|
||||
// loadUser := func(ctx context.Context, tx *gorm.DB, id int) (User, error) {
|
||||
// var user User
|
||||
// err := tx.First(&user, id).Error
|
||||
// return user, err
|
||||
// }
|
||||
//
|
||||
// auth, err := hwsauth.NewAuthenticator[User, *gorm.DB](...)
|
||||
//
|
||||
// Bun Example:
|
||||
//
|
||||
// beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
|
||||
// return bunDB.BeginTx(ctx, nil)
|
||||
// }
|
||||
//
|
||||
// loadUser := func(ctx context.Context, tx bun.Tx, id int) (User, error) {
|
||||
// var user User
|
||||
// err := tx.NewSelect().Model(&user).Where("id = ?", id).Scan(ctx)
|
||||
// return user, err
|
||||
// }
|
||||
//
|
||||
// auth, err := hwsauth.NewAuthenticator[User, bun.Tx](...)
|
||||
//
|
||||
// # Environment Variables
|
||||
//
|
||||
// The following environment variables are supported:
|
||||
//
|
||||
// - HWSAUTH_SSL: Enable SSL mode (default: false)
|
||||
// - HWSAUTH_TRUSTED_HOST: Trusted host for SSL (required if SSL is true)
|
||||
// - HWSAUTH_SECRET_KEY: Secret key for signing tokens (required)
|
||||
// - HWSAUTH_ACCESS_TOKEN_EXPIRY: Access token expiry in minutes (default: 5)
|
||||
// - HWSAUTH_REFRESH_TOKEN_EXPIRY: Refresh token expiry in minutes (default: 1440)
|
||||
// - HWSAUTH_TOKEN_FRESH_TIME: Token fresh time in minutes (default: 5)
|
||||
// - HWSAUTH_LANDING_PAGE: Landing page for logged in users (default: "/profile")
|
||||
// - HWSAUTH_JWT_TABLE_NAME: Custom JWT table name (optional)
|
||||
// - HWSAUTH_DATABASE_TYPE: Database type (e.g., "postgres", "mysql")
|
||||
// - HWSAUTH_DATABASE_VERSION: Database version (e.g., "15")
|
||||
//
|
||||
// # Security Considerations
|
||||
//
|
||||
// - Always use SSL in production (set HWSAUTH_SSL=true)
|
||||
// - Use strong, randomly generated secret keys
|
||||
// - Set appropriate token expiry times based on your security requirements
|
||||
// - Use FreshReq middleware for sensitive operations (password changes, etc.)
|
||||
// - Store refresh tokens securely in HTTP-only cookies
|
||||
//
|
||||
// # Type Parameters
|
||||
//
|
||||
// hwsauth uses Go generics for type safety:
|
||||
//
|
||||
// - T Model: Your user model type (must implement the Model interface)
|
||||
// - TX DBTransaction: Your transaction type (must implement DBTransaction interface)
|
||||
//
|
||||
// This allows compile-time type checking and eliminates the need for type assertions
|
||||
// when working with your user models.
|
||||
package hwsauth
|
||||
@@ -2,17 +2,17 @@ module git.haelnorr.com/h/golib/hwsauth
|
||||
|
||||
go 1.25.5
|
||||
|
||||
replace git.haelnorr.com/h/golib/hws => ../hws
|
||||
|
||||
require (
|
||||
git.haelnorr.com/h/golib/cookies v0.9.0
|
||||
git.haelnorr.com/h/golib/env v0.9.1
|
||||
git.haelnorr.com/h/golib/hws v0.1.0
|
||||
git.haelnorr.com/h/golib/jwt v0.9.2
|
||||
git.haelnorr.com/h/golib/jwt v0.10.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/rs/zerolog v1.34.0
|
||||
)
|
||||
|
||||
replace git.haelnorr.com/h/golib/hws => ../hws
|
||||
|
||||
require (
|
||||
git.haelnorr.com/h/golib/hlog v0.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
|
||||
@@ -4,8 +4,8 @@ git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjo
|
||||
git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg=
|
||||
git.haelnorr.com/h/golib/hlog v0.9.0 h1:ib8n2MdmiRK2TF067p220kXmhDe9aAnlcsgpuv+QpvE=
|
||||
git.haelnorr.com/h/golib/hlog v0.9.0/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk=
|
||||
git.haelnorr.com/h/golib/jwt v0.9.2 h1:l1Ow7DPGACAU54CnMP/NlZjdc4nRD1wr3xZ8a7taRvU=
|
||||
git.haelnorr.com/h/golib/jwt v0.9.2/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
|
||||
git.haelnorr.com/h/golib/jwt v0.10.0 h1:8cI8mSnb8X+EmJtrBO/5UZwuBMtib0IE9dv85gkm94E=
|
||||
git.haelnorr.com/h/golib/jwt v0.10.0/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
|
||||
@@ -5,7 +5,15 @@ import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func (auth *Authenticator[T]) IgnorePaths(paths ...string) error {
|
||||
// IgnorePaths excludes specified paths from authentication middleware.
|
||||
// Paths must be valid URL paths (relative paths without scheme or host).
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// auth.IgnorePaths("/", "/login", "/register", "/public", "/static")
|
||||
//
|
||||
// Returns an error if any path is invalid.
|
||||
func (auth *Authenticator[T, TX]) IgnorePaths(paths ...string) error {
|
||||
for _, path := range paths {
|
||||
u, err := url.Parse(path)
|
||||
valid := err == nil &&
|
||||
|
||||
@@ -7,14 +7,38 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (auth *Authenticator[T]) Login(
|
||||
// Login authenticates a user and sets JWT tokens as HTTP-only cookies.
|
||||
// The rememberMe parameter determines token expiration behavior.
|
||||
//
|
||||
// Parameters:
|
||||
// - w: HTTP response writer for setting cookies
|
||||
// - r: HTTP request
|
||||
// - model: The authenticated user model
|
||||
// - rememberMe: If true, tokens have extended expiry; if false, session-based
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// user, err := validateCredentials(username, password)
|
||||
// if err != nil {
|
||||
// http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
// return
|
||||
// }
|
||||
// err = auth.Login(w, r, user, true)
|
||||
// if err != nil {
|
||||
// http.Error(w, "Login failed", http.StatusInternalServerError)
|
||||
// return
|
||||
// }
|
||||
// http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||
// }
|
||||
func (auth *Authenticator[T, TX]) Login(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
model T,
|
||||
rememberMe bool,
|
||||
) error {
|
||||
|
||||
err := jwt.SetTokenCookies(w, r, auth.tokenGenerator, model.ID(), true, rememberMe, auth.SSL)
|
||||
err := jwt.SetTokenCookies(w, r, auth.tokenGenerator, model.GetID(), true, rememberMe, auth.SSL)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "jwt.SetTokenCookies")
|
||||
}
|
||||
|
||||
@@ -4,19 +4,40 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.haelnorr.com/h/golib/cookies"
|
||||
"git.haelnorr.com/h/golib/jwt"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (auth *Authenticator[T]) Logout(tx DBTransaction, w http.ResponseWriter, r *http.Request) error {
|
||||
// Logout revokes the user's authentication tokens and clears their cookies.
|
||||
// This operation requires a database transaction to revoke tokens.
|
||||
//
|
||||
// Parameters:
|
||||
// - tx: Database transaction for revoking tokens
|
||||
// - w: HTTP response writer for clearing cookies
|
||||
// - r: HTTP request containing the tokens to revoke
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// tx, _ := db.BeginTx(r.Context(), nil)
|
||||
// defer tx.Rollback()
|
||||
// if err := auth.Logout(tx, w, r); err != nil {
|
||||
// http.Error(w, "Logout failed", http.StatusInternalServerError)
|
||||
// return
|
||||
// }
|
||||
// tx.Commit()
|
||||
// http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
// }
|
||||
func (auth *Authenticator[T, TX]) Logout(tx TX, w http.ResponseWriter, r *http.Request) error {
|
||||
aT, rT, err := auth.getTokens(tx, r)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "auth.getTokens")
|
||||
}
|
||||
err = aT.Revoke(tx)
|
||||
err = aT.Revoke(jwt.DBTransaction(tx))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "aT.Revoke")
|
||||
}
|
||||
err = rT.Revoke(tx)
|
||||
err = rT.Revoke(jwt.DBTransaction(tx))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "rT.Revoke")
|
||||
}
|
||||
|
||||
@@ -8,11 +8,18 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func (auth *Authenticator[T]) Authenticate() hws.Middleware {
|
||||
// Authenticate returns the main authentication middleware.
|
||||
// This middleware validates JWT tokens, refreshes expired tokens, and adds
|
||||
// the authenticated user to the request context.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// server.AddMiddleware(auth.Authenticate())
|
||||
func (auth *Authenticator[T, TX]) Authenticate() hws.Middleware {
|
||||
return auth.server.NewMiddleware(auth.authenticate())
|
||||
}
|
||||
|
||||
func (auth *Authenticator[T]) authenticate() hws.MiddlewareFunc {
|
||||
func (auth *Authenticator[T, TX]) authenticate() hws.MiddlewareFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) (*http.Request, *hws.HWSError) {
|
||||
if slices.Contains(auth.ignoredPaths, r.URL.Path) {
|
||||
return r, nil
|
||||
@@ -21,11 +28,16 @@ func (auth *Authenticator[T]) authenticate() hws.MiddlewareFunc {
|
||||
defer cancel()
|
||||
|
||||
// Start the transaction
|
||||
tx, err := auth.conn.BeginTx(ctx, nil)
|
||||
tx, err := auth.beginTx(ctx)
|
||||
if err != nil {
|
||||
return nil, &hws.HWSError{Message: "Unable to start transaction", StatusCode: http.StatusServiceUnavailable, Error: err}
|
||||
}
|
||||
model, err := auth.getAuthenticatedUser(tx, w, r)
|
||||
// Type assert to TX - safe because user's beginTx should return their TX type
|
||||
txTyped, ok := tx.(TX)
|
||||
if !ok {
|
||||
return nil, &hws.HWSError{Message: "Transaction type mismatch", StatusCode: http.StatusInternalServerError, Error: err}
|
||||
}
|
||||
model, err := auth.getAuthenticatedUser(txTyped, w, r)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
auth.logger.Debug().
|
||||
|
||||
@@ -14,13 +14,30 @@ func getNil[T Model]() T {
|
||||
return result
|
||||
}
|
||||
|
||||
// Model represents an authenticated user model.
|
||||
// User types must implement this interface to be used with the authenticator.
|
||||
type Model interface {
|
||||
ID() int
|
||||
GetID() int // Returns the unique identifier for the user
|
||||
}
|
||||
|
||||
// ContextLoader is a function type that loads a model from a context.
|
||||
// Deprecated: Use CurrentModel method instead.
|
||||
type ContextLoader[T Model] func(ctx context.Context) T
|
||||
|
||||
type LoadFunc[T Model] func(tx DBTransaction, id int) (T, error)
|
||||
// LoadFunc is a function type that loads a user model from the database.
|
||||
// It receives a context for cancellation, a transaction for database operations,
|
||||
// and the user ID to load.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// loadUser := func(ctx context.Context, tx *sql.Tx, id int) (User, error) {
|
||||
// var user User
|
||||
// err := tx.QueryRowContext(ctx,
|
||||
// "SELECT id, username, email FROM users WHERE id = $1", id).
|
||||
// Scan(&user.ID, &user.Username, &user.Email)
|
||||
// return user, err
|
||||
// }
|
||||
type LoadFunc[T Model, TX DBTransaction] func(ctx context.Context, tx TX, id int) (T, error)
|
||||
|
||||
// Return a new context with the user added in
|
||||
func setAuthenticatedModel[T Model](ctx context.Context, m authenticatedModel[T]) context.Context {
|
||||
@@ -43,15 +60,26 @@ func getAuthorizedModel[T Model](ctx context.Context) (model authenticatedModel[
|
||||
return model, true
|
||||
}
|
||||
|
||||
func (auth *Authenticator[T]) CurrentModel(ctx context.Context) T {
|
||||
auth.logger.Debug().Any("context", ctx).Msg("")
|
||||
// CurrentModel retrieves the authenticated user from the request context.
|
||||
// Returns a zero-value T if no user is authenticated or context is nil.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func handler(w http.ResponseWriter, r *http.Request) {
|
||||
// user := auth.CurrentModel(r.Context())
|
||||
// if user.ID() == 0 {
|
||||
// http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
// return
|
||||
// }
|
||||
// fmt.Fprintf(w, "Hello, %s!", user.Username)
|
||||
// }
|
||||
func (auth *Authenticator[T, TX]) CurrentModel(ctx context.Context) T {
|
||||
if ctx == nil {
|
||||
return getNil[T]()
|
||||
}
|
||||
model, ok := getAuthorizedModel[T](ctx)
|
||||
if !ok {
|
||||
result := getNil[T]()
|
||||
auth.logger.Debug().Any("model", result).Msg("")
|
||||
return result
|
||||
}
|
||||
return model.model
|
||||
|
||||
@@ -7,8 +7,14 @@ import (
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
)
|
||||
|
||||
// Checks if the model is set in the context and shows 401 page if not logged in
|
||||
func (auth *Authenticator[T]) LoginReq(next http.Handler) http.Handler {
|
||||
// LoginReq returns a middleware that requires the user to be authenticated.
|
||||
// If the user is not authenticated, it returns a 401 Unauthorized error page.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// protectedHandler := auth.LoginReq(http.HandlerFunc(dashboardHandler))
|
||||
// server.AddRoute("GET", "/dashboard", protectedHandler)
|
||||
func (auth *Authenticator[T, TX]) LoginReq(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, ok := getAuthorizedModel[T](r.Context())
|
||||
if !ok {
|
||||
@@ -36,9 +42,14 @@ func (auth *Authenticator[T]) LoginReq(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// Checks if the model is set in the context and redirects them to the landing page if
|
||||
// they are logged in
|
||||
func (auth *Authenticator[T]) LogoutReq(next http.Handler) http.Handler {
|
||||
// LogoutReq returns a middleware that redirects authenticated users to the landing page.
|
||||
// Use this for login and registration pages to prevent logged-in users from accessing them.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// loginPageHandler := auth.LogoutReq(http.HandlerFunc(showLoginPage))
|
||||
// server.AddRoute("GET", "/login", loginPageHandler)
|
||||
func (auth *Authenticator[T, TX]) LogoutReq(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, ok := getAuthorizedModel[T](r.Context())
|
||||
if ok {
|
||||
@@ -49,10 +60,17 @@ func (auth *Authenticator[T]) LogoutReq(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// FreshReq protects a route from access if the auth token is not fresh.
|
||||
// A status code of 444 will be written to the header and the request will be terminated.
|
||||
// As an example, this can be used on the client to show a confirm password dialog to refresh their login
|
||||
func (auth *Authenticator[T]) FreshReq(next http.Handler) http.Handler {
|
||||
// FreshReq returns a middleware that requires a fresh authentication token.
|
||||
// If the token is not fresh (recently issued), it returns a 444 status code.
|
||||
// Use this for sensitive operations like password changes or account deletions.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// changePasswordHandler := auth.FreshReq(http.HandlerFunc(handlePasswordChange))
|
||||
// server.AddRoute("POST", "/change-password", changePasswordHandler)
|
||||
//
|
||||
// The 444 status code can be used by the client to prompt for re-authentication.
|
||||
func (auth *Authenticator[T, TX]) FreshReq(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
model, ok := getAuthorizedModel[T](r.Context())
|
||||
if !ok {
|
||||
|
||||
@@ -7,7 +7,26 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (auth *Authenticator[T]) RefreshAuthTokens(tx DBTransaction, w http.ResponseWriter, r *http.Request) error {
|
||||
// RefreshAuthTokens manually refreshes the user's authentication tokens.
|
||||
// This revokes the old tokens and issues new ones.
|
||||
// Requires a database transaction for token operations.
|
||||
//
|
||||
// Note: Token refresh is normally handled automatically by the Authenticate middleware.
|
||||
// Use this method only when you need explicit control over token refresh.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func refreshHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// tx, _ := db.BeginTx(r.Context(), nil)
|
||||
// defer tx.Rollback()
|
||||
// if err := auth.RefreshAuthTokens(tx, w, r); err != nil {
|
||||
// http.Error(w, "Refresh failed", http.StatusUnauthorized)
|
||||
// return
|
||||
// }
|
||||
// tx.Commit()
|
||||
// w.WriteHeader(http.StatusOK)
|
||||
// }
|
||||
func (auth *Authenticator[T, TX]) RefreshAuthTokens(tx TX, w http.ResponseWriter, r *http.Request) error {
|
||||
aT, rT, err := auth.getTokens(tx, r)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getTokens")
|
||||
@@ -21,7 +40,7 @@ func (auth *Authenticator[T]) RefreshAuthTokens(tx DBTransaction, w http.Respons
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "jwt.SetTokenCookies")
|
||||
}
|
||||
err = revokeTokenPair(tx, aT, rT)
|
||||
err = revokeTokenPair(jwt.DBTransaction(tx), aT, rT)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "revokeTokenPair")
|
||||
}
|
||||
@@ -30,17 +49,17 @@ func (auth *Authenticator[T]) RefreshAuthTokens(tx DBTransaction, w http.Respons
|
||||
}
|
||||
|
||||
// Get the tokens from the request
|
||||
func (auth *Authenticator[T]) getTokens(
|
||||
tx DBTransaction,
|
||||
func (auth *Authenticator[T, TX]) getTokens(
|
||||
tx TX,
|
||||
r *http.Request,
|
||||
) (*jwt.AccessToken, *jwt.RefreshToken, error) {
|
||||
// get the existing tokens from the cookies
|
||||
atStr, rtStr := jwt.GetTokenCookies(r)
|
||||
aT, err := auth.tokenGenerator.ValidateAccess(tx, atStr)
|
||||
aT, err := auth.tokenGenerator.ValidateAccess(jwt.DBTransaction(tx), atStr)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(err, "tokenGenerator.ValidateAccess")
|
||||
}
|
||||
rT, err := auth.tokenGenerator.ValidateRefresh(tx, rtStr)
|
||||
rT, err := auth.tokenGenerator.ValidateRefresh(jwt.DBTransaction(tx), rtStr)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(err, "tokenGenerator.ValidateRefresh")
|
||||
}
|
||||
@@ -49,7 +68,7 @@ func (auth *Authenticator[T]) getTokens(
|
||||
|
||||
// Revoke the given token pair
|
||||
func revokeTokenPair(
|
||||
tx DBTransaction,
|
||||
tx jwt.DBTransaction,
|
||||
aT *jwt.AccessToken,
|
||||
rT *jwt.RefreshToken,
|
||||
) error {
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
)
|
||||
|
||||
// Attempt to use a valid refresh token to generate a new token pair
|
||||
func (auth *Authenticator[T]) refreshAuthTokens(
|
||||
tx DBTransaction,
|
||||
func (auth *Authenticator[T, TX]) refreshAuthTokens(
|
||||
tx TX,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
rT *jwt.RefreshToken,
|
||||
) (T, error) {
|
||||
model, err := auth.load(tx, rT.SUB)
|
||||
model, err := auth.load(r.Context(), tx, rT.SUB)
|
||||
if err != nil {
|
||||
return getNil[T](), errors.Wrap(err, "auth.load")
|
||||
}
|
||||
@@ -25,12 +25,12 @@ func (auth *Authenticator[T]) refreshAuthTokens(
|
||||
}[rT.TTL]
|
||||
|
||||
// Set fresh to true because new tokens coming from refresh request
|
||||
err = jwt.SetTokenCookies(w, r, auth.tokenGenerator, model.ID(), false, rememberMe, auth.SSL)
|
||||
err = jwt.SetTokenCookies(w, r, auth.tokenGenerator, model.GetID(), false, rememberMe, auth.SSL)
|
||||
if err != nil {
|
||||
return getNil[T](), errors.Wrap(err, "jwt.SetTokenCookies")
|
||||
}
|
||||
// New tokens sent, revoke the old tokens
|
||||
err = rT.Revoke(tx)
|
||||
err = rT.Revoke(jwt.DBTransaction(tx))
|
||||
if err != nil {
|
||||
return getNil[T](), errors.Wrap(err, "rT.Revoke")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user