nakipelo / orchid-2fa-totp
TOTP (Google Authenticator and compatible apps) channel for nakipelo/orchid-2fa: two-factor authentication of the Laravel Orchid admin panel with an authenticator app.
Requires
- php: ^8.2
- bacon/bacon-qr-code: ^3.0
- laravel/framework: ^11.35 || ^12.0 || ^13.0
- nakipelo/orchid-2fa: ^0.2
- orchid/platform: ^14.53
- pragmarx/google2fa: ^8.0 || ^9.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18.3
- orchestra/testbench: ^9.9 || ^10.0 || ^11.0
- pestphp/pest: ^3.7 || ^4.0
- pestphp/pest-plugin-laravel: ^3.0 || ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
An authenticator-app channel (TOTP: Google Authenticator, Microsoft Authenticator, 1Password, Aegis and other RFC 6238 apps) for nakipelo/orchid-2fa, the two-factor authentication of the Laravel Orchid admin panel.
nakipelo/orchid-2fa does the sign-in flow, the attempt limits, trusted devices, recovery codes, the
profile screen and the admin reset; this package adds the method "Authenticator app" (channel totp):
- codes of RFC 6238 with SHA1, 6 digits and 30-second steps; ±1 step tolerance by default;
- every code is accepted once (replay protection, atomic under parallel requests);
- setup with a QR code rendered on the server (inline SVG, no external services) and the key as text, confirmed by the first code — at sign-in and in the profile;
- the secret is stored encrypted with the application key;
- recovery codes (provided by
nakipelo/orchid-2fa); - an optional e-mail fallback.
Requirements
- PHP 8.2+ (Laravel 13 requires PHP 8.3+), Laravel 11.35+ / 12 / 13,
orchid/platform^14.53 — asnakipelo/orchid-2fa; see its notes on Laravel versions with security advisories; nakipelo/orchid-2fa^0.2 (0.2.0 or later: the channel passes its 0.2 conformance suite), installed and set up as described in its Installation (theHasTwoFactortrait, the profile link, the reset button). Upgrading it from 0.1: see Upgrade 0.1 → 0.2;- a server-side session driver (
file,database,redis, …), notcookie: the secret of an unfinished setup lives in the session. With thecookiedriver it would travel to the browser (encrypted), and a session over the 4 KB cookie limit is silently lost.
Installation
composer require nakipelo/orchid-2fa-totp
php artisan migrate # table two_factor_totp
The service provider is auto-discovered and registers the channel totp
(TwoFactor::extend('totp', …)).
Optionally publish what you want to change:
php artisan vendor:publish --tag=orchid-2fa-totp-config # config/orchid-2fa-totp.php php artisan vendor:publish --tag=orchid-2fa-totp-migrations # database/migrations php artisan vendor:publish --tag=orchid-2fa-totp-views # resources/views/vendor/orchid-2fa-totp/enroll.blade.php php artisan vendor:publish --tag=orchid-2fa-totp-translations # lang/vendor/orchid-2fa-totp/{en,ru}/messages.php
The migration is loaded from the package; publish it only to change it (for example, a non-bigint
users.id), and do it before php artisan migrate: the copy keeps the file name and replaces
the package migration.
Enabling the method
The channel is offered when totp is in channels.allowed of config/orchid-2fa.php. The default
there is ['email', 'totp'], so after the installation users can choose between e-mail and an
authenticator app; nothing else to configure.
Only the authenticator app: allow only totp:
// config/orchid-2fa.php 'channels' => [ 'allowed' => ['totp'], // ... ],
A user who needs 2FA and has no app sets it up at the next sign-in, right after the password. Users whose current method is e-mail are moved to the app at their next sign-in (a code by e-mail as a proof, then the setup) — see Moving all users to another method for who is asked for what, how to count the users who will set it up with the password only, and the rollback.
Setting up the app
Both places are screens of nakipelo/orchid-2fa; this package supplies the setup step:
- At sign-in — an enforced user without a usable method is led to
admin/two-factor/enrollright after the password (Setting up a method at sign-in); - In the profile —
admin/profile/two-factor(the "Manage" button of the profile block): enable, change to the app, "Set up again" for a new phone (Profile).
The setup step shows a QR code, the key in groups of four characters with the account
("Issuer (label)") and an otpauth:// link for an app on the same device. The partial has no inline
styles or scripts, so it works under a strict Content-Security-Policy (the QR code is an inline SVG
sized by its width/height attributes). A new 160-bit secret is kept only in the server session
until the first correct code; then it is stored. Setting up again replaces the secret only after the
code of the new one, so the old app works until then.
At sign-in the code screen asks for "the code from your authenticator app"; the attempt limits are
those of verifier channels in nakipelo/orchid-2fa
(Limits).
Recovery codes
The method needs recovery codes (a lost phone), so nakipelo/orchid-2fa issues them whenever the app
is set up, and accepts them at sign-in ("Use a recovery code") and in the profile. They are a feature
of the main package: see Recovery codes.
Configuration
config/orchid-2fa-totp.php (keys orchid-2fa-totp.*):
| Key | Default | Meaning |
|---|---|---|
window |
1 |
Tolerance in 30-second steps before and after the current one (clock drift, typing time). 0 — only the current step; a negative number counts as 0, a value that is not a number as 1. The algorithm itself (SHA1, 6 digits, 30 s) is fixed: it is what the apps use. |
email_fallback |
false |
Offer "Send a code by e-mail instead" on the code screen (E-mail fallback). |
issuer |
null |
Name the app shows above the code; null — config('app.name'). |
label |
null |
Account name in the app; null — the user's e-mail address (the attribute orchid-2fa.channels.email.attribute, email by default). |
The settings of 2FA itself (allowed methods, attempt limits, recovery codes, enrollment_ttl) are in
config/orchid-2fa.php of nakipelo/orchid-2fa.
Issuer and label per user
A string in the config is the same for everyone. To compute the issuer or the label per user, register
closures in a service provider of the application (a closure in a config file breaks
config:cache):
use Nakipelo\Orchid2FATotp\TotpChannel; public function boot(): void { TotpChannel::issuerUsing(fn ($user) => config('app.name').' ('.$user->tenant->name.')'); TotpChannel::labelUsing(fn ($user) => $user->name); }
A closure takes precedence over the config; an empty result falls back to app.name / the e-mail
address. null removes the closure. The values are written into the app at the setup: changing them
later does not change apps already set up.
E-mail fallback
With email_fallback = true the code screen of a TOTP user offers "Send a code by e-mail instead" — a
regular e-mail code under the e-mail send limits of nakipelo/orchid-2fa — but only while:
- the
emailchannel is registered and inchannels.allowed, and - the user's current address is the address they had when they set up the app. At the setup an HMAC
of the address (keyed with
APP_KEY) is stored; it is compared with the current address usingAPP_KEYand thenAPP_PREVIOUS_KEYS.
The hash is written only at the setup and at "Set up again", never at sign-in. After a change of the
address — for example from a stolen session — the fallback is unavailable until the owner sets up the
app again, which also stores the new address. nakipelo/orchid-2fa checks the condition again when
the fallback is requested.
Replay protection
A code is accepted only for a time step after the last accepted one (two_factor_totp.last_step), so
a code cannot be used twice, nor an older code after a newer one, including the code of the setup.
The step is taken with an atomic conditional UPDATE … WHERE secret = ? AND (last_step IS NULL OR last_step < ?): of parallel requests with the same code only one passes, and a code of the old secret
checked while "Set up again" completes is refused (the row then holds the new secret).
The consequences:
- One sign-in per 30-second step: a second sign-in in the same step waits for the next code.
- A code may belong to a step up to
windowsteps after the server's current one (a phone whose clock runs ahead), and that step becomes the last accepted one. The next sign-in then needs a code of a later step: with the defaultwindow = 1it is possible up to two steps (about a minute) later —window + 1steps in general. - If the server clock goes back (a wrong time corrected, a restore from a snapshot), the last accepted step is ahead of the clock and every code is refused until the clock catches up with it. Users who cannot wait sign in with a recovery code. Keep the server synchronised with NTP.
Removing the package
In this order:
-
Reset the users of the method, otherwise they cannot sign in (fail-closed):
php artisan orchid-2fa:reset --channel=totp --force
(The command of
nakipelo/orchid-2faalso works after the removal.) -
If
config/orchid-2fa.phpliststotpinchannels.allowed, remove it and keep another method there — after "only the authenticator app" (['totp']) put back['email']. A name without a driver is ignored, and with no allowed method leftnakipelo/orchid-2fathrows on boot inlocal/testing(socomposer removefails in itspackage:discoverstep); elsewhere users who need 2FA cannot sign in. -
Roll back the migration while the package is still installed — only the migrations of this path are rolled back (the others are listed as "Migration not found"):
php artisan migrate:reset --path=vendor/nakipelo/orchid-2fa-totp/database/migrations
For a published migration pass the path of the copy.
-
composer remove nakipelo/orchid-2fa-totp.
If the package is already removed, drop the table two_factor_totp and delete its row from the
migrations table by hand. The users reset in step 1 sign in with the default method (e-mail codes,
when email is allowed) or set up an allowed one at the next sign-in.
Limitations
APP_KEYrotation. The secrets are encrypted with the application key. RotatingAPP_KEYwithout keeping the old one inAPP_PREVIOUS_KEYSmakes them unreadable: codes are refused (fail-closed, anerrorin the log) and users sign in only with recovery codes or after a reset by an administrator. It also disables the e-mail fallback until the app is set up again.- Server time. Codes depend on the clock: keep the server synchronised (NTP). The tolerance is
one step (30 s) each way by default (
window). With the phone's clock ahead, the next sign-in is possible up to two steps after the previous one; after the server clock goes back, codes are refused until it catches up with the last accepted step — recovery codes still work (see Replay protection). - First setup. A user without any confirmed method (new, or after a reset) sets up the app
protected by the password only — the accepted risk of a first enrollment, as in Laravel Fortify; see
Limitations and recommendations
of
nakipelo/orchid-2fa. - Lost phone. Without the phone, sign in with a recovery code and set the app up again. With the phone and the recovery codes lost, only a reset by an administrator helps.
- Address change. Changing the e-mail address disables the e-mail fallback until the app is set up again (see above). The app itself keeps working.
- Removal. Remove the package only after
orchid-2fa:reset --channel=totp(see above). - Session driver. A server-side session driver is required (see Requirements).
- A leftover row. A reset done while the package is not installed leaves the row in
two_factor_totp; it does not affect the sign-in and is replaced by the next setup.
Development
composer install composer check # pint --test, larastan (level 8), pest (with the conformance suite of nakipelo/orchid-2fa) composer serve # workbench: http://127.0.0.1:8000/admin, admin@example.com / password, MAIL_MAILER=log
The tests run on SQLite in memory. tests/Feature/ReplayTest.php, ResetTest.php and
VerificationTest.php also run on MySQL and PostgreSQL in CI (job databases): set DB_CONNECTION
(mysql or pgsql) and the DB_* variables of a test database to run them locally, for example
DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_DATABASE=orchid_2fa_totp DB_USERNAME=postgres DB_PASSWORD=postgres \ vendor/bin/pest tests/Feature/ReplayTest.php tests/Feature/ResetTest.php tests/Feature/VerificationTest.php
On a server the test with parallel processes uses a second connection to that database (on SQLite a
shared file); it needs pcntl and posix and is skipped without them. The conformance suite below
boots its own SQLite database.
Conformance suite
tests/Conformance/TotpChannelConformanceTest.php runs the conformance suite of
nakipelo/orchid-2fa 0.2 (ChannelConformanceTestCase) against the channel, including:
- the race test of the replay protection, which the suite requires from every verifier channel
(
racingChannel(), or an explicit opt-out withskipsRaceTest()).racingChannel()returns a subclass ofTotpChannelthat overridesmatchingStep()— called byverifyCode()after the row (the secret andlast_step) is read and right before the conditionalUPDATE— and runs the suite's callback there: a second verification of the same code runs entirely between the read and the write of the first, and exactly one of them must pass; - a new enrollment replaces the old binding: a code of the old secret is refused afterwards;
- the values of
enrollmentData()(the key, theotpauth://URI, the QR code) do not reach the log.
invalidCode() returns a code that is none of the codes (±3 steps) of the current secret and of the
secret of the enrollment step the code is for (the $state argument of the suite), so the wrong code
of an enrollment is never a valid one by chance.
The workbench allows ['email', 'totp']; ORCHID_2FA_ALLOWED=totp and ORCHID_2FA_TOTP_FALLBACK=1
in the environment of composer serve switch to TOTP only and enable the e-mail fallback. The test
administrator holds all permissions, so it is enforced: by default it gets an e-mail code (in the
log); with ORCHID_2FA_ALLOWED=totp it sets up the app right after the password.
License
MIT, see LICENSE.