updated HWSAuth doc

2026-01-11 22:58:17 +11:00
parent cbfb157b72
commit ef48eaeb40

@@ -36,17 +36,38 @@ This eliminates type assertions and provides compile-time type checking.
## Quick Start
### 1. User Model
### 1. User Model (Bun ORM)
```go
type User struct {
UserID int
Username string
Email string
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"`
}
func (u User) ID() int {
return u.UserID
// 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
}
```
@@ -63,42 +84,62 @@ HWSAUTH_REFRESH_TOKEN_EXPIRY=1440
HWSAUTH_LANDING_PAGE=/dashboard
```
Load config:
Load config using `ConfigFromEnv()`:
```go
// 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
```go
beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
return db.BeginTx(ctx, nil)
}
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
}
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
}
// Configure ignored paths
auth.IgnorePaths("/", "/static/*", "/healthz")
auth, err := hwsauth.NewAuthenticator[User, *sql.Tx](
cfg,
loadUser,
server,
beginTx,
logger,
errorPage,
)
return auth, nil
}
```
### 4. Apply Middleware
```go
server.AddMiddleware(auth.Authenticate())
auth.IgnorePaths("/", "/login", "/register")
// Add middleware to server
err := server.AddMiddleware(auth.Authenticate())
if err != nil {
return err
}
```
## Core Features
@@ -112,7 +153,7 @@ server.AddMiddleware(auth.Authenticate())
**IgnorePaths()** - Exclude paths from authentication:
```go
auth.IgnorePaths("/public", "/health")
auth.IgnorePaths("/public", "/healthz")
```
### Route Guards
@@ -294,76 +335,342 @@ Function to create database transactions.
7. **Rate limiting**: Protect authentication endpoints
8. **HTTPS only**: Never send tokens over HTTP
## Common Patterns
## Complete Examples
### Protected Dashboard
### Route Setup with LoginReq, LogoutReq, and FreshReq
```go
func setupRoutes(server *hws.Server, auth *hwsauth.Authenticator[User, *sql.Tx]) {
// Public routes
server.AddRoute("GET", "/", homeHandler)
server.AddRoute("GET", "/login", auth.LogoutReq(loginPageHandler))
server.AddRoute("POST", "/login", loginSubmitHandler)
// Protected routes
server.AddRoute("GET", "/dashboard",
auth.LoginReq(dashboardHandler))
server.AddRoute("GET", "/profile",
auth.LoginReq(profileHandler))
// Sensitive operations
server.AddRoute("POST", "/change-password",
auth.LoginReq(auth.FreshReq(changePasswordHandler)))
func setupRoutes(
server *hws.Server,
auth *hwsauth.Authenticator[*User, bun.Tx],
db *bun.DB,
) error {
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(server, auth, db)),
},
{
Path: "/register",
Method: hws.MethodGET,
Handler: auth.LogoutReq(registerPageHandler),
},
{
Path: "/register",
Method: hws.MethodPOST,
Handler: auth.LogoutReq(registerSubmitHandler(server, auth, db)),
},
// Logout - accessible to anyone
{
Path: "/logout",
Method: hws.MethodPOST,
Handler: logoutHandler(server, 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(server, 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(server, auth, db))),
},
{
Path: "/change-username",
Method: hws.MethodPOST,
Handler: auth.LoginReq(auth.FreshReq(changeUsernameHandler(server, auth, db))),
},
// Regular authenticated routes (no fresh token required)
{
Path: "/change-bio",
Method: hws.MethodPOST,
Handler: auth.LoginReq(changeBioHandler(server, auth, db)),
},
}
return server.AddRoutes(routes...)
}
```
### Login Handler
### Login Handler with Transaction Management
```go
func loginHandler(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
user, err := validateCredentials(username, password)
if err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
func loginSubmitHandler(
server *hws.Server,
auth *hwsauth.Authenticator[*User, bun.Tx],
db *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
rememberMe := r.FormValue("remember_me") == "on"
err = auth.Login(w, r, user, rememberMe)
if err != nil {
http.Error(w, "Login failed", http.StatusInternalServerError)
return
}
// Start database transaction
tx, err := db.BeginTx(ctx, nil)
if err != nil {
server.ThrowError(w, r, hws.HWSError{
StatusCode: http.StatusServiceUnavailable,
Message: "Login failed",
Error: err,
})
return
}
defer tx.Rollback()
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
// 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 {
server.ThrowError(w, r, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "Login failed",
Error: err,
})
return
}
tx.Commit()
// Redirect to dashboard or previous page
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
})
}
```
### Registration with Auto-Login
```go
func registerSubmitHandler(
server *hws.Server,
auth *hwsauth.Authenticator[*User, bun.Tx],
db *bun.DB,
) http.Handler {
return http.HandlerFunc(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 {
server.ThrowError(w, r, hws.HWSError{
StatusCode: http.StatusServiceUnavailable,
Message: "Registration failed",
Error: err,
})
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 {
server.ThrowError(w, r, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "Registration failed",
Error: err,
})
return
}
if exists {
renderRegisterForm(w, r, "Username is taken")
return
}
// Create user
user, err := createUser(ctx, tx, username, password)
if err != nil {
server.ThrowError(w, r, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "Registration failed",
Error: err,
})
return
}
// Auto-login after registration
rememberMe := r.FormValue("remember-me") == "on"
err = auth.Login(w, r, user, rememberMe)
if err != nil {
server.ThrowError(w, r, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "Login failed",
Error: err,
})
return
}
tx.Commit()
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
})
}
```
### Logout Handler
```go
func logoutHandler(w http.ResponseWriter, r *http.Request) {
tx, _ := db.BeginTx(r.Context(), nil)
defer tx.Rollback()
func logoutHandler(
server *hws.Server,
auth *hwsauth.Authenticator[*User, bun.Tx],
db *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
if err := auth.Logout(tx, w, r); err != nil {
http.Error(w, "Logout failed", http.StatusInternalServerError)
return
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
server.ThrowError(w, r, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "Logout failed",
Error: err,
})
return
}
defer tx.Rollback()
tx.Commit()
http.Redirect(w, r, "/", http.StatusSeeOther)
// Logout - clears cookies and revokes tokens in database
err = auth.Logout(tx, w, r)
if err != nil {
server.ThrowError(w, r, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "Logout failed",
Error: err,
})
return
}
tx.Commit()
http.Redirect(w, r, "/login", http.StatusSeeOther)
})
}
```
### Access Current User
### Reauthentication for Fresh Token Requirement
```go
func reauthenticateHandler(
server *hws.Server,
auth *hwsauth.Authenticator[*User, bun.Tx],
db *bun.DB,
) http.Handler {
return http.HandlerFunc(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 {
server.ThrowError(w, r, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "Reauthentication failed",
Error: err,
})
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 {
server.ThrowError(w, r, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to refresh tokens",
Error: err,
})
return
}
tx.Commit()
w.WriteHeader(http.StatusOK)
})
}
```
### Protected Handler - Accessing Current User
```go
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
// Get the authenticated user from context
user := auth.CurrentModel(r.Context())
if user.ID() == 0 {
// This check is optional since LoginReq already ensures authentication
if user.GetID() == 0 {
http.Error(w, "Not authenticated", http.StatusUnauthorized)
return
}
@@ -373,7 +680,59 @@ func dashboardHandler(w http.ResponseWriter, r *http.Request) {
Email: user.Email,
}
renderTemplate(w, "dashboard", data)
renderTemplate(w, "dashboard.html", data)
}
```
### Sensitive Operation with Fresh Token
```go
func changePasswordHandler(
server *hws.Server,
auth *hwsauth.Authenticator[*User, bun.Tx],
db *bun.DB,
) http.Handler {
return http.HandlerFunc(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 {
server.ThrowError(w, r, hws.HWSError{
StatusCode: http.StatusServiceUnavailable,
Message: "Failed to change password",
Error: err,
})
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 {
server.ThrowError(w, r, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to change password",
Error: err,
})
return
}
tx.Commit()
http.Redirect(w, r, "/profile", http.StatusSeeOther)
})
}
```
@@ -413,3 +772,4 @@ func dashboardHandler(w http.ResponseWriter, r *http.Request) {
- [Source Code](https://git.haelnorr.com/h/golib/hwsauth)
- [Issue Tracker](https://git.haelnorr.com/h/golib/hwsauth/issues)
- [Examples](https://git.haelnorr.com/h/golib/hwsauth/tree/master/examples)