Add HTMX-based UI templates and handlers for account and audit management
- Introduced `web/templates/` for HTMX-fragmented pages (`dashboard`, `accounts`, `account_detail`, `error_fragment`, etc.). - Implemented UI routes for account CRUD, audit log display, and login/logout with CSRF protection. - Added `internal/ui/` package for handlers, CSRF manager, session validation, and token issuance. - Updated documentation to include new UI features and templates directory structure. - Security: Double-submit CSRF cookies, constant-time HMAC validation, login password/Argon2id re-verification at all steps to prevent bypass.
This commit is contained in:
183
internal/ui/handlers_auth.go
Normal file
183
internal/ui/handlers_auth.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
)
|
||||
|
||||
// handleLoginPage renders the login form.
|
||||
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, "login", LoginData{})
|
||||
}
|
||||
|
||||
// handleLoginPost processes username+password (and optional TOTP code).
|
||||
//
|
||||
// Security design:
|
||||
// - Password is verified via Argon2id on every request, including the TOTP
|
||||
// second step, to prevent credential-bypass by jumping to TOTP directly.
|
||||
// - Timing is held constant regardless of whether the account exists, by
|
||||
// always running a dummy Argon2 check for unknown accounts.
|
||||
// - On TOTP required: returns the totp_step fragment (200) so HTMX swaps the
|
||||
// form in place. The username and password are included as hidden fields;
|
||||
// they are re-verified on the TOTP submission.
|
||||
// - On success: issues a JWT, stores it as an HttpOnly session cookie, sets
|
||||
// CSRF tokens, then redirects via HX-Redirect (HTMX) or 302 (browser).
|
||||
func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.render(w, "totp_step", LoginData{Error: "invalid form submission"})
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
totpCode := r.FormValue("totp_code")
|
||||
|
||||
if username == "" || password == "" {
|
||||
u.render(w, "login", LoginData{Error: "username and password are required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load account by username.
|
||||
acct, err := u.db.GetAccountByUsername(username)
|
||||
if err != nil {
|
||||
// Security: always run dummy Argon2 to prevent timing-based user enumeration.
|
||||
_, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
||||
u.writeAudit(r, model.EventLoginFail, nil, nil,
|
||||
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, username))
|
||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// Security: check account status before credential verification.
|
||||
if acct.Status != model.AccountStatusActive {
|
||||
_, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
||||
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_inactive"}`)
|
||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password. Always run even if TOTP step, to prevent bypass.
|
||||
ok, err := auth.VerifyPassword(password, acct.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`)
|
||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// TOTP check.
|
||||
if acct.TOTPRequired {
|
||||
if totpCode == "" {
|
||||
// Return TOTP step fragment so HTMX swaps the form.
|
||||
u.render(w, "totp_step", LoginData{
|
||||
Username: username,
|
||||
// Security: password is embedded as a hidden form field so the
|
||||
// second submission can re-verify it. It is never logged.
|
||||
Password: password,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Decrypt and validate TOTP secret.
|
||||
secret, err := crypto.OpenAESGCM(u.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||
if err != nil {
|
||||
u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
valid, err := auth.ValidateTOTP(secret, totpCode)
|
||||
if err != nil || !valid {
|
||||
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
||||
u.render(w, "totp_step", LoginData{
|
||||
Error: "invalid TOTP code",
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Determine token expiry based on admin role.
|
||||
expiry := u.cfg.DefaultExpiry()
|
||||
roles, err := u.db.GetRoles(acct.ID)
|
||||
if err != nil {
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
for _, rol := range roles {
|
||||
if rol == "admin" {
|
||||
expiry = u.cfg.AdminExpiry()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
tokenStr, claims, err := token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
||||
if err != nil {
|
||||
u.logger.Error("issue token", "error", err)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||
u.logger.Error("track token", "error", err)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Security: set session cookie as HttpOnly, Secure, SameSite=Strict.
|
||||
// Path=/ so it is sent on all UI routes (not just /ui/*).
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: tokenStr,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: claims.ExpiresAt,
|
||||
})
|
||||
|
||||
// Set CSRF tokens for subsequent requests.
|
||||
if _, err := u.setCSRFCookies(w); err != nil {
|
||||
u.logger.Error("set CSRF cookie", "error", err)
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "")
|
||||
u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
|
||||
fmt.Sprintf(`{"jti":%q,"via":"ui"}`, claims.JTI))
|
||||
|
||||
// Redirect to dashboard.
|
||||
if isHTMX(r) {
|
||||
w.Header().Set("HX-Redirect", "/dashboard")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleLogout revokes the session token and clears the cookie.
|
||||
func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err == nil && cookie.Value != "" {
|
||||
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
||||
if err == nil {
|
||||
if revokeErr := u.db.RevokeToken(claims.JTI, "ui_logout"); revokeErr != nil {
|
||||
u.logger.Warn("revoke token on UI logout", "error", revokeErr)
|
||||
}
|
||||
u.writeAudit(r, model.EventTokenRevoked, nil, nil,
|
||||
fmt.Sprintf(`{"jti":%q,"reason":"ui_logout"}`, claims.JTI))
|
||||
}
|
||||
}
|
||||
u.clearSessionCookie(w)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
// writeAudit is a fire-and-forget audit log helper for the UI package.
|
||||
func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) {
|
||||
ip := clientIP(r)
|
||||
if err := u.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil {
|
||||
u.logger.Warn("write audit event", "type", eventType, "error", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user