<#973 Multi-factor authentication for password log...
# osctrl
g
#973 Multi-factor authentication for password logins Pull request opened by javuto Multi-factor authentication for password logins Closes #962. Adds a second factor to `db`/password authentication in osctrl-api and the SPA: authenticator apps (TOTP), passkeys and hardware security keys (WebAuthn), and single-use recovery codes. Federated logins are untouched — the identity provider owns the factor policy there — and service accounts are exempt, since they authenticate with a long-lived token rather than interactively. How a login works The password step no longer creates a session on its own. When the user has a factor,
POST /api/v1/login
answers with a challenge instead of a token, and sets no cookies: {"mfa_required": true, "challenge": "…", "methods": ["totp", "webauthn", "recovery"]} The session is minted only by the second request —
POST /api/v1/login/mfa
with a TOTP or recovery code, or the WebAuthn `begin`/`finish` pair. Both steps sit behind the same per-IP login rate limiter, and every rejected factor is audit-logged. Enforcement
service.mfaRequired
(
--mfa-required
,
SERVICE_MFA_REQUIRED
, default off) makes a second factor mandatory. A user who has none is not locked out: the login returns an enrollment challenge, they scan a QR code and confirm a code, and the session arrives together with their recovery codes — still nothing issued until a factor exists and is proven. While it is on, the last remaining factor on an account cannot be removed. | Setting | Flag | Environment variable | Default | | ------------------- | -------------- | -------------------- | --------------- | | service.mfaRequired | --mfa-required | SERVICE_MFA_REQUIRED | false | | service.mfaIssuer | --mfa-issuer | SERVICE_MFA_ISSUER | osctrl (<host>) | | service.mfaRPID | --mfa-rpid | SERVICE_MFA_RPID | service.host | | service.mfaOrigins | --mfa-origins | SERVICE_MFA_ORIGINS | https://<rp id> | WebAuthn needs a Relying Party ID and the exact origins the SPA is served from. When neither can be resolved the relying party stays nil: TOTP and recovery codes keep working and the SPA hides passkey registration. Enrollment Users enroll themselves from Profile → Two-factor authentication: set up an authenticator app, register passkeys and security keys, regenerate recovery codes. Removing a factor or regenerating codes requires re-entering the account password, so a hijacked session cannot quietly strip protection off an account. Recovery codes are displayed once, at generation. Security properties • TOTP time steps are burned on use, so a shoulder-surfed code cannot be replayed inside its 30s window; drift tolerance is ±1 step. • Challenges are single-use and expire after five minutes; the winner of a race is the request that claims the row. • Recovery codes are consumed atomically (conditional update), so the same code cannot satisfy two concurrent logins. • WebAuthn ceremony state lives server-side on the challenge row — the browser never carries it. • TOTP secrets are never returned after enrollment completes; recovery codes are stored as SHA-256 (they are ~99 bits of machine-generated randomness, and bcrypt on ten of them would put seconds on the login path). • Second-factor failures return one deliberately vague error, so nothing reveals which half of the check failed. • Deleting a user removes their factors, so a recreated username cannot inherit an old secret or key. Endpoints Pre-auth (login rate limiter):
POST /api/v1/login/mfa
,
/login/mfa/webauthn/begin
,
/login/mfa/webauthn/finish
,
/login/mfa/enroll/begin
,
/login/mfa/enroll/finish
Authenticated (own account):
GET /api/v1/mfa
,
POST|DELETE /api/v1/mfa/totp
,
POST /api/v1/mfa/totp/verify
,
POST /api/v1/mfa/recovery
,
POST /api/v1/mfa/webauthn
,
POST /api/v1/mfa/webauthn/verify
,
DELETE /api/v1/mfa/webauthn/{id}
Changes •
pkg/mfa
(new) — TOTP on stdlib crypto (RFC 6238: HMAC-SHA1, 6 digits, 30s), recovery codes, WebAuthn wrapper over
go-webauthn
, and the manager owning four auto-migrated tables:
user_mfa_totp
,
user_mfa_credentials
,
user_mfa_recovery_codes
,
user_mfa_challenges
. QR codes render server-side as PNG data URIs, so the SPA needs no QR library. •
cmd/api/handlers
—
mfa_login.go
(second factor + forced enrollment) and
mfa.go
(self-service enrollment). Session issuing was pulled out of
LoginHandler
into a single
issueSession
helper so the MFA paths cannot drift from the password-only path. •
cmd/api/main.go
— builds the manager and, when an RP ID and origin resolve, the WebAuthn relying party; registers the routes. •
pkg/config
— the four
service.mfa*
values, flags and environment variables. •
frontend/
—
api/mfa.ts
(client plus the base64url ↔️ ArrayBuffer plumbing for the browser ceremonies), a second step on the login page (code, security key, recovery code, forced enrollment), and the profile panel. • Docs — an MFA section in
docs/auth-providers.md
covering the flow, WebAuthn RP ID/origins and the DB reset for locked-out users; annotated sample YAMLs; regenerated OpenAPI spec; CHANGELOG entry. New dependencies:
<http://github.com/go-webauthn/webauthn|github.com/go-webauthn/webauthn>
,
<http://github.com/skip2/go-qrcode|github.com/skip2/go-qrcode>
. Testing •
go build ./...
,
go test ./...
,
go vet ./...
,
make openapi-check
— pass. • Frontend: 233 tests pass,
tsc
clean. • New coverage: RFC 6238 vectors, drift and replay, enrollment lifecycle, re-enrollment after removal, challenge single-use and expiry, recovery-code consumption, the full two-step login (TOTP, recovery, forced enrollment, service-account exemption), and the SPA's second-factor screen. Self-review caught one real bug before it shipped:
gorm.Model
soft-deletes leave the row in the unique index, so removing a factor and enrolling again would have collided. Fixed, with regression tests for both TOTP and credentials. Not in this PR • Registering a passkey during forced enrollment (that step is TOTP-only; keys can be added from the profile afterwards). • An admin-facing "reset this user's MFA" action — recovery today is the documented SQL. jmpsec/osctrl