Compare commits

...

5 Commits

Author SHA1 Message Date
8c2ca4d79a removed trustedhost from hws config 2026-01-13 11:32:29 +11:00
3726ad738a fixed bad import 2026-01-11 23:35:05 +11:00
423a9ee26d updated docs 2026-01-11 23:33:48 +11:00
9f98bbce2d refactored hws to improve database operability 2026-01-11 23:11:49 +11:00
4c5af63ea2 refactor to improve database operability in hwsauth 2026-01-11 23:00:50 +11:00
23 changed files with 775 additions and 155 deletions

2
hws/.gitignore vendored
View File

@@ -17,3 +17,5 @@ coverage.html
# Go workspace file
go.work
.claude/

21
hws/LICENSE Normal file
View 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
View File

@@ -0,0 +1,119 @@
# HWS (H Web Server)
[![Go Reference](https://pkg.go.dev/badge/git.haelnorr.com/h/golib/hws.svg)](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

View File

@@ -9,7 +9,6 @@ import (
type Config struct {
Host string // ENV HWS_HOST: Host to listen on (default: 127.0.0.1)
Port uint64 // ENV HWS_PORT: Port to listen on (default: 3000)
TrustedHost string // ENV HWS_TRUSTED_HOST: Domain/Hostname to accept as trusted (default: same as Host)
GZIP bool // ENV HWS_GZIP: Flag for GZIP compression on requests (default: false)
ReadHeaderTimeout time.Duration // ENV HWS_READ_HEADER_TIMEOUT: Timeout for reading request headers in seconds (default: 2)
WriteTimeout time.Duration // ENV HWS_WRITE_TIMEOUT: Timeout for writing requests in seconds (default: 10)
@@ -18,13 +17,9 @@ type Config struct {
// ConfigFromEnv returns a Config struct loaded from the environment variables
func ConfigFromEnv() (*Config, error) {
host := env.String("HWS_HOST", "127.0.0.1")
trustedHost := env.String("HWS_TRUSTED_HOST", host)
cfg := &Config{
Host: host,
Host: env.String("HWS_HOST", "127.0.0.1"),
Port: env.UInt64("HWS_PORT", 3000),
TrustedHost: trustedHost,
GZIP: env.Bool("HWS_GZIP", false),
ReadHeaderTimeout: time.Duration(env.Int("HWS_READ_HEADER_TIMEOUT", 2)) * time.Second,
WriteTimeout: time.Duration(env.Int("HWS_WRITE_TIMEOUT", 10)) * time.Second,

View File

@@ -15,7 +15,6 @@ func Test_ConfigFromEnv(t *testing.T) {
// Clear any existing env vars
os.Unsetenv("HWS_HOST")
os.Unsetenv("HWS_PORT")
os.Unsetenv("HWS_TRUSTED_HOST")
os.Unsetenv("HWS_GZIP")
os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
os.Unsetenv("HWS_WRITE_TIMEOUT")
@@ -27,7 +26,6 @@ func Test_ConfigFromEnv(t *testing.T) {
assert.Equal(t, "127.0.0.1", config.Host)
assert.Equal(t, uint64(3000), config.Port)
assert.Equal(t, "127.0.0.1", config.TrustedHost)
assert.Equal(t, false, config.GZIP)
assert.Equal(t, 2*time.Second, config.ReadHeaderTimeout)
assert.Equal(t, 10*time.Second, config.WriteTimeout)
@@ -41,7 +39,6 @@ func Test_ConfigFromEnv(t *testing.T) {
config, err := hws.ConfigFromEnv()
require.NoError(t, err)
assert.Equal(t, "192.168.1.1", config.Host)
assert.Equal(t, "192.168.1.1", config.TrustedHost) // Should match host by default
})
t.Run("Custom port", func(t *testing.T) {
@@ -53,18 +50,6 @@ func Test_ConfigFromEnv(t *testing.T) {
assert.Equal(t, uint64(8080), config.Port)
})
t.Run("Custom trusted host", func(t *testing.T) {
os.Setenv("HWS_HOST", "127.0.0.1")
os.Setenv("HWS_TRUSTED_HOST", "example.com")
defer os.Unsetenv("HWS_HOST")
defer os.Unsetenv("HWS_TRUSTED_HOST")
config, err := hws.ConfigFromEnv()
require.NoError(t, err)
assert.Equal(t, "127.0.0.1", config.Host)
assert.Equal(t, "example.com", config.TrustedHost)
})
t.Run("GZIP enabled", func(t *testing.T) {
os.Setenv("HWS_GZIP", "true")
defer os.Unsetenv("HWS_GZIP")
@@ -92,7 +77,6 @@ func Test_ConfigFromEnv(t *testing.T) {
t.Run("All custom values", func(t *testing.T) {
os.Setenv("HWS_HOST", "0.0.0.0")
os.Setenv("HWS_PORT", "9000")
os.Setenv("HWS_TRUSTED_HOST", "myapp.com")
os.Setenv("HWS_GZIP", "true")
os.Setenv("HWS_READ_HEADER_TIMEOUT", "3")
os.Setenv("HWS_WRITE_TIMEOUT", "15")
@@ -100,7 +84,6 @@ func Test_ConfigFromEnv(t *testing.T) {
defer func() {
os.Unsetenv("HWS_HOST")
os.Unsetenv("HWS_PORT")
os.Unsetenv("HWS_TRUSTED_HOST")
os.Unsetenv("HWS_GZIP")
os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
os.Unsetenv("HWS_WRITE_TIMEOUT")
@@ -111,7 +94,6 @@ func Test_ConfigFromEnv(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "0.0.0.0", config.Host)
assert.Equal(t, uint64(9000), config.Port)
assert.Equal(t, "myapp.com", config.TrustedHost)
assert.Equal(t, true, config.GZIP)
assert.Equal(t, 3*time.Second, config.ReadHeaderTimeout)
assert.Equal(t, 15*time.Second, config.WriteTimeout)

View File

@@ -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
View 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.

141
hwsauth/README.md Normal file
View File

@@ -0,0 +1,141 @@
# HWSAuth
[![Go Reference](https://pkg.go.dev/badge/git.haelnorr.com/h/golib/hwsauth.svg)](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 server
serverCfg, _ := hws.ConfigFromEnv()
server, _ := hws.NewServer(serverCfg)
// Create authenticator
auth, _ := hwsauth.NewAuthenticator[User, *sql.Tx](
cfg,
loadUser,
server,
beginTx,
logger,
errorPageFunc,
)
// Define routes
routes := []hws.Route{
{
Path: "/dashboard",
Method: hws.MethodGET,
Handler: auth.LoginReq(http.HandlerFunc(dashboardHandler)),
},
}
server.AddRoutes(routes...)
// Add authentication middleware
server.AddMiddleware(auth.Authenticate())
// Optionally ignore public paths
auth.IgnorePaths("/", "/login", "/register", "/static")
// Start server
ctx := context.Background()
server.Start(ctx)
<-server.Ready()
}
```
## 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

View File

@@ -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")
}

View File

@@ -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,

View File

@@ -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")
}

View File

@@ -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
View 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

View File

@@ -5,23 +5,21 @@ go 1.25.5
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/hws v0.2.0
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
git.haelnorr.com/h/golib/hlog v0.9.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
golang.org/x/sys v0.12.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.40.0 // indirect
k8s.io/apimachinery v0.35.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect
)

View File

@@ -2,10 +2,12 @@ git.haelnorr.com/h/golib/cookies v0.9.0 h1:Vf+eX1prHkKuGrQon1BHY87yaPc1H+HJFRXDO
git.haelnorr.com/h/golib/cookies v0.9.0/go.mod h1:y1385YExI9gLwckCVDCYVcsFXr6N7T3brJjnJD2QIuo=
git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY=
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/hlog v0.9.1 h1:9VmE/IQTfD8LAEyTbUCZLy/+8PbcHA1Kob/WQHRHKzc=
git.haelnorr.com/h/golib/hlog v0.9.1/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk=
git.haelnorr.com/h/golib/hws v0.2.0 h1:MR2Tu2qPaW+/oK8aXFJLRFaYZIHgKiex3t3zE41cu1U=
git.haelnorr.com/h/golib/hws v0.2.0/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo=
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=
@@ -18,11 +20,13 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -34,13 +38,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=
k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=

View File

@@ -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 &&

View File

@@ -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")
}

View File

@@ -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")
}

View File

@@ -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().

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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")
}