Skip to content

feat(2fa): sign in with an authenticator app, with recovery codes - #277

Merged
roncodes merged 2 commits into
release/v1.6.65from
feat/authenticator-app-2fa
Sep 28, 2026
Merged

roncodes merged 2 commits into
release/v1.6.65from
feat/authenticator-app-2fa

Conversation

@roncodes

@roncodes roncodes commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

Closes #163. Stacked on #272 (2FA hardening): it builds on that PR's sign-in flow and attempt limit. Merge #272 first and this PR will retarget to main. Console side: fleetbase/fleetbase#686.

Why

The ticket asks for Authy-style authenticator 2FA next to email and SMS. Twilio has retired the Authy API, so this uses standard authenticator-app codes (TOTP, RFC 6238). They work with Authy as well as Google Authenticator, Microsoft Authenticator and 1Password, with no per-verification cost and no vendor dependency.

What changed

Setup (all in the users group; the account's own data only):

Endpoint Needs
POST users/two-fa/authenticator/setup → secret, otpauth:// URL, QR code current password
POST users/two-fa/authenticator/confirm → 8 recovery codes, shown once a code from the app
POST users/two-fa/authenticator/disable current password
POST users/two-fa/recovery-codes → new codes current password
GET users/two-fa/authenticator → {enabled, confirmed_at, recovery_codes_remaining} –
  • A new secret is kept aside until it's confirmed (15 minutes), so a working app keeps working while the user replaces it.
  • saveTwoFactorSettings refuses authenticator_app until an app is set up.
  • Removing the app when it's the 2FA method turns 2FA off rather than locking the user out.

Sign-in

  • With method authenticator_app, two-fa/validate sends nothing and returns method: "authenticator_app". The client token points at the user server side (Redis), so it doesn't expose who it's for.
  • two-fa/verify accepts a code from the app, allowing one 30-second step either side for clock drift. Each code works once (the last used step is stored). It also accepts a recovery code, once each; dashes, spaces and case are ignored.
  • Wrong codes count towards the existing 5-attempt lockout from fix(2fa): start two-factor sessions only after the password is checked #272.
  • two-fa/resend sends a code by email (SMS if there's no email) as the fallback the ticket asks for. It returns the new method so the console can switch.

Storage and logging

  • The secret is encrypted with the app key. Recovery codes are stored as keyed SHA-256 hashes (they're random and single use). Neither is returned after setup or logged.
  • These are written to the auth activity log: authenticator_enabled, authenticator_disabled, recovery_codes_regenerated, recovery_code_used, two_factor_verified (with the method), and two_factor_locked.

QR codes and dependencies

  • New Fleetbase\Support\Barcode helper. It owns milon/barcode, so extensions can move to it later instead of calling milon directly.
  • qrCodeSvg() draws a compact SVG (one path, about 9 KB for an otpauth:// URL, versus 59 KB from milon's own SVG). It has a white background and a 4-module quiet zone, so it scans on the console's dark theme. milon's SVG is transparent with no border.
  • New dependencies: pragmarx/google2fa ^8.0, and milon/barcode ^10.0, which every install already has through Fleet-Ops. No bacon/bacon-qr-code.

Tests

  • The shared test container now provides a recording activity logger and a test encrypter, since core-api doesn't depend on illuminate/encryption.

Heads-up

  • milon's QR encoder emits PHP 8.1+ "implicit float to int" deprecation notices. They're harmless and Fleet-Ops' QR codes already trigger them. Laravel sends them to the deprecations log channel.
  • Hosts need composer update to install pragmarx/google2fa.

Verification

  • New tests:
    • setup and confirm, including an expired setup and a wrong code;
    • sign-in with an app code, with nothing sent;
    • replay rejected;
    • recovery codes accepted once each;
    • wrong codes count towards lockout;
    • a challenge can't be used for another user;
    • email fallback;
    • disable turns 2FA off;
    • recovery code regeneration;
    • endpoint password checks;
    • Barcode renders exact runs, with the quiet zone and errors covered;
    • the controller reports the challenge method.
  • The full suite passes.
  • QR decoding: a generated otpauth:// QR decoded correctly with Chromium's BarcodeDetector, drawn over the dark console background.
  • End to end on the dev stack through the real HTTP kernel, MySQL and Redis, inside a rolled-back transaction:
    • setup rejects a wrong password;
    • confirm returns 8 codes and sets the method;
    • login → validate returns method: authenticator_app, and 0 codes are sent;
    • a wrong code and the reused setup code are both rejected;
    • the next code signs in, and a recovery code signs in (7 left);
    • resend falls back to email;
    • all the events above were logged.

- two-fa/check no longer starts a session from the identity alone, which let
  the emailed/SMS code stand in for the password and revealed which accounts
  have 2FA on. It now always answers {twoFaSession: null, isTwoFaEnabled: false}
  so older consoles fall through to auth/login, which already starts the
  session after the password check. createTwoFaSessionIfEnabled is deprecated.
- Store 2FA sessions with a 600 second TTL. EX was being given an absolute
  timestamp, so sessions lived for decades.
- Invalidate a 2FA session after 5 wrong codes, counted per session so
  resending a code does not reset the count, and compare codes with
  hash_equals.
- Generate verification codes with random_int instead of mt_rand.
Adds authenticator apps (TOTP, RFC 6238) as a 2FA method, next to email
and SMS. Works with Google Authenticator, Authy, 1Password and similar
apps.

- Setup: POST users/two-fa/authenticator/setup (current password) returns
  a secret, an otpauth:// URL and a QR code. confirm checks a code from
  the app, makes it the user's 2FA method and returns 8 one-time recovery
  codes. disable and recovery-codes also need the current password.
- Sign-in: when the method is authenticator_app, two-fa/validate sends
  nothing and returns method "authenticator_app". two-fa/verify accepts a
  code from the app (one time step either side, each code usable once) or
  a recovery code. Wrong codes count towards the existing 5-attempt
  lockout. two-fa/resend sends a code by email (SMS without an email) as
  a fallback.
- Storage: the secret is encrypted with the app key; recovery codes are
  stored as keyed SHA-256 hashes. Neither is ever returned again or logged.
- Enable, disable, recovery code use, successful sign-ins and lockouts are
  written to the `auth` activity log.
- saveTwoFactorSettings refuses authenticator_app until an app is set up.
- New Fleetbase\Support\Barcode helper, which owns milon/barcode. QR codes
  are drawn as a compact SVG on a white background with a quiet zone, so
  they scan on dark themes too.
- New dependencies: pragmarx/google2fa ^8.0 and milon/barcode ^10.0
  (already installed everywhere through Fleet-Ops).
- Tests: the shared test container now provides a recording activity
  logger and a test encrypter.

Closes #163
@roncodes
roncodes changed the base branch from fix/2fa-login-hardening to release/v1.6.65 September 28, 2026 03:28
@roncodes
roncodes merged commit 2dd7749 into release/v1.6.65 Sep 28, 2026
@roncodes
roncodes deleted the feat/authenticator-app-2fa branch September 28, 2026 03:33
@roncodes roncodes mentioned this pull request Sep 28, 2026
6 tasks done
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.

1 participant