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:
90
api/app/Http/Controllers/Api/V1/Auth/MfaVerifyController.php
Normal file
90
api/app/Http/Controllers/Api/V1/Auth/MfaVerifyController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user