Clone
7
HWSAuth
Haelnorr edited this page 2026-01-13 21:22:53 +11:00

HWSAuth - v0.3.4

JWT-based authentication middleware for the hws web framework.

Overview

hwsauth provides a complete authentication solution for HWS web applications using JSON Web Tokens (JWT). It handles access tokens, refresh tokens, automatic token rotation, and integrates seamlessly with any database or ORM.

Database Flexibility: hwsauth works with any database backend - from the standard library's database/sql to popular ORMs like GORM, Bun, SQLC, and Ent. The examples below use Bun ORM, but you can easily adapt them to your preferred database solution. See the ORM Integration section for examples with different backends.

Installation

go get git.haelnorr.com/h/golib/hwsauth

Key Concepts

Authentication Flow

  1. User Login: Credentials are validated and JWT tokens (access + refresh) are issued
  2. Request Authentication: Middleware validates tokens on each request
  3. Automatic Refresh: Expired access tokens are refreshed using valid refresh tokens
  4. Token Freshness: Sensitive operations require recently issued tokens

Type Safety with Generics

hwsauth uses Go generics for type safety:

Authenticator[T Model, TX DBTransaction]
  • T: Your user model type (must implement Model interface)
  • TX: Your transaction type (must implement DBTransaction interface)

This eliminates type assertions and provides compile-time type checking.

Quick Start

1. User Model (Bun ORM)

type User struct {
    bun.BaseModel `bun:"table:users,alias:u"`

    ID           int    `bun:"id,pk,autoincrement"`
    Username     string `bun:"username,unique"`
    PasswordHash string `bun:"password_hash"`
    Email        string `bun:"email"`
}

// Required by HWSAuth
func (u *User) GetID() int {
    return u.ID
}

// User lookup function for HWSAuth
func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) {
    user := new(User)
    err := tx.NewSelect().
        Model(user).
        Where("id = ?", id).
        Limit(1).
        Scan(ctx)
    if err != nil {
        if err.Error() == "sql: no rows in result set" {
            return nil, nil
        }
        return nil, err
    }
    return user, nil
}

2. Configuration

Environment variables:

HWSAUTH_SECRET_KEY=your-secret-key
HWSAUTH_SSL=true
HWSAUTH_TRUSTED_HOST=https://example.com
HWSAUTH_ACCESS_TOKEN_EXPIRY=5
HWSAUTH_REFRESH_TOKEN_EXPIRY=1440
HWSAUTH_LANDING_PAGE=/dashboard

Load config using ConfigFromEnv():

// ConfigFromEnv reads all HWSAUTH_* environment variables
// and returns a ready-to-use Config struct
cfg, err := hwsauth.ConfigFromEnv()
if err != nil {
    return nil, err
}

The ConfigFromEnv() function automatically loads all configuration from environment variables and validates required fields (like HWSAUTH_SECRET_KEY). This is the recommended way to configure HWSAuth.

3. Create Authenticator

func setupAuth(
    config *Config,
    logger *hlog.Logger,
    db *bun.DB,
    server *hws.Server,
) (*hwsauth.Authenticator[*User, bun.Tx], error) {
    // Define the BeginTX function
    beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
        tx, err := db.BeginTx(ctx, nil)
        return tx, err
    }
    
    // Create the authenticator
    auth, err := hwsauth.NewAuthenticator(
        config.HWSAuth,
        GetUserByID,
        server,
        beginTx,
        logger,
        errorPageHandler,
    )
    if err != nil {
        return nil, err
    }

    // Configure ignored paths
    auth.IgnorePaths("/", "/static/*", "/healthz")

    return auth, nil
}

4. Apply Middleware

// Add middleware to server
err := server.AddMiddleware(auth.Authenticate())
if err != nil {
    return err
}

5. (Optional) Global Context Loader

For accessing the current user across your application (especially useful in templates), you can create a global ContextLoader:

package contexts

import (
    "yourapp/internal/models"
    "git.haelnorr.com/h/golib/hwsauth"
)

// Global variable for accessing current user from context
var CurrentUser hwsauth.ContextLoader[*models.User]

Then in your auth setup, assign the authenticator's CurrentModel method:

func setupAuth(...) (*hwsauth.Authenticator[*User, bun.Tx], error) {
    // ... create authenticator ...
    
    // Set up global context loader
    contexts.CurrentUser = auth.CurrentModel
    
    return auth, nil
}

Now you can access the current user anywhere in your application:

// In handlers
func profileHandler(w http.ResponseWriter, r *http.Request) {
    user := contexts.CurrentUser(r.Context())
    // use user...
}

// In templates (templ example)
templ Profile() {
    {{ user := contexts.CurrentUser(ctx) }}
    <h1>Hello, { user.Username }</h1>
}

Note: The user will be nil if not authenticated, so check for nil in non-protected routes.

Core Features

Middleware

Authenticate() - Main authentication middleware:

server.AddMiddleware(auth.Authenticate())

IgnorePaths() - Exclude paths from authentication:

auth.IgnorePaths("/public", "/healthz")

Route Guards

LoginReq - Require authentication:

protectedHandler := auth.LoginReq(myHandler)

LogoutReq - Redirect authenticated users:

loginPageHandler := auth.LogoutReq(showLoginForm)

FreshReq - Require fresh authentication:

sensitiveHandler := auth.FreshReq(changePasswordHandler)

Authentication Operations

Login - Authenticate user and set cookies:

err := auth.Login(w, r, user, rememberMe)

Logout - Clear authentication and revoke tokens:

err := auth.Logout(tx, w, r)

CurrentModel - Get authenticated user:

user := auth.CurrentModel(r.Context())

RefreshAuthTokens - Manually refresh tokens:

err := auth.RefreshAuthTokens(tx, w, r)

ORM Integration

Standard Library (database/sql)

beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
    return db.BeginTx(ctx, nil)
}

loadUser := func(ctx context.Context, tx *sql.Tx, id int) (User, error) {
    // Use tx to query database
    return user, err
}

auth := hwsauth.NewAuthenticator[User, *sql.Tx](...)

GORM

beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
    gormTx := gormDB.WithContext(ctx).Begin()
    return gormTx.Statement.ConnPool.(*sql.Tx), nil
}

loadUser := func(ctx context.Context, tx *gorm.DB, id int) (User, error) {
    var user User
    err := gormDB.WithContext(ctx).First(&user, id).Error
    return user, err
}

auth := hwsauth.NewAuthenticator[User, *gorm.DB](...)

Bun

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 := hwsauth.NewAuthenticator[User, bun.Tx](...)

Configuration Reference

Config Struct

type Config struct {
    SSL                bool   // Enable SSL cookies
    TrustedHost        string // Trusted host for SSL
    SecretKey          string // JWT signing key (required)
    AccessTokenExpiry  int64  // Access token expiry (minutes)
    RefreshTokenExpiry int64  // Refresh token expiry (minutes)
    TokenFreshTime     int64  // Fresh token duration (minutes)
    LandingPage        string // Logged-in user landing page
    DatabaseType       string // Database type (postgres, mysql, etc.)
    DatabaseVersion    string // Database version
    JWTTableName       string // Custom JWT table name
}

Environment Variables

Variable Description Default Required
HWSAUTH_SSL Enable SSL cookies false No
HWSAUTH_TRUSTED_HOST Trusted host - If SSL=true
HWSAUTH_SECRET_KEY JWT signing key - Yes
HWSAUTH_ACCESS_TOKEN_EXPIRY Access expiry (min) 5 No
HWSAUTH_REFRESH_TOKEN_EXPIRY Refresh expiry (min) 1440 No
HWSAUTH_TOKEN_FRESH_TIME Fresh time (min) 5 No
HWSAUTH_LANDING_PAGE Landing page /profile No
HWSAUTH_DATABASE_TYPE DB type - No
HWSAUTH_DATABASE_VERSION DB version - No
HWSAUTH_JWT_TABLE_NAME JWT table name - No

Interfaces

Model

User models must implement:

type Model interface {
    ID() int
}

DBTransaction

Transaction types must implement:

type DBTransaction interface {
    Commit() error
    Rollback() error
}

Standard *sql.Tx implements this automatically.

LoadFunc

type LoadFunc[T Model, TX DBTransaction] func(
    ctx context.Context,
    tx TX,
    id int,
) (T, error)

Function to load users from database.

BeginTX

type BeginTX func(ctx context.Context) (DBTransaction, error)

Function to create database transactions.

Security Best Practices

  1. Use SSL in production: Set HWSAUTH_SSL=true
  2. Strong secret keys: Generate with openssl rand -base64 32
  3. Appropriate expiry times: Balance security and UX
  4. Fresh tokens for sensitive ops: Use FreshReq middleware
  5. HTTP-only cookies: Tokens stored securely by default
  6. Parameterized queries: Prevent SQL injection
  7. Rate limiting: Protect authentication endpoints
  8. HTTPS only: Never send tokens over HTTP

Complete Examples

The examples below focus on HWSAuth-specific functionality using basic error handling. For complete HWS server setup, middleware configuration, and advanced error handling patterns, see the HWS documentation.

Route Setup with LoginReq, LogoutReq, and FreshReq

routes := []hws.Route{
    // Public routes - no authentication required
    {
        Path:    "/",
        Method:  hws.MethodGET,
        Handler: homeHandler,
    },
    
    // LogoutReq - requires user to NOT be logged in
    // Redirects authenticated users away
    {
        Path:    "/login",
        Method:  hws.MethodGET,
        Handler: auth.LogoutReq(loginPageHandler),
    },
    {
        Path:    "/login",
        Method:  hws.MethodPOST,
        Handler: auth.LogoutReq(loginSubmitHandler(auth, db)),
    },
    {
        Path:    "/register",
        Method:  hws.MethodGET,
        Handler: auth.LogoutReq(registerPageHandler),
    },
    {
        Path:    "/register",
        Method:  hws.MethodPOST,
        Handler: auth.LogoutReq(registerSubmitHandler(auth, db)),
    },
    
    // Logout - accessible to anyone
    {
        Path:    "/logout",
        Method:  hws.MethodPOST,
        Handler: logoutHandler(auth, db),
    },
    
    // LoginReq - requires user to be authenticated
    {
        Path:    "/dashboard",
        Method:  hws.MethodGET,
        Handler: auth.LoginReq(dashboardHandler),
    },
    {
        Path:    "/profile",
        Method:  hws.MethodGET,
        Handler: auth.LoginReq(profileHandler),
    },
    
    // Reauthentication endpoint for sensitive operations
    {
        Path:    "/reauthenticate",
        Method:  hws.MethodPOST,
        Handler: auth.LoginReq(reauthenticateHandler(auth, db)),
    },
    
    // FreshReq - requires fresh authentication (recent login)
    // Used for sensitive operations like changing password/username
    {
        Path:    "/change-password",
        Method:  hws.MethodPOST,
        Handler: auth.LoginReq(auth.FreshReq(changePasswordHandler(auth, db))),
    },
    {
        Path:    "/change-username",
        Method:  hws.MethodPOST,
        Handler: auth.LoginReq(auth.FreshReq(changeUsernameHandler(auth, db))),
    },
    
    // Regular authenticated routes (no fresh token required)
    {
        Path:    "/change-bio",
        Method:  hws.MethodPOST,
        Handler: auth.LoginReq(changeBioHandler(auth, db)),
    },
}

server.AddRoutes(routes...)

Login Handler with Transaction Management

func loginSubmitHandler(
    auth *hwsauth.Authenticator[*User, bun.Tx],
    db *bun.DB,
) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
        defer cancel()

        // Start database transaction
        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            http.Error(w, "Login failed", http.StatusServiceUnavailable)
            return
        }
        defer tx.Rollback()

        // Parse form data
        r.ParseForm()
        username := r.FormValue("username")
        password := r.FormValue("password")

        // Validate credentials
        user, err := getUserByUsername(ctx, tx, username)
        if err != nil || user == nil {
            renderLoginForm(w, r, "Invalid username or password")
            return
        }

        err = user.CheckPassword(ctx, tx, password)
        if err != nil {
            renderLoginForm(w, r, "Invalid username or password")
            return
        }

        // Check if "remember me" is enabled
        rememberMe := r.FormValue("remember-me") == "on"

        // Login user - sets authentication cookies
        err = auth.Login(w, r, user, rememberMe)
        if err != nil {
            http.Error(w, "Login failed", http.StatusInternalServerError)
            return
        }

        tx.Commit()
        http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
    }
}

Registration with Auto-Login

func registerSubmitHandler(
    auth *hwsauth.Authenticator[*User, bun.Tx],
    db *bun.DB,
) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
        defer cancel()

        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            http.Error(w, "Registration failed", http.StatusServiceUnavailable)
            return
        }
        defer tx.Rollback()

        r.ParseForm()
        username := r.FormValue("username")
        password := r.FormValue("password")
        confirmPassword := r.FormValue("confirm-password")

        // Validate passwords match
        if password != confirmPassword {
            renderRegisterForm(w, r, "Passwords do not match")
            return
        }

        // Check if username is unique
        exists, err := usernameExists(ctx, tx, username)
        if err != nil {
            http.Error(w, "Registration failed", http.StatusInternalServerError)
            return
        }
        if exists {
            renderRegisterForm(w, r, "Username is taken")
            return
        }

        // Create user
        user, err := createUser(ctx, tx, username, password)
        if err != nil {
            http.Error(w, "Registration failed", http.StatusInternalServerError)
            return
        }

        // Auto-login after registration
        rememberMe := r.FormValue("remember-me") == "on"
        err = auth.Login(w, r, user, rememberMe)
        if err != nil {
            http.Error(w, "Login failed", http.StatusInternalServerError)
            return
        }

        tx.Commit()
        http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
    }
}

Logout Handler

func logoutHandler(
    auth *hwsauth.Authenticator[*User, bun.Tx],
    db *bun.DB,
) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
        defer cancel()

        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            http.Error(w, "Logout failed", http.StatusInternalServerError)
            return
        }
        defer tx.Rollback()

        // Logout - clears cookies and revokes tokens in database
        err = auth.Logout(tx, w, r)
        if err != nil {
            http.Error(w, "Logout failed", http.StatusInternalServerError)
            return
        }

        tx.Commit()
        http.Redirect(w, r, "/login", http.StatusSeeOther)
    }
}

Reauthentication for Fresh Token Requirement

func reauthenticateHandler(
    auth *hwsauth.Authenticator[*User, bun.Tx],
    db *bun.DB,
) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
        defer cancel()

        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            http.Error(w, "Reauthentication failed", http.StatusInternalServerError)
            return
        }
        defer tx.Rollback()

        // Get current user from context
        user := auth.CurrentModel(r.Context())

        // Validate password
        r.ParseForm()
        password := r.FormValue("password")
        err = user.CheckPassword(ctx, tx, password)
        if err != nil {
            w.WriteHeader(http.StatusUnauthorized)
            renderPasswordPrompt(w, r, "Incorrect password")
            return
        }

        // Refresh tokens to make them "fresh"
        err = auth.RefreshAuthTokens(tx, w, r)
        if err != nil {
            http.Error(w, "Failed to refresh tokens", http.StatusInternalServerError)
            return
        }

        tx.Commit()
        w.WriteHeader(http.StatusOK)
    }
}

Protected Handler - Accessing Current User

func dashboardHandler(w http.ResponseWriter, r *http.Request) {
    // Get the authenticated user from context
    user := auth.CurrentModel(r.Context())
    
    // This check is optional since LoginReq already ensures authentication
    if user.GetID() == 0 {
        http.Error(w, "Not authenticated", http.StatusUnauthorized)
        return
    }

    data := DashboardData{
        Username: user.Username,
        Email:    user.Email,
    }
    
    renderTemplate(w, "dashboard.html", data)
}

Sensitive Operation with Fresh Token

func changePasswordHandler(
    auth *hwsauth.Authenticator[*User, bun.Tx],
    db *bun.DB,
) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
        defer cancel()

        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            http.Error(w, "Failed to change password", http.StatusServiceUnavailable)
            return
        }
        defer tx.Rollback()

        // Get current user - guaranteed to exist due to LoginReq + FreshReq
        user := auth.CurrentModel(r.Context())

        r.ParseForm()
        newPassword := r.FormValue("new-password")
        confirmPassword := r.FormValue("confirm-password")

        if newPassword != confirmPassword {
            renderChangePasswordForm(w, r, "Passwords do not match")
            return
        }

        // Update password
        err = user.UpdatePassword(ctx, tx, newPassword)
        if err != nil {
            http.Error(w, "Failed to change password", http.StatusInternalServerError)
            return
        }

        tx.Commit()
        http.Redirect(w, r, "/profile", http.StatusSeeOther)
    }
}

Troubleshooting

Tokens not persisting

  • Check HWSAUTH_SSL matches your environment
  • Verify HWSAUTH_TRUSTED_HOST is correct
  • Ensure cookies are enabled in browser

User not authenticated

  • Check middleware is applied: server.AddMiddleware(auth.Authenticate())
  • Verify path isn't in ignored paths
  • Check token hasn't expired

Transaction type errors

  • Ensure your TX type parameter matches your beginTx return type
  • Verify LoadFunc accepts the correct transaction type
  • Check ORM transaction compatibility

"Secret key is required"

  • Set HWSAUTH_SECRET_KEY environment variable
  • Or provide in Config struct

Integration

With ezconf

HWSAuth includes built-in integration with ezconf for unified configuration management:

import (
	"git.haelnorr.com/h/golib/ezconf"
	"git.haelnorr.com/h/golib/hlog"
	"git.haelnorr.com/h/golib/hws"
	"git.haelnorr.com/h/golib/hwsauth"
)

func main() {
	// Create ezconf loader
	loader := ezconf.New()

	// Register all packages
	loader.RegisterIntegrations(
		hws.NewEZConfIntegration(),
		hlog.NewEZConfIntegration(),
		hwsauth.NewEZConfIntegration(),
	)

	// Load all configurations
	if err := loader.Load(); err != nil {
		log.Fatal(err)
	}

	// Get configurations
	authCfg, _ := loader.GetConfig("hwsauth")
	cfg := authCfg.(*hwsauth.Config)

	// Use configuration
	auth, _ := hwsauth.NewAuthenticator[*User, bun.Tx](
		cfg,
		loadUserFunc,
		server,
		beginTxFunc,
		logger,
		errorPageFunc,
	)
}

Benefits of using ezconf:

  • Unified configuration across hws, hwsauth, hlog, and other packages
  • Automatic environment variable documentation
  • Generate and manage .env files for your entire application

See the ezconf documentation for more details.

See Also

  • HWS - H Web Server framework
  • JWT - JWT token library
  • env - Environment variable loading