Skip to content

Multi-factor authentication for password logins - #973

Merged
javuto merged 1 commit into
developfrom
mfa-auth-db
Aug 16, 2026
Merged

Multi-factor authentication for password logins#973
javuto merged 1 commit into
developfrom
mfa-auth-db

Conversation

@javuto

@javuto javuto commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

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/handlersmfa_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: github.com/go-webauthn/webauthn, 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.

@javuto javuto added ✨ enhancement New feature or request osctrl-api osctrl-api related changes 🔐 security Security related issues labels Aug 16, 2026
@javuto
javuto merged commit b769e27 into develop Aug 16, 2026
8 checks passed
@javuto
javuto deleted the mfa-auth-db branch August 16, 2026 08:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ enhancement New feature or request osctrl-api osctrl-api related changes 🔐 security Security related issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Add support for MFA when authentication is set to db

1 participant