diff --git a/HWSAuth.md b/HWSAuth.md index 5b04252..df158cb 100644 --- a/HWSAuth.md +++ b/HWSAuth.md @@ -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) +