OwnServa

← Documentation

Authenticator MFA

Migration 015_mfa.sql adds optional per-user authenticator protection. Apply migrations through 015 before deploying the updated dashboard and API; both now depend on session_identity(text). Existing users remain unenrolled and retain their current access.

Set ACCOUNT_ENCRYPTION_KEY on the dashboard only to a new, distinct 32-byte hex key. For example, generate it locally with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))". Store it in the operator's secret store and back it up with the database. Keep the existing deployment SECRET_ENCRYPTION_KEY separate. Compose forwards the account key only to the dashboard. Enrollment is unavailable without a correctly formatted account key.

Do not replace the key while users are enrolled: a new key cannot decrypt old authenticator seeds. A key rotation/migration tool is not included. Recovery codes are separately hashed and can still be used if an encryption key becomes unavailable; users must have saved them. Loss of the device, key and recovery codes requires an independently verified operator recovery process, which is not implemented as a self-service bypass.

Enrollment and recovery

Under Account, request an email security code and select Set up authenticator. The email proof must come from the current address, session and enrollment purpose. The application generates a random 160-bit seed and stores it encrypted using AES-256-GCM with account- and purpose-bound authenticated data. The manual secret works with a standard authenticator using SHA-1, six digits and a 30-second interval. QR rendering is not included; no seed is sent to an external QR service.

Enter a code within fifteen minutes to confirm enrollment. Setup is bound to the initiating session, and a confirmed code records its time step to prevent reuse. Enabling MFA marks the current session verified, revokes other sessions, clears pending linking intents/security proofs and returns ten random 128-bit recovery codes once. Save them privately offline before dismissing the page. Only SHA-256 recovery digests are stored; codes are consumed transactionally and cannot be reused.

Disabling MFA requires both a currently MFA-verified session and a fresh email security proof. It removes the stored authenticator seed/recovery hashes and revokes other sessions. Password reset does not remove MFA. Account deletion clears the seed, pending enrollment and recovery data along with other account access.

Sign-in boundary

Password and Google/GitHub sign-ins initially create ordinary opaque first-factor sessions. For enrolled users, no dashboard identity or account settings access is exposed until verification. Dashboard navigation goes to /mfa, where the user enters an authenticator or unused recovery code. Pending sessions can sign out and start again. They expire for challenge purposes after fifteen minutes; they must then sign in again.

The dashboard's normal session adapter and the Go API both use the same SQL function. The API requires NOT mfa_required, so a direct bearer request cannot bypass the page. Auth.js uses an internal adapter that retains a pending cookie and a callback that removes the pending user's identity from session responses. This prevents Auth.js from discarding the challenge cookie as if it were an invalid session. Future code must use the ordinary adapter for authorization, not authAdapter; the latter is only for Auth.js cookie/session handling.

Verified MFA sessions last up to seven days, bounded by the session's ordinary expiry/revocation. Auth.js rolling expiration cannot extend the MFA verification timestamp. TOTP accepts at most one step of clock skew in either direction and rejects steps already consumed for that user. Keep application/database and authenticator clocks synchronized. Verification has a committed ten-attempt, fifteen-minute per-account limit shared across pending sessions, in addition to the existing request/global rate limits. Failed verification transactions cannot roll back that limiter. No SMS or email-only second-factor fallback is offered.

Routes and checks

All account POST routes retain same-origin checks and body limits. New actions are enable-mfa and disable-mfa with an email code, mfa-confirm with otp, and mfa-verify with otp (an authenticator or recovery code). security-code accepts enable-mfa/disable-mfa purposes. /api/account/security exposes enrollment state and the count of remaining recovery codes, never the stored seed or hashes. Enrollment/confirmation responses use Cache-Control: no-store; secrets are kept in component memory, not URLs or local storage. MFA changes and successful challenges are audited without codes.

Automated checks run the production account actions, adapters and challenge SQL against embedded PostgreSQL. They cover standard RFC 6238 test vectors, encryption tampering/account/purpose/key binding, email-proof/session-bound enrollment, setup expiry, other-session revocation, Auth.js identity masking, direct API-role session decisions, globally single-use time steps/recovery codes, stale challenges, rolling-session limits, committed account throttling, verified-only disabling and seed cleanup. Browser fixtures simulate responses; they cannot establish live OAuth, SMTP or production PostgreSQL behavior.

Before production, test password and each configured OAuth provider through /mfa, failed/replayed codes, recovery-code login, expired challenges, session revocation, password reset with MFA still enabled, and explicit provider linking while MFA is enabled. Verify that direct API and account requests with a pending cookie are denied. Complete email delivery, authenticator interoperability, clock-skew and operator recovery drills with the deployed configuration.

Implementation uses OTPAuth and follows the RFC 6238 time-step/replay model.