Multi-factor authentication for password logins - #973
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/loginanswers 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/mfawith a TOTP or recovery code, or the WebAuthnbegin/finishpair. 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.service.mfaRequired--mfa-requiredSERVICE_MFA_REQUIREDfalseservice.mfaIssuer--mfa-issuerSERVICE_MFA_ISSUERosctrl (<host>)service.mfaRPID--mfa-rpidSERVICE_MFA_RPIDservice.hostservice.mfaOrigins--mfa-originsSERVICE_MFA_ORIGINShttps://<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
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/finishAuthenticated (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 overgo-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) andmfa.go(self-service enrollment). Session issuing was pulled out ofLoginHandlerinto a singleissueSessionhelper 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 fourservice.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/auth-providers.mdcovering 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.tscclean.Self-review caught one real bug before it shipped:
gorm.Modelsoft-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