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:
17
PROGRESS.md
17
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.
|
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
|
### 2026-03-16 — Phase 14: FIDO2/WebAuthn and Passkey Authentication
|
||||||
|
|
||||||
**Task:** Add FIDO2/WebAuthn support for passwordless passkey login and security key 2FA.
|
**Task:** Add FIDO2/WebAuthn support for passwordless passkey login and security key 2FA.
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -24,6 +24,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/stretchr/testify v1.11.1 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
|
|||||||
2
go.sum
2
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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
|||||||
@@ -304,16 +304,19 @@ func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
DeletePrefix: "/profile/webauthn",
|
DeletePrefix: "/profile/webauthn",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load WebAuthn credentials for the profile page.
|
if claims != nil {
|
||||||
if u.cfg.WebAuthnEnabled() && claims != nil {
|
|
||||||
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
data.TOTPEnabled = acct.TOTPRequired
|
||||||
|
// Load WebAuthn credentials for the profile page.
|
||||||
|
if u.cfg.WebAuthnEnabled() {
|
||||||
creds, err := u.db.GetWebAuthnCredentials(acct.ID)
|
creds, err := u.db.GetWebAuthnCredentials(acct.ID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
data.WebAuthnCreds = creds
|
data.WebAuthnCreds = creds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
u.render(w, "profile", data)
|
u.render(w, "profile", data)
|
||||||
}
|
}
|
||||||
|
|||||||
287
internal/ui/handlers_totp.go
Normal file
287
internal/ui/handlers_totp.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ type UIServer struct {
|
|||||||
vault *vault.Vault
|
vault *vault.Vault
|
||||||
pendingLogins sync.Map // nonce (string) → *pendingLogin
|
pendingLogins sync.Map // nonce (string) → *pendingLogin
|
||||||
tokenDownloads sync.Map // nonce (string) → *tokenDownload
|
tokenDownloads sync.Map // nonce (string) → *tokenDownload
|
||||||
|
pendingTOTPEnrolls sync.Map // nonce (string) → *pendingTOTPEnroll
|
||||||
}
|
}
|
||||||
|
|
||||||
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
|
// 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
|
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
|
// dummyHash returns the pre-computed Argon2id PHC hash for constant-time dummy
|
||||||
// verification when an account is unknown or inactive (F-07).
|
// verification when an account is unknown or inactive (F-07).
|
||||||
// Delegates to auth.DummyHash() which uses sync.Once for one-time computation.
|
// 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/token_delegates.html",
|
||||||
"templates/fragments/webauthn_credentials.html",
|
"templates/fragments/webauthn_credentials.html",
|
||||||
"templates/fragments/webauthn_enroll.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...)
|
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
||||||
if err != nil {
|
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.
|
// accumulate indefinitely, enabling a memory-exhaustion attack.
|
||||||
go srv.cleanupPendingLogins()
|
go srv.cleanupPendingLogins()
|
||||||
go srv.cleanupTokenDownloads()
|
go srv.cleanupTokenDownloads()
|
||||||
|
go srv.cleanupPendingTOTPEnrolls()
|
||||||
|
|
||||||
return srv, nil
|
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.
|
// Register attaches all UI routes to mux, wrapped with security headers.
|
||||||
// All UI responses (pages, fragments, redirects, static assets) carry the
|
// All UI responses (pages, fragments, redirects, static assets) carry the
|
||||||
// headers added by securityHeaders.
|
// 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/begin", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnBegin))))
|
||||||
uiMux.Handle("POST /profile/webauthn/finish", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnFinish))))
|
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))))
|
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.
|
// Admin WebAuthn management.
|
||||||
uiMux.Handle("DELETE /accounts/{id}/webauthn/{credentialId}", admin(u.handleAdminWebAuthnDelete))
|
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
|
// 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
|
// 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
|
WebAuthnCreds []*model.WebAuthnCredential
|
||||||
DeletePrefix string // URL prefix for delete buttons (e.g. "/profile/webauthn")
|
DeletePrefix string // URL prefix for delete buttons (e.g. "/profile/webauthn")
|
||||||
WebAuthnEnabled bool
|
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.
|
// PGCredsData is the view model for the "My PG Credentials" list page.
|
||||||
|
|||||||
@@ -14,7 +14,18 @@
|
|||||||
<dt class="text-muted">Type</dt><dd>{{.Account.AccountType}}</dd>
|
<dt class="text-muted">Type</dt><dd>{{.Account.AccountType}}</dd>
|
||||||
<dt class="text-muted">Status</dt>
|
<dt class="text-muted">Status</dt>
|
||||||
<dd id="status-cell">{{template "account_status" .}}</dd>
|
<dd id="status-cell">{{template "account_status" .}}</dd>
|
||||||
<dt class="text-muted">TOTP</dt><dd>{{if .Account.TOTPRequired}}Enabled{{else}}Disabled{{end}}</dd>
|
<dt class="text-muted">TOTP</dt>
|
||||||
|
<dd id="totp-admin-status">
|
||||||
|
{{if .Account.TOTPRequired}}
|
||||||
|
Enabled
|
||||||
|
<button class="btn btn-sm btn-danger" style="margin-left:.5rem"
|
||||||
|
hx-delete="/accounts/{{.Account.UUID}}/totp"
|
||||||
|
hx-target="#totp-admin-status"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="Remove TOTP for this account?"
|
||||||
|
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'>Remove</button>
|
||||||
|
{{else}}Disabled{{end}}
|
||||||
|
</dd>
|
||||||
{{if .WebAuthnEnabled}}<dt class="text-muted">Passkeys</dt><dd>{{len .WebAuthnCreds}} registered</dd>{{end}}
|
{{if .WebAuthnEnabled}}<dt class="text-muted">Passkeys</dt><dd>{{len .WebAuthnCreds}} registered</dd>{{end}}
|
||||||
<dt class="text-muted">Created</dt><dd class="text-small">{{formatTime .Account.CreatedAt}}</dd>
|
<dt class="text-muted">Created</dt><dd class="text-small">{{formatTime .Account.CreatedAt}}</dd>
|
||||||
<dt class="text-muted">Updated</dt><dd class="text-small">{{formatTime .Account.UpdatedAt}}</dd>
|
<dt class="text-muted">Updated</dt><dd class="text-small">{{formatTime .Account.UpdatedAt}}</dd>
|
||||||
|
|||||||
35
web/templates/fragments/totp_enroll_qr.html
Normal file
35
web/templates/fragments/totp_enroll_qr.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{{define "totp_enroll_qr"}}
|
||||||
|
<div id="totp-section">
|
||||||
|
{{if .TOTPError}}<div class="alert alert-error" role="alert">{{.TOTPError}}</div>{{end}}
|
||||||
|
<p class="text-small" style="margin-bottom:.75rem">
|
||||||
|
Scan this QR code with your authenticator app, then enter the 6-digit code to confirm.
|
||||||
|
</p>
|
||||||
|
<div style="text-align:center;margin:1rem 0">
|
||||||
|
<img src="{{.TOTPQR}}" alt="TOTP QR Code" width="200" height="200"
|
||||||
|
style="image-rendering:pixelated">
|
||||||
|
</div>
|
||||||
|
<details style="margin-bottom:1rem">
|
||||||
|
<summary class="text-small text-muted" style="cursor:pointer">Manual entry</summary>
|
||||||
|
<code style="font-size:.8rem;word-break:break-all;display:block;margin-top:.5rem;
|
||||||
|
padding:.5rem;background:var(--color-bg-alt,#f5f5f5);border-radius:4px">
|
||||||
|
{{.TOTPSecret}}
|
||||||
|
</code>
|
||||||
|
</details>
|
||||||
|
<form hx-post="/profile/totp/confirm"
|
||||||
|
hx-target="#totp-section"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'>
|
||||||
|
<input type="hidden" name="totp_enroll_nonce" value="{{.TOTPEnrollNonce}}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="totp-confirm-code">Authenticator Code</label>
|
||||||
|
<input type="text" id="totp-confirm-code" name="totp_code"
|
||||||
|
class="form-control" autocomplete="one-time-code"
|
||||||
|
inputmode="numeric" pattern="[0-9]{6}" maxlength="6"
|
||||||
|
required autofocus placeholder="6-digit code">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.5rem">
|
||||||
|
Verify & Enable
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
29
web/templates/fragments/totp_section.html
Normal file
29
web/templates/fragments/totp_section.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{{define "totp_section"}}
|
||||||
|
<div id="totp-section">
|
||||||
|
{{if .TOTPSuccess}}<div class="alert alert-success" role="alert">{{.TOTPSuccess}}</div>{{end}}
|
||||||
|
{{if .TOTPEnabled}}
|
||||||
|
<p class="text-small" style="margin-bottom:.5rem">
|
||||||
|
<span style="color:var(--color-success,#27ae60);font-weight:600">✓ Enabled</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-muted text-small">To remove TOTP, contact an administrator.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted text-small" style="margin-bottom:.75rem">
|
||||||
|
Add a time-based one-time password for two-factor authentication.
|
||||||
|
</p>
|
||||||
|
{{if .TOTPError}}<div class="alert alert-error" role="alert">{{.TOTPError}}</div>{{end}}
|
||||||
|
<form hx-post="/profile/totp/enroll"
|
||||||
|
hx-target="#totp-section"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="totp-enroll-password">Current Password</label>
|
||||||
|
<input type="password" id="totp-enroll-password" name="password"
|
||||||
|
class="form-control" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.5rem">
|
||||||
|
Set Up Authenticator
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Profile</h1>
|
<h1>Profile</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Two-Factor Authentication (TOTP)</h2>
|
||||||
|
{{template "totp_section" .}}
|
||||||
|
</div>
|
||||||
{{if .WebAuthnEnabled}}
|
{{if .WebAuthnEnabled}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Passkeys</h2>
|
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Passkeys</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user