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) } }