From 61891e96247d58d7708f47362b51a9997950ae50 Mon Sep 17 00:00:00 2001 From: Njogu Amos <29255728+njoguamos@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:42:20 +0300 Subject: [PATCH 1/3] feat: add TOTP storage and service Add opt-in TOTP method, encrypted secret and recovery code storage, and enrollment and verification logic. --- config/laranail/authkit.php | 6 + ...o_factor_authentication_to_users_table.php | 30 +++ src/Enums/TwoFactorMethod.php | 11 + src/Services/TwoFactorAuthentication.php | 196 ++++++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 database/migrations/two-factor/2026_10_02_000000_add_two_factor_authentication_to_users_table.php create mode 100644 src/Enums/TwoFactorMethod.php create mode 100644 src/Services/TwoFactorAuthentication.php diff --git a/config/laranail/authkit.php b/config/laranail/authkit.php index 2ab109e..5336b57 100644 --- a/config/laranail/authkit.php +++ b/config/laranail/authkit.php @@ -32,6 +32,12 @@ ], ], + 'two_factor' => [ + 'enabled' => (bool) env('AUTHKIT_TWO_FACTOR_ENABLED', false), + 'table' => env('AUTHKIT_TWO_FACTOR_TABLE', 'users'), + 'challenge_expiration_minutes' => (int) env('AUTHKIT_TWO_FACTOR_CHALLENGE_EXPIRATION_MINUTES', 5), + ], + /* |-------------------------------------------------------------------------- | API tokens diff --git a/database/migrations/two-factor/2026_10_02_000000_add_two_factor_authentication_to_users_table.php b/database/migrations/two-factor/2026_10_02_000000_add_two_factor_authentication_to_users_table.php new file mode 100644 index 0000000..f0963cf --- /dev/null +++ b/database/migrations/two-factor/2026_10_02_000000_add_two_factor_authentication_to_users_table.php @@ -0,0 +1,30 @@ +enum('two_factor_method', ['none', 'totp'])->default('none'); + $table->text('two_factor_secret')->nullable(); + $table->text('two_factor_recovery_codes')->nullable(); + $table->timestamp('two_factor_confirmed_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table(config('laranail.authkit.two_factor.table', 'users'), function (Blueprint $table): void { + $table->dropColumn([ + 'two_factor_method', 'two_factor_secret', 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + ]); + }); + } +}; diff --git a/src/Enums/TwoFactorMethod.php b/src/Enums/TwoFactorMethod.php new file mode 100644 index 0000000..5c704c1 --- /dev/null +++ b/src/Enums/TwoFactorMethod.php @@ -0,0 +1,11 @@ +value($user, 'two_factor_method') === TwoFactorMethod::TOTP->value + && $this->value($user, 'two_factor_secret') !== null + && $this->value($user, 'two_factor_confirmed_at') !== null; + } + + /** Start or resume enrollment. The secret remains inactive until confirm() succeeds. */ + public function begin(Authenticatable $user): array + { + if ($this->enabled($user)) { + throw new LogicException('Two-factor authentication is already enabled.'); + } + + $stored = $this->value($user, 'two_factor_secret'); + $confirmed = $this->value($user, 'two_factor_confirmed_at'); + + if ($stored === null || $confirmed !== null) { + $secret = $this->provider->generateSecretKey(32); + $this->fill($user, [ + 'two_factor_method' => TwoFactorMethod::NONE->value, + 'two_factor_secret' => Crypt::encryptString($secret), + 'two_factor_recovery_codes' => null, + 'two_factor_confirmed_at' => null, + ]); + } else { + $secret = $this->decrypt((string) $stored); + } + + $label = (string) ($this->value($user, 'email') ?? $user->getAuthIdentifier()); + + return [ + 'secret' => $secret, + 'qr_code_url' => $this->provider->qrCodeUrl((string) config('app.name'), $label, $secret), + ]; + } + + /** Confirm enrollment and return one-time recovery codes. */ + public function confirm(Authenticatable $user, string $code): ?array + { + $codes = $this->withUserLock($user, fn (): ?array => $this->confirmLocked($user, $code)); + + return is_array($codes) ? $codes : null; + } + + /** Consume either a TOTP or a single-use recovery code. */ + public function verify(Authenticatable $user, string $code): bool + { + return $this->withUserLock($user, fn (): bool => $this->verifyLocked($user, $code)); + } + + public function disable(Authenticatable $user): void + { + $this->fill($user, [ + 'two_factor_method' => TwoFactorMethod::NONE->value, + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + 'two_factor_confirmed_at' => null, + ]); + } + + public function recoveryCodes(Authenticatable $user): array + { + $stored = $this->value($user, 'two_factor_recovery_codes'); + + if (! is_string($stored) || $stored === '') { + return []; + } + + try { + $codes = json_decode(Crypt::decryptString($stored), true, flags: JSON_THROW_ON_ERROR); + } catch (Throwable) { + return []; + } + + return is_array($codes) ? $codes : []; + } + + public function rotateRecoveryCodes(Authenticatable $user): array + { + $codes = array_map(static fn (): string => RecoveryCode::generate(), range(1, 8)); + $this->fill($user, ['two_factor_recovery_codes' => Crypt::encryptString(json_encode($codes, JSON_THROW_ON_ERROR))]); + + return $codes; + } + + private function confirmLocked(Authenticatable $user, string $code): ?array + { + $stored = $this->value($user, 'two_factor_secret'); + + if ($stored === null || $this->value($user, 'two_factor_confirmed_at') !== null) { + return null; + } + + if (! $this->provider->verify($this->decrypt((string) $stored), $code)) { + return null; + } + + $codes = array_map(static fn (): string => RecoveryCode::generate(), range(1, 8)); + $this->fill($user, [ + 'two_factor_method' => TwoFactorMethod::TOTP->value, + 'two_factor_recovery_codes' => Crypt::encryptString(json_encode($codes, JSON_THROW_ON_ERROR)), + 'two_factor_confirmed_at' => now(), + ]); + + return $codes; + } + + private function verifyLocked(Authenticatable $user, string $code): bool + { + if (! $this->enabled($user)) { + return false; + } + + $codes = $this->recoveryCodes($user); + $normalized = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code) ?? ''); + $index = array_search($normalized, array_map( + static fn (string $stored): string => strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $stored) ?? ''), + $codes, + ), true); + + if ($index !== false) { + unset($codes[$index]); + $this->fill($user, [ + 'two_factor_recovery_codes' => $codes === [] + ? null + : Crypt::encryptString(json_encode(array_values($codes), JSON_THROW_ON_ERROR)), + ]); + + return true; + } + + return $this->provider->verify( + $this->decrypt((string) $this->value($user, 'two_factor_secret')), + $code, + ); + } + + private function decrypt(string $secret): string + { + try { + return Crypt::decryptString($secret); + } catch (Throwable) { + return (string) decrypt($secret); + } + } + + private function value(Authenticatable $user, string $key): mixed + { + $value = method_exists($user, 'getAttribute') ? $user->getAttribute($key) : null; + + return $value instanceof BackedEnum ? $value->value : $value; + } + + private function fill(Authenticatable $user, array $attributes): void + { + if (method_exists($user, 'forceFill')) { + $user->forceFill($attributes)->save(); + } + } + + private function withUserLock(Authenticatable $user, callable $callback): mixed + { + $lockKey = 'authkit:two-factor:verify:' . hash( + 'sha256', + $user::class . ':' . (string) $user->getAuthIdentifier(), + ); + + try { + return Cache::lock($lockKey, 10)->block(5, $callback); + } catch (LockTimeoutException) { + return false; + } + } +} From 377f3b05789b01f204cf5fe1357c16bc76b8f156 Mon Sep 17 00:00:00 2001 From: Njogu Amos <29255728+njoguamos@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:42:26 +0300 Subject: [PATCH 2/3] feat: add two-factor API flows Add MFA login challenges, account management endpoints, verified-token abilities, and route middleware. --- routes/api.php | 14 +++ src/Actions/IssueTokenForUser.php | 9 +- src/Contracts/IssueTokenForUserInterface.php | 1 + src/Http/Controllers/Api/LoginController.php | 17 +++- .../Api/TwoFactorChallengeController.php | 57 ++++++++++++ .../Api/TwoFactorManagementController.php | 91 +++++++++++++++++++ .../RequireTwoFactorAuthentication.php | 36 ++++++++ src/Providers/AuthKitServiceProvider.php | 10 ++ src/Support/AuthKit.php | 5 + 9 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 src/Http/Controllers/Api/TwoFactorChallengeController.php create mode 100644 src/Http/Controllers/Api/TwoFactorManagementController.php create mode 100644 src/Http/Middleware/RequireTwoFactorAuthentication.php diff --git a/routes/api.php b/routes/api.php index 5df3b3e..259fb4e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -39,6 +39,20 @@ ->middleware('throttle:10,1') ->name('login'); + if (AuthKit::twoFactorEnabled()) { + Route::post('/two-factor/challenge', Api\TwoFactorChallengeController::class) + ->middleware('throttle:5,1') + ->name('two-factor.challenge'); + + Route::middleware(['auth:sanctum', 'throttle:10,1'])->group(function (): void { + Route::get('/user/two-factor', [Api\TwoFactorManagementController::class, 'show'])->name('user-two-factor.show'); + Route::post('/user/two-factor', [Api\TwoFactorManagementController::class, 'begin'])->name('user-two-factor.begin'); + Route::post('/user/two-factor/confirm', [Api\TwoFactorManagementController::class, 'confirm'])->name('user-two-factor.confirm'); + Route::post('/user/two-factor/disable', [Api\TwoFactorManagementController::class, 'disable'])->name('user-two-factor.disable'); + Route::post('/user/two-factor/recovery-codes', [Api\TwoFactorManagementController::class, 'regenerateRecoveryCodes'])->name('user-two-factor.recovery-codes'); + }); + } + Route::post('/logout', Api\LogoutController::class) ->middleware('auth:sanctum') ->name('logout'); diff --git a/src/Actions/IssueTokenForUser.php b/src/Actions/IssueTokenForUser.php index 33d3759..5adbad5 100644 --- a/src/Actions/IssueTokenForUser.php +++ b/src/Actions/IssueTokenForUser.php @@ -32,10 +32,17 @@ public function execute( ?string $name = null, ?array $abilities = null, ?DateTimeInterface $expiresAt = null, + bool $twoFactorVerified = false, ): TokenResult { + $tokenAbilities = $abilities ?? $this->defaultAbilities(); + + if ($twoFactorVerified && ! in_array('two-factor:verified', $tokenAbilities, true)) { + $tokenAbilities[] = 'two-factor:verified'; + } + $token = $user->createToken( name: $name ?? 'api-token', - abilities: $abilities ?? $this->defaultAbilities(), + abilities: $tokenAbilities, expiresAt: $expiresAt ?? $this->defaultExpiry(), ); diff --git a/src/Contracts/IssueTokenForUserInterface.php b/src/Contracts/IssueTokenForUserInterface.php index 58bbad1..6ee67b9 100644 --- a/src/Contracts/IssueTokenForUserInterface.php +++ b/src/Contracts/IssueTokenForUserInterface.php @@ -20,5 +20,6 @@ public function execute( ?string $name = null, ?array $abilities = null, ?DateTimeInterface $expiresAt = null, + bool $twoFactorVerified = false, ): TokenResult; } diff --git a/src/Http/Controllers/Api/LoginController.php b/src/Http/Controllers/Api/LoginController.php index 35bfe8c..82704ac 100644 --- a/src/Http/Controllers/Api/LoginController.php +++ b/src/Http/Controllers/Api/LoginController.php @@ -10,6 +10,7 @@ use Simtabi\Laranail\AuthKit\Enums\AuthStatus; use Simtabi\Laranail\AuthKit\Support\AuthResult; use Simtabi\Laranail\AuthKit\Contracts\LoginUserInterface; +use Simtabi\Laranail\AuthKit\Services\TwoFactorAuthentication; use Simtabi\Laranail\AuthKit\Contracts\IssueTokenForUserInterface; use Simtabi\Laranail\AuthKit\Contracts\AttemptEmailPasswordLoginInterface; use Simtabi\Laranail\AuthKit\Http\Requests\AttemptEmailPasswordLoginRequest; @@ -45,6 +46,20 @@ protected function guard(): string protected function apiPassed(AuthResult $result): JsonResponse { + if (AuthKit::twoFactorEnabled() && app(TwoFactorAuthentication::class)->enabled($result->user)) { + $challenge = bin2hex(random_bytes(32)); + cache()->put( + 'authkit:two-factor:challenge:' . hash('sha256', $challenge), + ['user_id' => $result->user->getAuthIdentifier(), 'guard' => $this->guard()], + now()->addMinutes((int) config('laranail.authkit.two_factor.challenge_expiration_minutes', 5)), + ); + + return $this->jsonResponse(status: 'mfa_required', data: [ + 'challenge_token' => $challenge, + 'expires_in' => (int) config('laranail.authkit.two_factor.challenge_expiration_minutes', 5) * 60, + ], code: 202)->header('Cache-Control', 'no-store'); + } + $tokenResult = $this->issuer->execute( user: $result->user, name: 'api-login', @@ -53,7 +68,7 @@ protected function apiPassed(AuthResult $result): JsonResponse return $this->jsonResponse(status: 'success', data: [ 'token' => $tokenResult->token, 'user' => $tokenResult->user, - ]); + ])->header('Cache-Control', 'no-store'); } protected function passed(Request $request, AuthResult $result): JsonResponse diff --git a/src/Http/Controllers/Api/TwoFactorChallengeController.php b/src/Http/Controllers/Api/TwoFactorChallengeController.php new file mode 100644 index 0000000..4131bb5 --- /dev/null +++ b/src/Http/Controllers/Api/TwoFactorChallengeController.php @@ -0,0 +1,57 @@ +validate([ + 'challenge_token' => ['required', 'string', 'size:64'], + 'code' => ['required', 'string', 'min:6', 'max:20'], + ]); + + $key = 'authkit:two-factor:challenge:' . hash('sha256', $validated['challenge_token']); + + try { + return Cache::lock($key . ':lock', 10)->block(5, function () use ($key, $validated, $twoFactor, $issuer): JsonResponse { + $challenge = Cache::get($key); + + if (! is_array($challenge) || ! isset($challenge['user_id'], $challenge['guard'])) { + throw ValidationException::withMessages(['code' => 'The challenge is invalid or expired.']); + } + + $model = UserModelResolver::resolve($challenge['guard']); + $user = $model::query()->find($challenge['user_id']); + + if ($user === null || ! $twoFactor->verify($user, $validated['code'])) { + throw ValidationException::withMessages(['code' => 'The verification code is invalid.']); + } + + Cache::forget($key); + $token = $issuer->execute(user: $user, name: 'api-login', twoFactorVerified: true); + + return response()->json([ + 'status' => 'success', + 'data' => ['token' => $token->token, 'user' => $token->user], + ])->header('Cache-Control', 'no-store'); + }); + } catch (LockTimeoutException) { + throw ValidationException::withMessages(['code' => 'The challenge is already being processed.']); + } + } +} diff --git a/src/Http/Controllers/Api/TwoFactorManagementController.php b/src/Http/Controllers/Api/TwoFactorManagementController.php new file mode 100644 index 0000000..9f65f9e --- /dev/null +++ b/src/Http/Controllers/Api/TwoFactorManagementController.php @@ -0,0 +1,91 @@ +json([ + 'status' => 'success', + 'data' => [ + 'method' => $request->user()->two_factor_method instanceof BackedEnum + ? $request->user()->two_factor_method->value + : ($request->user()->two_factor_method ?? 'none'), + 'enabled' => $twoFactor->enabled($request->user()), + 'recovery_codes_remaining' => count($twoFactor->recoveryCodes($request->user())), + ], + ]); + } + + public function begin(Request $request, TwoFactorAuthentication $twoFactor): JsonResponse + { + $validated = $request->validate(['password' => ['required', 'string']]); + $user = $request->user(); + + if (! Hash::check($validated['password'], $user->getAuthPassword())) { + throw ValidationException::withMessages(['password' => 'The password is incorrect.']); + } + + if ($twoFactor->enabled($user)) { + abort(409, 'Two-factor authentication is already enabled.'); + } + + $setup = $twoFactor->begin($user); + + return response()->json(['status' => 'success', 'data' => $setup])->header('Cache-Control', 'no-store'); + } + + public function confirm(Request $request, TwoFactorAuthentication $twoFactor): JsonResponse + { + $validated = $request->validate(['code' => ['required', 'string', 'size:6', 'regex:/^\d{6}$/']]); + $codes = $twoFactor->confirm($request->user(), $validated['code']); + + if ($codes === null) { + throw ValidationException::withMessages(['code' => 'The authenticator code is invalid.']); + } + + return response()->json([ + 'status' => 'success', + 'data' => ['method' => 'totp', 'recovery_codes' => $codes], + ])->header('Cache-Control', 'no-store'); + } + + public function disable(Request $request, TwoFactorAuthentication $twoFactor): JsonResponse + { + $validated = $request->validate(['code' => ['required', 'string', 'min:6', 'max:20']]); + $user = $request->user(); + + if (! $twoFactor->verify($user, $validated['code'])) { + throw ValidationException::withMessages(['code' => 'The authenticator or recovery code is invalid.']); + } + + $twoFactor->disable($user); + + return response()->json(['status' => 'success', 'data' => ['method' => 'none']]); + } + + public function regenerateRecoveryCodes(Request $request, TwoFactorAuthentication $twoFactor): JsonResponse + { + $validated = $request->validate(['code' => ['required', 'string', 'min:6', 'max:20']]); + $user = $request->user(); + + if (! $twoFactor->verify($user, $validated['code'])) { + throw ValidationException::withMessages(['code' => 'The authenticator or recovery code is invalid.']); + } + + return response()->json([ + 'status' => 'success', + 'data' => ['recovery_codes' => $twoFactor->rotateRecoveryCodes($user)], + ])->header('Cache-Control', 'no-store'); + } +} diff --git a/src/Http/Middleware/RequireTwoFactorAuthentication.php b/src/Http/Middleware/RequireTwoFactorAuthentication.php new file mode 100644 index 0000000..879d958 --- /dev/null +++ b/src/Http/Middleware/RequireTwoFactorAuthentication.php @@ -0,0 +1,36 @@ +user(); + + if ($user === null) { + abort(401); + } + + abort_unless(app(TwoFactorAuthentication::class)->enabled($user), 403, 'Two-factor authentication must be enabled.'); + + $token = method_exists($user, 'currentAccessToken') ? $user->currentAccessToken() : null; + + if ($token !== null) { + $abilities = $token->abilities ?? []; + abort_unless(is_array($abilities) && in_array('two-factor:verified', $abilities, true), 403, 'MFA verification is required.'); + } else { + abort_unless($request->hasSession(), 403, 'MFA verification is required.'); + abort_unless($request->session()->get('authkit.two_factor_verified') === true, 403, 'MFA verification is required.'); + } + + return $next($request); + } +} diff --git a/src/Providers/AuthKitServiceProvider.php b/src/Providers/AuthKitServiceProvider.php index fe6dc50..64686c1 100644 --- a/src/Providers/AuthKitServiceProvider.php +++ b/src/Providers/AuthKitServiceProvider.php @@ -26,6 +26,10 @@ public function configurePackage(Package $package): void ->publish( paths: ['database/migrations/passkeys' => database_path(path: 'migrations')], tag: 'laranail::authkit-passkey-migrations', + ) + ->publish( + paths: ['database/migrations/two-factor' => database_path(path: 'migrations')], + tag: 'laranail::authkit-two-factor-migrations', ); } @@ -41,6 +45,7 @@ public function packageRegistered(): void secretKey: (string) config(key: 'laranail.authkit.turnstile.secret_key'), ); }); + $this->app->singleton(Services\TwoFactorAuthentication::class); $this->app->bind(abstract: Contracts\AttemptEmailPasswordLoginInterface::class, concrete: Actions\AttemptEmailPasswordLogin::class); $this->app->bind(abstract: Contracts\CheckEmailExistsInterface::class, concrete: Actions\CheckEmailExists::class); @@ -63,6 +68,11 @@ public function packageBooted(): void // laranail.authkit.api.enabled to false. $this->loadRoutesFrom($this->packagePath('routes/api.php')); + $this->app->make('router')->aliasMiddleware( + 'two-factor', + \Simtabi\Laranail\AuthKit\Http\Middleware\RequireTwoFactorAuthentication::class, + ); + $this->configureFortify(); } diff --git a/src/Support/AuthKit.php b/src/Support/AuthKit.php index f245229..71cdeb8 100644 --- a/src/Support/AuthKit.php +++ b/src/Support/AuthKit.php @@ -55,6 +55,11 @@ public static function apiEnabled(): bool return (bool) config(key: 'laranail.authkit.api.enabled', default: true); } + public static function twoFactorEnabled(): bool + { + return (bool) config(key: 'laranail.authkit.two_factor.enabled', default: false); + } + public static function apiPrefix(): string { return (string) config(key: 'laranail.authkit.api.prefix', default: 'api/auth'); From 9680b2e72e73cd8832ad7d1b962e1d4c40e5c6c4 Mon Sep 17 00:00:00 2001 From: Njogu Amos <29255728+njoguamos@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:42:30 +0300 Subject: [PATCH 3/3] docs: document TOTP authentication Describe the opt-in API flow, migration setup, enrollment, recovery codes, and security behavior. --- CHANGELOG.md | 8 +++++-- README.md | 3 ++- UPGRADING.md | 2 +- docs/security.md | 2 +- docs/two-factor-authentication.md | 38 +++++++++++++++++++++++++++++++ 5 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 docs/two-factor-authentication.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 66ad9f5..43f0620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **Opt-in TOTP two-factor authentication for API clients.** Login can return a short-lived MFA challenge; clients can verify TOTP or recovery codes, manage enrollment and recovery codes, and protect routes with the `two-factor` middleware. Verified API tokens receive a `two-factor:verified` ability. The `two_factor_method` field defaults to `none` and is ready for future methods. + ### Changed - **Breaking. Social login moved to `laranail/authkit-social-login`.** Fifteen classes, the `socials` migration, its factory and the `social` config block left this package. See [UPGRADING.md](UPGRADING.md); `rector-migrate-social.php` codemods the class renames. - The config key moved from `laranail.authkit.social.*` to `laranail.authkit-social-login.*`, in the new - package's own published file. **Provider env variable names are unchanged** — `AUTHKIT_GOOGLE_CLIENT_ID` + The config key moved from `laranail.authkit.social.*` to `authkit-social-login.*`, in the new + package's own `config/authkit-social-login.php` file. **Provider env variable names are unchanged** — `AUTHKIT_GOOGLE_CLIENT_ID` and the rest keep working, because renaming them would break deployed `.env` files with credentials silently resolving to null. diff --git a/README.md b/README.md index ac551b5..a4ac97b 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Full documentation: group(function () { + // Sensitive routes +}); +``` + +## API flow + +When the core setting is enabled and the account method is `totp`, `POST /api/auth/login` returns HTTP 202 with `status: mfa_required`, a short-lived `challenge_token`, and `expires_in`. Submit that token with the user's TOTP or recovery code to `POST /api/auth/two-factor/challenge`; only a successful challenge returns a Sanctum bearer token. Failed attempts are throttled, and challenges expire after five minutes by default. + +Authenticated clients can inspect `GET /api/auth/user/two-factor`, start enrollment with `POST /api/auth/user/two-factor` (send the current `password`), confirm with `POST /api/auth/user/two-factor/confirm`, disable with `POST /api/auth/user/two-factor/disable`, and replace recovery codes with `POST /api/auth/user/two-factor/recovery-codes`. Enrollment confirmation returns the recovery codes once; disable and recovery-code replacement require a current TOTP or unused recovery code. + +API tokens issued after the challenge carry the `two-factor:verified` ability. The `two-factor` middleware checks this exact ability (not wildcard ability matching) and confirms that TOTP remains enabled. Accounts with method `none` retain the existing password-only login response. + +TOTP verification delegates to Fortify's provider, which applies its configured verification window and replay cache. Keep the application encryption key secure and stable because it protects stored TOTP and recovery secrets.