Add TOTP enrollment to web UI

- Profile page TOTP section with enrollment flow:
  password re-auth → QR code + manual entry → 6-digit confirm
- Server-side QR code generation (go-qrcode, data: URI PNG)
- Admin "Remove TOTP" button on account detail page
- Enrollment nonces: sync.Map with 5-minute TTL, single-use
- Template fragments: totp_section.html, totp_enroll_qr.html
- Handler: handlers_totp.go (enroll start, confirm, admin remove)

Security: Password re-auth before secret generation (SEC-01).
Lockout checked before Argon2. CSRF on all endpoints. Single-use
enrollment nonces with expiry. TOTP counter replay prevention
(CRIT-01). Self-removal not permitted (admin only).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 17:39:45 -07:00
parent 25417b24f4
commit 37afc68287
10 changed files with 477 additions and 14 deletions

View File

@@ -304,13 +304,16 @@ func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
DeletePrefix: "/profile/webauthn",
}
// Load WebAuthn credentials for the profile page.
if u.cfg.WebAuthnEnabled() && claims != nil {
if 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
data.TOTPEnabled = acct.TOTPRequired
// Load WebAuthn credentials for the profile page.
if u.cfg.WebAuthnEnabled() {
creds, err := u.db.GetWebAuthnCredentials(acct.ID)
if err == nil {
data.WebAuthnCreds = creds
}
}
}
}

View File

@@ -0,0 +1,287 @@
package ui
import (
"encoding/base32"
"encoding/base64"
"fmt"
"net/http"
qrcode "github.com/skip2/go-qrcode"
"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"
)
// handleTOTPEnrollStart processes the password re-auth step and generates
// the TOTP secret + QR code for the user to scan.
//
// Security (SEC-01): the current password is required to prevent a stolen
// session from enrolling attacker-controlled TOTP. Lockout is checked and
// failures are recorded to prevent brute-force use as a password oracle.
func (u *UIServer) handleTOTPEnrollStart(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "invalid form submission"})
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
}
// Already enrolled — show enabled status.
if acct.TOTPRequired {
u.renderTOTPSection(w, r, ProfileData{TOTPEnabled: true})
return
}
password := r.FormValue("password")
if password == "" {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "password is required"})
return
}
// Security: check lockout before verifying password.
locked, lockErr := u.db.IsLockedOut(acct.ID)
if lockErr != nil {
u.logger.Error("lockout check (UI TOTP enroll)", "error", lockErr)
}
if locked {
u.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"locked"}`)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "account temporarily locked, please try again later"})
return
}
// Security: verify current password with constant-time Argon2id path.
ok, verifyErr := auth.VerifyPassword(password, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = u.db.RecordLoginFailure(acct.ID)
u.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"wrong_password"}`)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "password is incorrect"})
return
}
// Generate TOTP secret.
rawSecret, b32Secret, err := auth.GenerateTOTPSecret()
if err != nil {
u.logger.Error("generate TOTP secret", "error", err)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
// Encrypt and store as pending (totp_required stays 0 until confirmed).
masterKey, err := u.vault.MasterKey()
if err != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
secretEnc, secretNonce, err := crypto.SealAESGCM(masterKey, rawSecret)
if err != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
// Security: use StorePendingTOTP (not SetTOTP) so that totp_required
// remains 0 until the user proves possession via ConfirmTOTP.
if err := u.db.StorePendingTOTP(acct.ID, secretEnc, secretNonce); err != nil {
u.logger.Error("store pending TOTP", "error", err)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
otpURI := fmt.Sprintf("otpauth://totp/MCIAS:%s?secret=%s&issuer=MCIAS", acct.Username, b32Secret)
// Generate QR code PNG.
png, err := qrcode.Encode(otpURI, qrcode.Medium, 200)
if err != nil {
u.logger.Error("generate QR code", "error", err)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
qrDataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
// Issue enrollment nonce for the confirm step.
nonce, err := u.issueTOTPEnrollNonce(acct.ID)
if err != nil {
u.logger.Error("issue TOTP enroll nonce", "error", err)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "totp_enroll_qr", ProfileData{
PageData: PageData{CSRFToken: csrfToken},
TOTPSecret: b32Secret,
TOTPQR: qrDataURI,
TOTPEnrollNonce: nonce,
})
}
// handleTOTPConfirm validates the TOTP code and activates enrollment.
//
// Security (CRIT-01): the counter is recorded to prevent replay of the same
// code within its validity window.
func (u *UIServer) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "invalid form submission"})
return
}
claims := claimsFromContext(r.Context())
if claims == nil {
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
return
}
nonce := r.FormValue("totp_enroll_nonce")
totpCode := r.FormValue("totp_code")
// Security: consume the nonce (single-use); reject if unknown or expired.
accountID, ok := u.consumeTOTPEnrollNonce(nonce)
if !ok {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "session expired, please start enrollment again"})
return
}
acct, err := u.db.GetAccountByID(accountID)
if err != nil {
u.logger.Error("get account for TOTP confirm", "error", err, "account_id", accountID)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
// Security: verify nonce accountID matches session claims.
if acct.UUID != claims.Subject {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "session mismatch"})
return
}
if acct.TOTPSecretEnc == nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "enrollment not started"})
return
}
// Decrypt and validate TOTP code.
masterKey, err := u.vault.MasterKey()
if err != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
if err != nil {
u.logger.Error("decrypt TOTP secret for confirm", "error", err, "account_id", acct.ID)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
valid, totpCounter, err := auth.ValidateTOTP(secret, totpCode)
if err != nil || !valid {
// Re-issue a fresh nonce so the user can retry without restarting.
u.reissueTOTPEnrollQR(w, r, acct, secret, "invalid TOTP code")
return
}
// Security (CRIT-01): reject replay of a code already used.
if err := u.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
u.reissueTOTPEnrollQR(w, r, acct, secret, "invalid TOTP code")
return
}
// Activate TOTP (sets totp_required=1).
if err := u.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {
u.logger.Error("set TOTP", "error", err, "account_id", acct.ID)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
u.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, nil, "")
u.renderTOTPSection(w, r, ProfileData{
TOTPEnabled: true,
TOTPSuccess: "Two-factor authentication enabled successfully.",
})
}
// reissueTOTPEnrollQR re-renders the QR code page with a fresh nonce after
// a failed code confirmation, so the user can retry without restarting.
func (u *UIServer) reissueTOTPEnrollQR(w http.ResponseWriter, r *http.Request, acct *model.Account, secret []byte, errMsg string) {
b32Secret := base32.StdEncoding.EncodeToString(secret)
otpURI := fmt.Sprintf("otpauth://totp/MCIAS:%s?secret=%s&issuer=MCIAS", acct.Username, b32Secret)
png, err := qrcode.Encode(otpURI, qrcode.Medium, 200)
if err != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
qrDataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
newNonce, nonceErr := u.issueTOTPEnrollNonce(acct.ID)
if nonceErr != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "totp_enroll_qr", ProfileData{
PageData: PageData{CSRFToken: csrfToken},
TOTPSecret: b32Secret,
TOTPQR: qrDataURI,
TOTPEnrollNonce: newNonce,
TOTPError: errMsg,
})
}
// handleAdminTOTPRemove removes TOTP from an account (admin only).
func (u *UIServer) handleAdminTOTPRemove(w http.ResponseWriter, r *http.Request) {
accountUUID := r.PathValue("id")
if accountUUID == "" {
u.renderError(w, r, http.StatusBadRequest, "missing account ID")
return
}
acct, err := u.db.GetAccountByUUID(accountUUID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
if err := u.db.ClearTOTP(acct.ID); err != nil {
u.logger.Error("clear TOTP (admin)", "error", err, "account_id", acct.ID)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventTOTPRemoved, actorID, &acct.ID,
audit.JSON("admin", "true"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprint(w, `Disabled <span class="text-muted text-small">(removed)</span>`)
}
// renderTOTPSection is a helper to render the totp_section fragment with
// common page data fields populated.
func (u *UIServer) renderTOTPSection(w http.ResponseWriter, r *http.Request, data ProfileData) {
csrfToken, _ := u.setCSRFCookies(w)
data.CSRFToken = csrfToken
data.ActorName = u.actorName(r)
data.IsAdmin = isAdmin(r)
u.render(w, "totp_section", data)
}

View File

@@ -71,14 +71,15 @@ const tokenDownloadTTL = 5 * time.Minute
// UIServer serves the HTMX-based management UI.
type UIServer struct {
tmpls map[string]*template.Template // page name → template set
db *db.DB
cfg *config.Config
logger *slog.Logger
csrf *CSRFManager
vault *vault.Vault
pendingLogins sync.Map // nonce (string) → *pendingLogin
tokenDownloads sync.Map // nonce (string) → *tokenDownload
tmpls map[string]*template.Template // page name → template set
db *db.DB
cfg *config.Config
logger *slog.Logger
csrf *CSRFManager
vault *vault.Vault
pendingLogins sync.Map // nonce (string) → *pendingLogin
tokenDownloads sync.Map // nonce (string) → *tokenDownload
pendingTOTPEnrolls sync.Map // nonce (string) → *pendingTOTPEnroll
}
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
@@ -113,6 +114,48 @@ func (u *UIServer) consumeTOTPNonce(nonce string) (int64, bool) {
return pl.accountID, true
}
// pendingTOTPEnroll stores the account ID for a TOTP enrollment ceremony
// that has passed password re-auth and generated a secret, awaiting code
// confirmation.
type pendingTOTPEnroll struct {
expiresAt time.Time
accountID int64
}
const totpEnrollTTL = 5 * time.Minute
// issueTOTPEnrollNonce creates a random single-use nonce for the TOTP
// enrollment confirmation step.
func (u *UIServer) issueTOTPEnrollNonce(accountID int64) (string, error) {
raw := make([]byte, totpNonceBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("ui: generate TOTP enroll nonce: %w", err)
}
nonce := hex.EncodeToString(raw)
u.pendingTOTPEnrolls.Store(nonce, &pendingTOTPEnroll{
accountID: accountID,
expiresAt: time.Now().Add(totpEnrollTTL),
})
return nonce, nil
}
// consumeTOTPEnrollNonce looks up and deletes the enrollment nonce,
// returning the associated account ID. Returns (0, false) if unknown or expired.
func (u *UIServer) consumeTOTPEnrollNonce(nonce string) (int64, bool) {
v, ok := u.pendingTOTPEnrolls.LoadAndDelete(nonce)
if !ok {
return 0, false
}
pe, ok2 := v.(*pendingTOTPEnroll)
if !ok2 {
return 0, false
}
if time.Now().After(pe.expiresAt) {
return 0, false
}
return pe.accountID, true
}
// dummyHash returns the pre-computed Argon2id PHC hash for constant-time dummy
// verification when an account is unknown or inactive (F-07).
// Delegates to auth.DummyHash() which uses sync.Once for one-time computation.
@@ -222,6 +265,8 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
"templates/fragments/token_delegates.html",
"templates/fragments/webauthn_credentials.html",
"templates/fragments/webauthn_enroll.html",
"templates/fragments/totp_section.html",
"templates/fragments/totp_enroll_qr.html",
}
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil {
@@ -270,6 +315,7 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
// accumulate indefinitely, enabling a memory-exhaustion attack.
go srv.cleanupPendingLogins()
go srv.cleanupTokenDownloads()
go srv.cleanupPendingTOTPEnrolls()
return srv, nil
}
@@ -342,6 +388,22 @@ func (u *UIServer) cleanupTokenDownloads() {
}
}
// cleanupPendingTOTPEnrolls periodically evicts expired TOTP enrollment nonces.
func (u *UIServer) cleanupPendingTOTPEnrolls() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
u.pendingTOTPEnrolls.Range(func(key, value any) bool {
pe, ok := value.(*pendingTOTPEnroll)
if !ok || now.After(pe.expiresAt) {
u.pendingTOTPEnrolls.Delete(key)
}
return true
})
}
}
// Register attaches all UI routes to mux, wrapped with security headers.
// All UI responses (pages, fragments, redirects, static assets) carry the
// headers added by securityHeaders.
@@ -448,8 +510,13 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("POST /profile/webauthn/begin", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnBegin))))
uiMux.Handle("POST /profile/webauthn/finish", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnFinish))))
uiMux.Handle("DELETE /profile/webauthn/{id}", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnDelete))))
// TOTP profile routes (enrollment).
uiMux.Handle("POST /profile/totp/enroll", authed(u.requireCSRF(http.HandlerFunc(u.handleTOTPEnrollStart))))
uiMux.Handle("POST /profile/totp/confirm", authed(u.requireCSRF(http.HandlerFunc(u.handleTOTPConfirm))))
// Admin WebAuthn management.
uiMux.Handle("DELETE /accounts/{id}/webauthn/{credentialId}", admin(u.handleAdminWebAuthnDelete))
// Admin TOTP removal.
uiMux.Handle("DELETE /accounts/{id}/totp", admin(u.handleAdminTOTPRemove))
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
@@ -862,6 +929,13 @@ type ProfileData struct { //nolint:govet // fieldalignment: readability over ali
WebAuthnCreds []*model.WebAuthnCredential
DeletePrefix string // URL prefix for delete buttons (e.g. "/profile/webauthn")
WebAuthnEnabled bool
// TOTP enrollment fields (populated only during enrollment flow).
TOTPEnabled bool
TOTPSecret string // base32-encoded; shown once during enrollment
TOTPQR string // data:image/png;base64,... QR code
TOTPEnrollNonce string // single-use nonce for confirm step
TOTPError string // enrollment-specific error message
TOTPSuccess string // success flash after confirmation
}
// PGCredsData is the view model for the "My PG Credentials" list page.