Files
mcias/internal/ui/handlers_auth.go
Kyle Isom 25417b24f4 Add FIDO2/WebAuthn passkey authentication
Phase 14: Full WebAuthn support for passwordless passkey login and
hardware security key 2FA.

- go-webauthn/webauthn v0.16.1 dependency
- WebAuthnConfig with RPID/RPOrigin/DisplayName validation
- Migration 000009: webauthn_credentials table
- DB CRUD with ownership checks and admin operations
- internal/webauthn adapter: encrypt/decrypt at rest with AES-256-GCM
- REST: register begin/finish, login begin/finish, list, delete
- Web UI: profile enrollment, login passkey button, admin management
- gRPC: ListWebAuthnCredentials, RemoveWebAuthnCredential RPCs
- mciasdb: webauthn list/delete/reset subcommands
- OpenAPI: 6 new endpoints, WebAuthnCredentialInfo schema
- Policy: self-service enrollment rule, admin remove via wildcard
- Tests: DB CRUD, adapter round-trip, interface compliance
- Docs: ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14

Security: Credential IDs and public keys encrypted at rest with
AES-256-GCM via vault master key. Challenge ceremonies use 128-bit
nonces with 120s TTL in sync.Map. Sign counter validated on each
assertion to detect cloned authenticators. Password re-auth required
for registration (SEC-01 pattern). No credential material in API
responses or logs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:12:59 -07:00

434 lines
15 KiB
Go

package ui
import (
"net/http"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"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"
"git.wntrmute.dev/kyle/mcias/internal/validate"
)
// handleLoginPage renders the login form.
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
u.render(w, "login", LoginData{
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
})
}
// handleLoginPost processes username+password (step 1) or TOTP code (step 2).
//
// Security design (F-02 fix):
// - Step 1: username+password submitted. Password verified via Argon2id.
// On success with TOTP required, a 90-second single-use server-side nonce
// is issued and its account ID stored in pendingLogins. Only the nonce
// (not the password) is embedded in the TOTP step HTML form, so the
// plaintext password is never sent over the wire a second time and never
// appears in the DOM during the TOTP step.
// - Step 2: totp_step=1 form submitted. The nonce is consumed (single-use,
// expiry checked) to retrieve the account ID; no password is needed.
// TOTP code is then verified against the decrypted stored secret.
// - Timing is held constant for unknown accounts by always running a dummy
// Argon2 check, preventing username enumeration.
// - On final success: JWT issued, stored as HttpOnly session cookie.
func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.render(w, "login", LoginData{Error: "invalid form submission"})
return
}
// Step 2: TOTP confirmation (totp_step=1 was set by step 1's rendered form).
if r.FormValue("totp_step") == "1" {
u.handleTOTPStep(w, r)
return
}
// Step 1: password verification.
username := r.FormValue("username")
password := r.FormValue("password")
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", u.dummyHash())
u.writeAudit(r, model.EventLoginFail, nil, nil,
audit.JSON("username", username, "reason", "unknown_user"))
u.render(w, "login", LoginData{Error: "invalid credentials"})
return
}
// Security: check account status before credential verification.
if acct.Status != model.AccountStatusActive {
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_inactive"}`)
u.render(w, "login", LoginData{Error: "invalid credentials"})
return
}
// Security: check per-account lockout before running Argon2 (F-08).
locked, lockErr := u.db.IsLockedOut(acct.ID)
if lockErr != nil {
u.logger.Error("lockout check", "error", lockErr)
}
if locked {
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
// Security: return the same "invalid credentials" as wrong-password
// to prevent user-enumeration via lockout differentiation (SEC-02).
u.render(w, "login", LoginData{Error: "invalid credentials"})
return
}
// Verify password.
ok, err := auth.VerifyPassword(password, acct.PasswordHash)
if err != nil || !ok {
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`)
_ = u.db.RecordLoginFailure(acct.ID)
u.render(w, "login", LoginData{Error: "invalid credentials"})
return
}
// TOTP required: issue a server-side nonce and show the TOTP step form.
// Security: the nonce replaces the password hidden field (F-02). The password
// is not stored anywhere after this point; only the account ID is retained.
if acct.TOTPRequired {
nonce, err := u.issueTOTPNonce(acct.ID)
if err != nil {
u.logger.Error("issue TOTP nonce", "error", err)
u.render(w, "login", LoginData{Error: "internal error"})
return
}
u.render(w, "totp_step", LoginData{
Username: username,
Nonce: nonce,
})
return
}
u.finishLogin(w, r, acct)
}
// handleTOTPStep handles the second POST when totp_step=1 is set.
// It consumes the single-use nonce to retrieve the account, then validates
// the submitted TOTP code before completing the login.
//
// The body has already been limited by MaxBytesReader in handleLoginPost
// before ParseForm was called; r.FormValue reads from the already-parsed
// in-memory form cache, not the network stream.
func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
// Body is already size-limited and parsed by the caller (handleLoginPost).
username := r.FormValue("username") //nolint:gosec // body already limited by caller
nonce := r.FormValue("totp_nonce") //nolint:gosec // body already limited by caller
totpCode := r.FormValue("totp_code") //nolint:gosec // body already limited by caller
// Security: consume the nonce (single-use); reject if unknown or expired.
accountID, ok := u.consumeTOTPNonce(nonce)
if !ok {
u.writeAudit(r, model.EventLoginFail, nil, nil,
audit.JSON("username", username, "reason", "invalid_totp_nonce"))
u.render(w, "login", LoginData{Error: "session expired, please log in again"})
return
}
acct, err := u.db.GetAccountByID(accountID)
if err != nil {
u.logger.Error("get account for TOTP step", "error", err, "account_id", accountID)
u.render(w, "login", LoginData{Error: "internal error"})
return
}
// Decrypt and validate TOTP secret.
masterKey, err := u.vault.MasterKey()
if err != nil {
u.render(w, "login", LoginData{Error: "internal error"})
return
}
secret, err := crypto.OpenAESGCM(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, totpCounter, err := auth.ValidateTOTP(secret, totpCode)
if err != nil || !valid {
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
_ = u.db.RecordLoginFailure(acct.ID)
// Re-issue a fresh nonce so the user can retry without going back to step 1.
newNonce, nonceErr := u.issueTOTPNonce(acct.ID)
if nonceErr != nil {
u.render(w, "login", LoginData{Error: "internal error"})
return
}
u.render(w, "totp_step", LoginData{
Error: "invalid TOTP code",
Username: username,
Nonce: newNonce,
})
return
}
// Security (CRIT-01): reject replay of a code already used within its
// ±30-second validity window.
if err := u.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"totp_replay"}`)
_ = u.db.RecordLoginFailure(acct.ID)
newNonce, nonceErr := u.issueTOTPNonce(acct.ID)
if nonceErr != nil {
u.render(w, "login", LoginData{Error: "internal error"})
return
}
u.render(w, "totp_step", LoginData{
Error: "invalid TOTP code",
Username: username,
Nonce: newNonce,
})
return
}
u.finishLogin(w, r, acct)
}
// finishLogin issues a JWT, sets the session cookie, and redirects to dashboard.
func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *model.Account) {
// 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
}
}
// Login succeeded: clear any outstanding failure counter.
_ = u.db.ClearLoginFailures(acct.ID)
privKey, err := u.vault.PrivKey()
if err != nil {
u.render(w, "login", LoginData{Error: "internal error"})
return
}
tokenStr, claims, err := token.IssueToken(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,
audit.JSON("jti", claims.JTI, "via", "ui"))
// 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 != "" {
pubKey, _ := u.vault.PubKey()
claims, err := validateSessionToken(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,
audit.JSON("jti", claims.JTI, "reason", "ui_logout"))
}
}
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 := u.clientIP(r)
if err := u.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil {
u.logger.Warn("write audit event", "type", eventType, "error", err)
}
}
// handleProfilePage renders the profile page for the currently logged-in user.
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
csrfToken, _ := u.setCSRFCookies(w)
claims := claimsFromContext(r.Context())
data := ProfileData{
PageData: PageData{
CSRFToken: csrfToken,
ActorName: u.actorName(r),
IsAdmin: isAdmin(r),
},
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
DeletePrefix: "/profile/webauthn",
}
// Load WebAuthn credentials for the profile page.
if u.cfg.WebAuthnEnabled() && claims != nil {
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err == nil {
creds, err := u.db.GetWebAuthnCredentials(acct.ID)
if err == nil {
data.WebAuthnCreds = creds
}
}
}
u.render(w, "profile", data)
}
// handleSelfChangePassword allows an authenticated human user to change their
// own password. The current password must be supplied to prevent a stolen
// session token from being used to take over an account.
//
// Security: current password is verified with Argon2id (constant-time) before
// the new hash is written. Lockout is checked first so the endpoint cannot
// be used to brute-force the existing password. On success all other active
// sessions are revoked; the caller's own session is preserved so they remain
// logged in. The plaintext passwords are never logged or returned.
func (u *UIServer) handleSelfChangePassword(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
claims := claimsFromContext(r.Context())
if claims == nil {
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
return
}
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
u.renderError(w, r, http.StatusUnauthorized, "account not found")
return
}
if acct.AccountType != model.AccountTypeHuman {
u.renderError(w, r, http.StatusBadRequest, "password change is only available for human accounts")
return
}
currentPassword := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
if currentPassword == "" || newPassword == "" {
u.renderError(w, r, http.StatusBadRequest, "current and new password are required")
return
}
// Server-side confirmation check mirrors the client-side guard; defends
// against direct POST requests that bypass the JavaScript validation.
if newPassword != confirmPassword {
u.renderError(w, r, http.StatusBadRequest, "passwords do not match")
return
}
// Security: check lockout before running Argon2 to prevent brute-force.
locked, lockErr := u.db.IsLockedOut(acct.ID)
if lockErr != nil {
u.logger.Error("lockout check (UI self-service password change)", "error", lockErr)
}
if locked {
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
u.renderError(w, r, http.StatusTooManyRequests, "account temporarily locked, please try again later")
return
}
// Security: verify current password with constant-time Argon2id path used
// at login so this endpoint cannot serve as a timing oracle.
ok, verifyErr := auth.VerifyPassword(currentPassword, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = u.db.RecordLoginFailure(acct.ID)
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"wrong_current_password"}`)
u.renderError(w, r, http.StatusUnauthorized, "current password is incorrect")
return
}
// Security (F-13): enforce minimum length before hashing.
if err := validate.Password(newPassword); err != nil {
u.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(newPassword, auth.ArgonParams{
Time: u.cfg.Argon2.Time,
Memory: u.cfg.Argon2.Memory,
Threads: u.cfg.Argon2.Threads,
})
if err != nil {
u.logger.Error("hash password (UI self-service)", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
if err := u.db.UpdatePasswordHash(acct.ID, hash); err != nil {
u.logger.Error("update password hash", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to update password")
return
}
// Security: clear failure counter (user proved knowledge of current
// password), then revoke all sessions except the current one so stale
// tokens are invalidated while the caller stays logged in.
_ = u.db.ClearLoginFailures(acct.ID)
if err := u.db.RevokeAllUserTokensExcept(acct.ID, claims.JTI, "password_changed"); err != nil {
u.logger.Error("revoke other tokens on UI password change", "account_id", acct.ID, "error", err)
u.renderError(w, r, http.StatusInternalServerError, "password updated but session revocation failed; revoke tokens manually")
return
}
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"via":"ui_self_service"}`)
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "password_change_result", ProfileData{
PageData: PageData{
CSRFToken: csrfToken,
ActorName: u.actorName(r),
IsAdmin: isAdmin(r),
Flash: "Password updated successfully. Other active sessions have been revoked.",
},
})
}