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: [ + '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/docs/security.md b/docs/security.md index 3c7e755..c6f7eb4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -14,7 +14,7 @@ TURNSTILE_SECRET_KEY= The configured challenge input is `cf-turnstile-response`. The rule is applied during Auth Kit user creation; attach `ValidateTurnstile` or `TurnstileRule` deliberately to other application-owned endpoints. Protect only routes where a challenge is appropriate, verify the callback server-side, and never treat the browser response as sufficient on its own. -Two-factor authentication is not enabled by default; MFA remains work in progress. Review the [passkey origin requirements](passkeys.md#relying-party-and-browser-client) before deploying passkeys. See [configuration](configuration.md) for feature and guard settings, and [testing](testing.md) for validation guidance. +TOTP two-factor authentication is opt-in. It encrypts TOTP and recovery secrets, rate-limits challenges, consumes recovery codes once, and relies on Fortify's replay-aware provider. Review the [TOTP guide](two-factor-authentication.md) before enabling it. Also review the [passkey origin requirements](passkeys.md#relying-party-and-browser-client) before deploying passkeys. See [configuration](configuration.md) for feature and guard settings, and [testing](testing.md) for validation guidance. --- diff --git a/docs/two-factor-authentication.md b/docs/two-factor-authentication.md new file mode 100644 index 0000000..5883dcf --- /dev/null +++ b/docs/two-factor-authentication.md @@ -0,0 +1,38 @@ +# Two-factor authentication + +Auth Kit supports TOTP through standard `otpauth` enrollment URIs. Authenticator apps such as 1Password and Google Authenticator can scan the setup QR code or accept the displayed secret manually. + +## Enable + +Publish the migration and run it: + +```sh +php artisan vendor:publish --tag=laranail::authkit-two-factor-migrations +php artisan migrate +``` + +For an API-only Auth Kit installation, set `laranail.authkit.two_factor.enabled` to `true` in the published Auth Kit config. The preset enables this core setting when `Features::twoFactorAuthentication()` is present in the preset feature list. The installer accepts `--two-factor-authentication`. + +The migration adds `two_factor_method` (`none` or `totp`, default `none`), the encrypted TOTP secret and recovery-code columns, and a confirmation timestamp. Configure `laranail.authkit.two_factor.table` if the authentication model uses a table other than `users` before running the migration. + +## Browser flow + +The preset provides account setup at `/auth/user/two-factor` and gates password sign-in with an authenticator or recovery-code challenge for accounts whose method is `totp`. Setup secrets remain inactive until a valid code confirms enrollment. Recovery codes are shown once and each can be used once. + +Apply the `two-factor` middleware to routes that must require an enabled factor and successful verification in the current browser session: + +```php +Route::middleware(['auth', 'two-factor'])->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. 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/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 @@ +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/Services/TwoFactorAuthentication.php b/src/Services/TwoFactorAuthentication.php new file mode 100644 index 0000000..f3d06fa --- /dev/null +++ b/src/Services/TwoFactorAuthentication.php @@ -0,0 +1,196 @@ +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; + } + } +} 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');