feat(auth): add two-factor authentication #16

Merged
ryan.mcgee merged 40 commits from feat/two-factor into main 2026-10-15 18:08:31 +00:00
Owner

Summary

  • Adds a second factor to signing in: authenticator app (TOTP), security keys and passkeys (WebAuthn), and recovery codes.
  • Stacked on #15 (feat/email-verification); last of the three auth changes (OIDC, email verification, 2FA / WebAuthn). New dependencies, as approved: nimble_totp, wax_ (with cbor, x509, asn1_compiler) and eqrcode.

Changes

  • Once a user has a factor, every way of logging in asks for it. UserAuth.log_in_user (the one place all sign-in paths end: password, OIDC) now only makes the login wait in the signed session for /users/two-factor; the waiting state grants nothing, and start_session/3 is the only thing that makes a session after it. Audit of session creation: password and OIDC (gated), the second-factor controller (verified), password change (sudo mode, see below), and token reissue (an already valid session).
  • A password change needs sudo mode (a recent login, second factor included), so it re-logs in without a second step.
  • TOTP: RFC 6238 test vectors checked; secret stored encrypted; a code works once (time step claimed atomically, also under racing requests); one step of drift allowed.
  • Recovery codes: 10, random (CSPRNG), hashed at rest, single use, made when the first factor is added and removed with the last.
  • 5 wrong answers lock the second step for 10 minutes per user, even for the right code; success clears the count.
  • WebAuthn: registration and sign-in by challenge (session or socket, used once, 2 minutes), RP id and origin from the endpoint, attestation none, signature counter that goes backwards refused, 10 keys per user.
  • Adding the first factor ends the user's other sessions (the current one stays).
  • Settings page /users/settings/two-factor (sudo mode): QR code, key, keys list, recovery codes; the login page gets the code form and a security-key button (assets/js/webauthn.js).
  • README updated, including that wax_ says it has not been independently reviewed.

Verification

  • mix format --check-formatted, mix compile --warnings-as-errors, mix credo --strict, mix dialyzer: clean.
  • mix test: 1311 passed on SQLite and on PostgreSQL. WebAuthn is tested against a software authenticator (ES256) that wrong-origin, wrong-host, wrong-challenge, wrong-signature, other-user, replayed and counter-regression cases are refused by.
  • Migration round trip with data on both backends, including the cascade on user delete.
  • Smoke test in Chromium: enabled an authenticator app (a code generated by an independent JavaScript TOTP, which matched the RFC vector), logged in through the second step (wrong code refused first), added a security key with a CDP virtual authenticator, and logged in with it.
## Summary - Adds a second factor to signing in: authenticator app (TOTP), security keys and passkeys (WebAuthn), and recovery codes. - Stacked on #15 (`feat/email-verification`); last of the three auth changes (OIDC, email verification, 2FA / WebAuthn). New dependencies, as approved: `nimble_totp`, `wax_` (with `cbor`, `x509`, `asn1_compiler`) and `eqrcode`. ## Changes - Once a user has a factor, **every** way of logging in asks for it. `UserAuth.log_in_user` (the one place all sign-in paths end: password, OIDC) now only makes the login wait in the signed session for `/users/two-factor`; the waiting state grants nothing, and `start_session/3` is the only thing that makes a session after it. Audit of session creation: password and OIDC (gated), the second-factor controller (verified), password change (sudo mode, see below), and token reissue (an already valid session). - A password change needs sudo mode (a recent login, second factor included), so it re-logs in without a second step. - TOTP: RFC 6238 test vectors checked; secret stored encrypted; a code works once (time step claimed atomically, also under racing requests); one step of drift allowed. - Recovery codes: 10, random (CSPRNG), hashed at rest, single use, made when the first factor is added and removed with the last. - 5 wrong answers lock the second step for 10 minutes per user, even for the right code; success clears the count. - WebAuthn: registration and sign-in by challenge (session or socket, used once, 2 minutes), RP id and origin from the endpoint, attestation `none`, signature counter that goes backwards refused, 10 keys per user. - Adding the first factor ends the user's other sessions (the current one stays). - Settings page `/users/settings/two-factor` (sudo mode): QR code, key, keys list, recovery codes; the login page gets the code form and a security-key button (`assets/js/webauthn.js`). - README updated, including that `wax_` says it has not been independently reviewed. ## Verification - `mix format --check-formatted`, `mix compile --warnings-as-errors`, `mix credo --strict`, `mix dialyzer`: clean. - `mix test`: 1311 passed on SQLite and on PostgreSQL. WebAuthn is tested against a software authenticator (ES256) that wrong-origin, wrong-host, wrong-challenge, wrong-signature, other-user, replayed and counter-regression cases are refused by. - Migration round trip with data on both backends, including the cascade on user delete. - Smoke test in Chromium: enabled an authenticator app (a code generated by an independent JavaScript TOTP, which matched the RFC vector), logged in through the second step (wrong code refused first), added a security key with a CDP virtual authenticator, and logged in with it.
- authenticator app (TOTP, RFC 6238), security keys and passkeys
  (WebAuthn via wax_) and ten single-use recovery codes
- every way of logging in (password, identity provider) waits for the
  second factor; the wait grants no access, only start_session makes a
  session; a password change in sudo mode is not asked again
- a code works once (atomic claim of the time step), five wrong answers
  lock the second step for ten minutes, secrets are encrypted at rest
  and recovery codes hashed
- adding the first factor ends the other sessions; removing the last
  one removes the recovery codes
- settings page with QR code (eqrcode), key management and recovery
  codes; WebAuthn tested against a software authenticator and a
  Chromium virtual authenticator
ryan.mcgee changed target branch from feat/email-verification to main 2026-10-15 18:08:21 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
White_Ghost_Cyber_Systems/traefik-control!16
No description provided.