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

@@ -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 &amp; Enable
</button>
</form>
</div>
{{end}}

View 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">&#x2713; 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}}