diff --git a/PROGRESS.md b/PROGRESS.md index 972e6c0..e1be3a2 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -4,6 +4,23 @@ Source of truth for current development state. --- Phases 0–14 complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean. +### 2026-03-16 — TOTP enrollment via web UI + +**Task:** Add TOTP enrollment and management to the web UI profile page. + +**Changes:** +- **Dependency:** `github.com/skip2/go-qrcode` for server-side QR code generation +- **Profile page:** TOTP section showing enabled status or enrollment form +- **Enrollment flow:** Password re-auth → generate secret → show QR code + manual entry → confirm with 6-digit code +- **QR code:** Generated server-side as `data:image/png;base64,...` URI (CSP-compliant) +- **Account detail:** Admin "Remove TOTP" button with HTMX delete + confirm +- **Enrollment nonces:** `pendingTOTPEnrolls sync.Map` with 5-minute TTL, single-use +- **Template fragments:** `totp_section.html`, `totp_enroll_qr.html` +- **Handler:** `internal/ui/handlers_totp.go` with `handleTOTPEnrollStart`, `handleTOTPConfirm`, `handleAdminTOTPRemove` +- **Security:** Password re-auth (SEC-01), lockout check, CSRF, single-use nonces, TOTP counter replay prevention (CRIT-01) + +--- + ### 2026-03-16 — Phase 14: FIDO2/WebAuthn and Passkey Authentication **Task:** Add FIDO2/WebAuthn support for passwordless passkey login and security key 2FA. diff --git a/go.mod b/go.mod index 2c41674..88d0be2 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/x448/float16 v0.8.4 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect diff --git a/go.sum b/go.sum index ce1be21..4dd9a42 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= diff --git a/internal/ui/handlers_auth.go b/internal/ui/handlers_auth.go index db70369..931d396 100644 --- a/internal/ui/handlers_auth.go +++ b/internal/ui/handlers_auth.go @@ -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 + } } } } diff --git a/internal/ui/handlers_totp.go b/internal/ui/handlers_totp.go new file mode 100644 index 0000000..631cd28 --- /dev/null +++ b/internal/ui/handlers_totp.go @@ -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 (removed)`) +} + +// 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) +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 232916a..a7064d6 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -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. diff --git a/web/templates/account_detail.html b/web/templates/account_detail.html index 47ed4fa..a852770 100644 --- a/web/templates/account_detail.html +++ b/web/templates/account_detail.html @@ -14,7 +14,18 @@
Type
{{.Account.AccountType}}
Status
{{template "account_status" .}}
-
TOTP
{{if .Account.TOTPRequired}}Enabled{{else}}Disabled{{end}}
+
TOTP
+
+ {{if .Account.TOTPRequired}} + Enabled + + {{else}}Disabled{{end}} +
{{if .WebAuthnEnabled}}
Passkeys
{{len .WebAuthnCreds}} registered
{{end}}
Created
{{formatTime .Account.CreatedAt}}
Updated
{{formatTime .Account.UpdatedAt}}
diff --git a/web/templates/fragments/totp_enroll_qr.html b/web/templates/fragments/totp_enroll_qr.html new file mode 100644 index 0000000..4525b32 --- /dev/null +++ b/web/templates/fragments/totp_enroll_qr.html @@ -0,0 +1,35 @@ +{{define "totp_enroll_qr"}} +
+ {{if .TOTPError}}{{end}} +

+ Scan this QR code with your authenticator app, then enter the 6-digit code to confirm. +

+
+ TOTP QR Code +
+
+ Manual entry + + {{.TOTPSecret}} + +
+
+ +
+ + +
+ +
+
+{{end}} diff --git a/web/templates/fragments/totp_section.html b/web/templates/fragments/totp_section.html new file mode 100644 index 0000000..07336c3 --- /dev/null +++ b/web/templates/fragments/totp_section.html @@ -0,0 +1,29 @@ +{{define "totp_section"}} +
+ {{if .TOTPSuccess}}{{end}} + {{if .TOTPEnabled}} +

+ ✓ Enabled +

+

To remove TOTP, contact an administrator.

+ {{else}} +

+ Add a time-based one-time password for two-factor authentication. +

+ {{if .TOTPError}}{{end}} +
+
+ + +
+ +
+ {{end}} +
+{{end}} diff --git a/web/templates/profile.html b/web/templates/profile.html index aaa8558..7110845 100644 --- a/web/templates/profile.html +++ b/web/templates/profile.html @@ -4,6 +4,10 @@ +
+

Two-Factor Authentication (TOTP)

+ {{template "totp_section" .}} +
{{if .WebAuthnEnabled}}

Passkeys