feat: enterprise MFA with TOTP, email codes, backup codes, and trusted devices

Three verification methods (TOTP authenticator, email code, backup codes),
trusted device management with 30-day expiry, role-based enforcement for
super_admin and org_admin, admin reset capability, and full test coverage
(46 tests). Modifies login flow to support MFA challenge/response with
temporary session tokens stored in cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 20:45:55 +02:00
parent df68aa8aef
commit 948687f27e
32 changed files with 2563 additions and 5 deletions

View File

@@ -8,7 +8,9 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AdminUpdateUserRequest;
use App\Http\Resources\Admin\AdminUserResource;
use App\Models\User;
use App\Services\MfaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
final class AdminUserController extends Controller
@@ -87,4 +89,15 @@ final class AdminUserController extends Controller
return response()->json(null, 204);
}
public function resetMfa(Request $request, User $user, MfaService $mfaService): JsonResponse
{
if (! $user->mfa_enabled) {
return $this->error('MFA is niet ingeschakeld voor deze gebruiker.', 422);
}
$mfaService->adminReset($request->user(), $user);
return $this->success(null, 'MFA reset for user');
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Auth\MfaConfirmRequest;
use App\Http\Requests\Api\V1\Auth\MfaDisableRequest;
use App\Enums\MfaMethod;
use App\Services\MfaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class MfaSetupController extends Controller
{
public function __construct(
private MfaService $mfaService,
) {}
public function setupTotp(Request $request): JsonResponse
{
$user = $request->user();
$data = $this->mfaService->setupTotp($user);
return $this->success($data, 'TOTP setup started');
}
public function confirmTotp(MfaConfirmRequest $request): JsonResponse
{
$user = $request->user();
try {
$backupCodes = $this->mfaService->confirmTotp($user, $request->validated('code'));
} catch (\DomainException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->success([
'mfa_enabled' => true,
'method' => MfaMethod::TOTP->value,
'backup_codes' => $backupCodes,
], 'TOTP enabled');
}
public function setupEmail(Request $request): JsonResponse
{
$user = $request->user();
try {
$this->mfaService->setupEmail($user);
} catch (\DomainException $e) {
return $this->error($e->getMessage(), 429);
}
return $this->success(null, 'Verification code sent');
}
public function confirmEmail(MfaConfirmRequest $request): JsonResponse
{
$user = $request->user();
try {
$backupCodes = $this->mfaService->confirmEmail($user, $request->validated('code'));
} catch (\DomainException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->success([
'mfa_enabled' => true,
'method' => MfaMethod::EMAIL->value,
'backup_codes' => $backupCodes,
], 'Email MFA enabled');
}
public function disable(MfaDisableRequest $request): JsonResponse
{
$user = $request->user();
if (! $user->mfa_enabled) {
return $this->error('MFA is niet ingeschakeld.', 422);
}
// Verify the code before disabling
$method = MfaMethod::from($request->validated('method'));
$code = $request->validated('code');
try {
if ($method === MfaMethod::TOTP) {
$secret = decrypt($user->mfa_secret);
$google2fa = app(\PragmaRX\Google2FA\Google2FA::class);
if (! $google2fa->verifyKey($secret, $code, 1)) {
throw new \DomainException('Ongeldige verificatiecode.');
}
} elseif ($method === MfaMethod::BACKUP_CODE) {
// Backup code verification is handled by verifying against stored hashes
// We need to check manually here since verifyBackupCode is private
$this->verifyBackupCodeForDisable($user, $code);
}
} catch (\DomainException $e) {
return $this->error($e->getMessage(), 422);
}
$this->mfaService->disable($user);
return $this->success(null, 'MFA disabled');
}
public function regenerateBackupCodes(MfaConfirmRequest $request): JsonResponse
{
$user = $request->user();
// Verify TOTP code before regenerating
try {
if ($user->mfa_method === MfaMethod::TOTP->value) {
$secret = decrypt($user->mfa_secret);
$google2fa = app(\PragmaRX\Google2FA\Google2FA::class);
if (! $google2fa->verifyKey($secret, $request->validated('code'), 1)) {
throw new \DomainException('Ongeldige verificatiecode.');
}
}
$codes = $this->mfaService->regenerateBackupCodes($user);
} catch (\DomainException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->success([
'backup_codes' => $codes,
], 'Backup codes regenerated');
}
public function status(Request $request): JsonResponse
{
$user = $request->user();
return $this->success([
'mfa_enabled' => $user->mfa_enabled,
'method' => $user->mfa_method,
'confirmed_at' => $user->mfa_confirmed_at?->toIso8601String(),
'backup_codes_remaining' => $this->mfaService->getRemainingBackupCodeCount($user),
'is_required' => $this->mfaService->isMfaRequired($user),
]);
}
private function verifyBackupCodeForDisable(\App\Models\User $user, string $code): void
{
$normalizedCode = strtoupper(str_replace([' ', '-'], '', $code));
$backupCodes = \App\Models\MfaBackupCode::where('user_id', $user->id)
->where('used', false)
->get();
foreach ($backupCodes as $backupCode) {
if (\Illuminate\Support\Facades\Hash::check($code, $backupCode->code_hash) ||
\Illuminate\Support\Facades\Hash::check($normalizedCode, $backupCode->code_hash)) {
$backupCode->update([
'used' => true,
'used_at' => now(),
]);
return;
}
}
throw new \DomainException('Ongeldige backup code.');
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Auth;
use App\Http\Controllers\Api\V1\Traits\SetAuthCookie;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Auth\MfaEmailSendRequest;
use App\Http\Requests\Api\V1\Auth\MfaVerifyRequest;
use App\Http\Resources\Api\V1\MeResource;
use App\Enums\MfaMethod;
use App\Models\User;
use App\Services\MfaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
final class MfaVerifyController extends Controller
{
use SetAuthCookie;
public function __construct(
private MfaService $mfaService,
) {}
public function verify(MfaVerifyRequest $request): JsonResponse
{
$validated = $request->validated();
$method = MfaMethod::from($validated['method']);
try {
$user = $this->mfaService->verifyMfaCode(
sessionToken: $validated['mfa_session_token'],
code: $validated['code'],
method: $method,
ipAddress: $request->ip(),
);
} catch (\DomainException $e) {
return $this->error($e->getMessage(), 422);
}
// Trust device if requested
if (! empty($validated['trust_device']) && ! empty($validated['device_fingerprint'])) {
$this->mfaService->trustDevice(
user: $user,
fingerprint: $validated['device_fingerprint'],
ipAddress: $request->ip(),
deviceName: $validated['device_name'] ?? null,
);
}
// Issue auth token (same as login flow)
$user->load([
'organisations',
'roles',
'permissions',
'persons' => fn ($q) => $q->with(['event:id,name,slug,start_date,end_date,organisation_id', 'event.organisation:id,name']),
]);
$token = $user->createToken('auth-token')->plainTextToken;
$cookieName = $this->resolveCookieName($request);
return $this->success([
'user' => new MeResource($user),
], 'MFA verification successful')
->withCookie($this->makeAuthCookie($cookieName, $token));
}
public function sendEmailCode(MfaEmailSendRequest $request): JsonResponse
{
$sessionToken = $request->validated('mfa_session_token');
$cacheKey = 'mfa_session:' . $sessionToken;
$session = Cache::get($cacheKey);
if (! $session) {
return $this->error('MFA-sessie verlopen. Log opnieuw in.', 422);
}
$user = User::findOrFail($session['user_id']);
try {
$this->mfaService->sendEmailCode($user);
} catch (\DomainException $e) {
return $this->error($e->getMessage(), 429);
}
return $this->success(null, 'Verification code sent');
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Auth;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\V1\TrustedDeviceResource;
use App\Services\MfaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class TrustedDeviceController extends Controller
{
public function __construct(
private MfaService $mfaService,
) {}
public function index(Request $request): JsonResponse
{
$devices = $this->mfaService->getTrustedDevices($request->user());
return $this->success(TrustedDeviceResource::collection($devices));
}
public function destroy(Request $request, string $device): JsonResponse
{
$this->mfaService->revokeDevice($request->user(), $device);
return response()->json(null, 204);
}
public function destroyAll(Request $request): JsonResponse
{
$this->mfaService->revokeAllDevices($request->user());
return response()->json(null, 204);
}
}

View File

@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\MfaMethod;
use App\Http\Controllers\Api\V1\Traits\SetAuthCookie;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\LoginRequest;
use App\Http\Resources\Api\V1\MeResource;
use App\Models\User;
use App\Services\MfaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
@@ -16,9 +19,13 @@ final class LoginController extends Controller
{
use SetAuthCookie;
public function __construct(
private MfaService $mfaService,
) {}
public function __invoke(LoginRequest $request): JsonResponse
{
if (!Auth::attempt($request->only('email', 'password'))) {
if (! Auth::attempt($request->only('email', 'password'))) {
Log::warning('Failed login attempt', [
'email' => $request->validated('email'),
'ip' => $request->ip(),
@@ -28,7 +35,55 @@ final class LoginController extends Controller
return $this->unauthorized('Invalid credentials');
}
$user = Auth::user()->load([
$user = Auth::user();
// MFA enabled and confirmed — check trusted device or require MFA
if ($user->mfa_enabled && $user->mfa_confirmed_at) {
$fingerprint = $request->header('X-Device-Fingerprint');
if ($fingerprint && $this->mfaService->isDeviceTrusted($user, $fingerprint)) {
return $this->issueToken($user, $request);
}
// MFA required — return session token instead of auth token
Auth::guard('web')->logout();
$mfaSession = $this->mfaService->createMfaSession($user, $request->ip());
// Auto-send email code if email is the preferred method
if ($user->mfa_method === MfaMethod::EMAIL->value) {
try {
$this->mfaService->sendEmailCode($user);
} catch (\DomainException) {
// Rate limited — code was already sent recently
}
}
return response()->json([
'success' => true,
'mfa_required' => true,
...$mfaSession,
]);
}
// MFA required by policy but not yet set up — issue token with flag
if ($this->mfaService->isMfaRequired($user) && ! $user->mfa_enabled) {
$response = $this->issueToken($user, $request);
$data = $response->getData(true);
$data['mfa_setup_required'] = true;
$cookieName = $this->resolveCookieName($request);
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json($data)
->withCookie($this->makeAuthCookie($cookieName, $token));
}
// No MFA — issue token as normal
return $this->issueToken($user, $request);
}
private function issueToken(User $user, LoginRequest $request): JsonResponse
{
$user->load([
'organisations',
'roles',
'permissions',