Search by

nakipelo / orchid-2fa-totp

EgorGruzdev

TOTP (Google Authenticator and compatible apps) channel for nakipelo/orchid-2fa: two-factor authentication of the Laravel Orchid admin panel with an authenticator app.

Package info

github.com/nakipelo/orchid-2fa-totp

pkg:composer/nakipelo/orchid-2fa-totp

Statistics

Installs: 1

Dependents: 0

Suggesters: 1

Stars: 0

Open Issues: 0

0.1.0 2026-11-03 22:02 UTC

This package is auto-updated.

Last update: 2026-11-03 22:03:35 UTC


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 — as nakipelo/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 (the HasTwoFactor trait, 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, …), not cookie: the secret of an unfinished setup lives in the session. With the cookie driver 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/enroll right 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 email channel is registered and in channels.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 using APP_KEY and then APP_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 window steps 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 default window = 1 it is possible up to two steps (about a minute) later — window + 1 steps 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:

  1. 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-2fa also works after the removal.)

  2. If config/orchid-2fa.php lists totp in channels.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 left nakipelo/orchid-2fa throws on boot in local/testing (so composer remove fails in its package:discover step); elsewhere users who need 2FA cannot sign in.

  3. 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.

  4. 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_KEY rotation. The secrets are encrypted with the application key. Rotating APP_KEY without keeping the old one in APP_PREVIOUS_KEYS makes them unreadable: codes are refused (fail-closed, an error in 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 with skipsRaceTest()). racingChannel() returns a subclass of TotpChannel that overrides matchingStep() — called by verifyCode() after the row (the secret and last_step) is read and right before the conditional UPDATE — 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, the otpauth:// 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.