Table of Contents
- HWSAuth - v0.3.4
- Overview
- Installation
- Key Concepts
- Quick Start
- 1. User Model (Bun ORM)
- 2. Configuration
- 3. Create Authenticator
- 4. Apply Middleware
- 5. (Optional) Global Context Loader
- Core Features
- ORM Integration
- Configuration Reference
- Interfaces
- Security Best Practices
- Complete Examples
- Route Setup with LoginReq, LogoutReq, and FreshReq
- Login Handler with Transaction Management
- Registration with Auto-Login
- Logout Handler
- Reauthentication for Fresh Token Requirement
- Protected Handler - Accessing Current User
- Sensitive Operation with Fresh Token
- Troubleshooting
- Integration
- See Also
- Links
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
- User Login: Credentials are validated and JWT tokens (access + refresh) are issued
- Request Authentication: Middleware validates tokens on each request
- Automatic Refresh: Expired access tokens are refreshed using valid refresh tokens
- 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 implementModelinterface)TX: Your transaction type (must implementDBTransactioninterface)
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
- Use SSL in production: Set
HWSAUTH_SSL=true - Strong secret keys: Generate with
openssl rand -base64 32 - Appropriate expiry times: Balance security and UX
- Fresh tokens for sensitive ops: Use
FreshReqmiddleware - HTTP-only cookies: Tokens stored securely by default
- Parameterized queries: Prevent SQL injection
- Rate limiting: Protect authentication endpoints
- 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_SSLmatches your environment - Verify
HWSAUTH_TRUSTED_HOSTis 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
TXtype parameter matches yourbeginTxreturn type - Verify
LoadFuncaccepts the correct transaction type - Check ORM transaction compatibility
"Secret key is required"
- Set
HWSAUTH_SECRET_KEYenvironment variable - Or provide in
Configstruct
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
Links
- GoDoc - API documentation
- Source Code - Repository
- Issue Tracker - Report bugs
- Examples - Code examples