From 4c5af63ea226f7bf74de9500f25e44b5f0bbf1e5 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 11 Jan 2026 23:00:50 +1100 Subject: [PATCH] refactor to improve database operability in hwsauth --- hwsauth/LICENSE.md | 21 ++++ hwsauth/README.md | 129 +++++++++++++++++++++++ hwsauth/authenticate.go | 10 +- hwsauth/authenticator.go | 34 +++--- hwsauth/config.go | 25 +++-- hwsauth/db.go | 35 +++---- hwsauth/doc.go | 212 ++++++++++++++++++++++++++++++++++++++ hwsauth/go.mod | 6 +- hwsauth/go.sum | 4 +- hwsauth/ignorepaths.go | 10 +- hwsauth/login.go | 28 ++++- hwsauth/logout.go | 27 ++++- hwsauth/middleware.go | 20 +++- hwsauth/model.go | 38 ++++++- hwsauth/protectpage.go | 36 +++++-- hwsauth/reauthenticate.go | 33 ++++-- hwsauth/refreshtokens.go | 10 +- 17 files changed, 582 insertions(+), 96 deletions(-) create mode 100644 hwsauth/LICENSE.md create mode 100644 hwsauth/README.md create mode 100644 hwsauth/doc.go diff --git a/hwsauth/LICENSE.md b/hwsauth/LICENSE.md new file mode 100644 index 0000000..d556e61 --- /dev/null +++ b/hwsauth/LICENSE.md @@ -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. diff --git a/hwsauth/README.md b/hwsauth/README.md new file mode 100644 index 0000000..b68dd20 --- /dev/null +++ b/hwsauth/README.md @@ -0,0 +1,129 @@ +# 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 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 + diff --git a/hwsauth/authenticate.go b/hwsauth/authenticate.go index db1374d..5fea296 100644 --- a/hwsauth/authenticate.go +++ b/hwsauth/authenticate.go @@ -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") } diff --git a/hwsauth/authenticator.go b/hwsauth/authenticator.go index b588fc5..48a716c 100644 --- a/hwsauth/authenticator.go +++ b/hwsauth/authenticator.go @@ -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, diff --git a/hwsauth/config.go b/hwsauth/config.go index 3bdcb1d..c79ce00 100644 --- a/hwsauth/config.go +++ b/hwsauth/config.go @@ -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") } diff --git a/hwsauth/db.go b/hwsauth/db.go index c750a8c..d710dd3 100644 --- a/hwsauth/db.go +++ b/hwsauth/db.go @@ -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 diff --git a/hwsauth/doc.go b/hwsauth/doc.go new file mode 100644 index 0000000..f0b1be7 --- /dev/null +++ b/hwsauth/doc.go @@ -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 diff --git a/hwsauth/go.mod b/hwsauth/go.mod index b3c8ffb..5e05d68 100644 --- a/hwsauth/go.mod +++ b/hwsauth/go.mod @@ -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 diff --git a/hwsauth/go.sum b/hwsauth/go.sum index 3bdb3a2..8a5e2ba 100644 --- a/hwsauth/go.sum +++ b/hwsauth/go.sum @@ -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= diff --git a/hwsauth/ignorepaths.go b/hwsauth/ignorepaths.go index 43ac514..cd00fe3 100644 --- a/hwsauth/ignorepaths.go +++ b/hwsauth/ignorepaths.go @@ -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 && diff --git a/hwsauth/login.go b/hwsauth/login.go index f007b42..d0e4ad6 100644 --- a/hwsauth/login.go +++ b/hwsauth/login.go @@ -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") } diff --git a/hwsauth/logout.go b/hwsauth/logout.go index a9ac8d7..08fcfa4 100644 --- a/hwsauth/logout.go +++ b/hwsauth/logout.go @@ -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") } diff --git a/hwsauth/middleware.go b/hwsauth/middleware.go index 56040bf..f9dbaad 100644 --- a/hwsauth/middleware.go +++ b/hwsauth/middleware.go @@ -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(). diff --git a/hwsauth/model.go b/hwsauth/model.go index 4c08c53..d37c761 100644 --- a/hwsauth/model.go +++ b/hwsauth/model.go @@ -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 diff --git a/hwsauth/protectpage.go b/hwsauth/protectpage.go index 6b82423..6b7f1a6 100644 --- a/hwsauth/protectpage.go +++ b/hwsauth/protectpage.go @@ -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 { diff --git a/hwsauth/reauthenticate.go b/hwsauth/reauthenticate.go index a8b95cf..ce9db58 100644 --- a/hwsauth/reauthenticate.go +++ b/hwsauth/reauthenticate.go @@ -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 { diff --git a/hwsauth/refreshtokens.go b/hwsauth/refreshtokens.go index 9cb5ac0..c69a0dc 100644 --- a/hwsauth/refreshtokens.go +++ b/hwsauth/refreshtokens.go @@ -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") }