Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Full documentation: <https://opensource.simtabi.com/documentation/laranail/authk
- [Password reset](docs/password-reset.md) · [Password updates](docs/password-updates.md)
- [Profile management](docs/profile-management.md) · [Email verification](docs/email-verification.md)
- [Browser sessions](docs/browser-sessions.md) · [API routes](docs/api-routes.md) · [API tokens](docs/api-tokens.md)
- [Two-factor authentication](docs/two-factor-authentication.md)
- [Social login](https://github.com/laranail/authkit-social-login/blob/main/docs/social-login.md) · [Passkeys](docs/passkeys.md) · [API tokens](docs/api-tokens.md)

### Project
Expand Down Expand Up @@ -103,7 +104,7 @@ Remove `passkeys` from `laranail.authkit.fortify.features` to disable Fortify's

### Security defaults

- Two-factor authentication is not enabled by default. MFA is still work in progress.
- TOTP two-factor authentication is opt-in. See the [TOTP setup and API flow](docs/two-factor-authentication.md).
- Before production, configure HTTPS, secure session cookies, a working mail transport, and Turnstile keys when bot protection is enabled.

## Passkeys
Expand Down
2 changes: 1 addition & 1 deletion UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ the `require`:

### 2. The config key moved

`laranail.authkit.social.*` → `laranail.authkit-social-login.*`, in its own published file.
`laranail.authkit.social.*` → `authkit-social-login.*`, in `config/authkit-social-login.php`.

```bash
php artisan vendor:publish --tag=laranail::authkit-social-login-config
Expand Down
6 changes: 6 additions & 0 deletions config/laranail/authkit.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

return new class extends Migration
{
public function up(): void
{
Schema::table(config('laranail.authkit.two_factor.table', 'users'), function (Blueprint $table): void {
$table->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',
]);
});
}
};
2 changes: 1 addition & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
38 changes: 38 additions & 0 deletions docs/two-factor-authentication.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
9 changes: 8 additions & 1 deletion src/Actions/IssueTokenForUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
);

Expand Down
1 change: 1 addition & 0 deletions src/Contracts/IssueTokenForUserInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ public function execute(
?string $name = null,
?array $abilities = null,
?DateTimeInterface $expiresAt = null,
bool $twoFactorVerified = false,
): TokenResult;
}
11 changes: 11 additions & 0 deletions src/Enums/TwoFactorMethod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\AuthKit\Enums;

enum TwoFactorMethod: string
{
case NONE = 'none';
case TOTP = 'totp';
}
17 changes: 16 additions & 1 deletion src/Http/Controllers/Api/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand Down
57 changes: 57 additions & 0 deletions src/Http/Controllers/Api/TwoFactorChallengeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\AuthKit\Http\Controllers\Api;

use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
use Illuminate\Cache\LockTimeoutException;
use Illuminate\Validation\ValidationException;
use Simtabi\Laranail\AuthKit\Support\UserModelResolver;
use Simtabi\Laranail\AuthKit\Services\TwoFactorAuthentication;
use Simtabi\Laranail\AuthKit\Contracts\IssueTokenForUserInterface;

final class TwoFactorChallengeController
{
public function __invoke(
Request $request,
TwoFactorAuthentication $twoFactor,
IssueTokenForUserInterface $issuer,
): JsonResponse {
$validated = $request->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.']);
}
}
}
91 changes: 91 additions & 0 deletions src/Http/Controllers/Api/TwoFactorManagementController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\AuthKit\Http\Controllers\Api;

use BackedEnum;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
use Simtabi\Laranail\AuthKit\Services\TwoFactorAuthentication;

final class TwoFactorManagementController
{
public function show(Request $request, TwoFactorAuthentication $twoFactor): JsonResponse
{
return response()->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');
}
}
Loading
Loading