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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user