Skip to content

feat(auth): optional two-factor sign-in with TOTP and recovery codes (#105) - #108

Merged
kipavy merged 1 commit into
mainfrom
feat/two-factor-105
Oct 11, 2026
Merged

kipavy merged 1 commit into
mainfrom
feat/two-factor-105

Conversation

@kipavy

@kipavy kipavy commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Closes #105. Builds on #107.

Two-factor sign-in is opt-in per account, off by default, free on every tier, and the same on a self-hosted server.

Endpoints

Route Auth Does
POST /v1/auth/login – For an enrolled account: 428 {error: "TWO_FACTOR_REQUIRED", pending_token, methods}, no tokens, no wrapped_user_secrets. Unchanged otherwise.
POST /v1/auth/login/2fa pending_token + code or recovery_code The usual login response.
POST /v1/auth/2fa/setup access token + auth_key Stores a pending seed, returns secret and otpauth_uri.
POST /v1/auth/2fa/enable access token + code Turns it on, returns ten recovery codes and fresh tokens. Ends every earlier session.
POST /v1/auth/2fa/disable access token + auth_key + code or recovery code Turns it off, returns fresh tokens.
POST /v1/auth/2fa/recovery-codes access token + code or recovery code Replaces the ten codes.
PUT /v1/auth/password as before, plus code when enrolled
POST /v1/admin/users/:id/2fa/clear admin key Removes the second factor, audited as clear_two_factor, ends the account's sessions.

GET /v1/auth/me gains two_factor_enabled, GET /v1/meta gains two_factor, and the admin user detail gains two_factor_enabled.

Error bodies: TWO_FACTOR_REQUIRED (428, nothing was sent), TWO_FACTOR_INVALID (401), TWO_FACTOR_LOCKED (429), TWO_FACTOR_UNAVAILABLE (503), TWO_FACTOR_ALREADY_ENABLED / TWO_FACTOR_NOT_ENABLED (409).

Operator notes

  • New optional TOTP_ENC_KEY (32 bytes, base64: openssl rand -base64 32). A malformed value stops the server at boot. Unset means the feature is not offered.
  • Back the key up before the first enrolment and never change it. Without it the server cannot check codes. Enrolled accounts are then refused at the second step, not let through, and get back in with a recovery code or an admin clear. Recovery codes are hashed on their own and do not depend on the key. .env.example, both compose files and the bootstrap runbook say so.
  • Migration 059 only adds nullable or defaulted columns and one table, so the previous binary runs against it. A rollback to a binary older than this ignores the second factor: enrolled accounts would sign in with the password alone until the roll forward.

Behaviour worth knowing

  • 428 is deliberate. Released clients show 401/403/404 from login as a wrong password and anything else as a server error with its status.
  • A code is accepted for the current step and one either side, and never twice.
  • Five wrong codes lock the second step for fifteen minutes. The counter is on the users row, not in process memory. Reaching the second step already takes the master password, so the lock cannot be used to shut a stranger out.
  • A request with no code at all is TWO_FACTOR_REQUIRED and does not count as a wrong guess, so a client that predates the feature cannot lock its user out by changing a password.
  • The pending token is a JWT of its own kind: it is refused as an access token and as a refresh token, and dies when token_version moves.
  • Recovery codes are 16 characters (about 79 bits), stored as SHA-256, single use.

Refactor

  • require_auth_key replaces the fetch-and-verify block that update_email and update_password each carried and the new handlers needed.
  • auth_response builds the login response for register, login, the second step and password change.
  • bare_token is the shared body of the refresh and pending tokens.

Tests

19 new: RFC 6238 vectors, step window, seed sealing, key parsing, recovery code normalisation, and DB-backed handler tests for sign-in, replay, lockout, enrolment, disable, regenerate, missing key, pending token expiry by version, password change, admin clear and the meta flag. cargo test --all-targets (716 passed) and clippy clean on Postgres 16. Also driven over HTTP against a local server: register, setup, enable, 428, both second-step proofs, password change, regenerate, disable.

Not in this PR: passkeys (#106), the client (VoltiusApp/voltius#615), the portal sign-in page and the dashboard button for the admin clear.

…105)

The master password derives both the login key and the vault key, so a
phished or reused password was the whole account. An account can now
turn on a second factor the server checks before it hands out a session
or the wrapped secrets.

Login for such an account answers 428 TWO_FACTOR_REQUIRED with a
five-minute pending token, and POST /v1/auth/login/2fa trades it plus a
code or a recovery code for the usual response. Enrolment is setup
(seed + otpauth URI, needs the auth key) then enable (first code), which
returns ten recovery codes and ends every earlier session. Disabling,
regenerating the codes and changing the master password all take a code.

Seeds are sealed with TOTP_ENC_KEY. Without the key the server reports
two_factor: false in /v1/meta and refuses enrolment, but an account
already enrolled is still stopped at the second step and passes it only
with a recovery code. Five wrong codes lock that step for fifteen
minutes, counted in Postgres, and a code is accepted once.

POST /v1/admin/users/:id/2fa/clear is the way back for someone who lost
the authenticator and the codes; it is audited and ends their sessions.

update_email and update_password shared a fetch-and-verify of the auth
key with the new handlers, now require_auth_key, and the AuthResponse
built in four places comes from one auth_response.
@kipavy
kipavy merged commit 667f13a into main Oct 11, 2026
1 check passed
@kipavy
kipavy deleted the feat/two-factor-105 branch October 11, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optional two-factor authentication (TOTP + recovery codes) for cloud accounts

1 participant