diff --git a/api/app/Enums/EmailTemplateType.php b/api/app/Enums/EmailTemplateType.php index c88dda06..e41794be 100644 --- a/api/app/Enums/EmailTemplateType.php +++ b/api/app/Enums/EmailTemplateType.php @@ -12,6 +12,7 @@ enum EmailTemplateType: string case REGISTRATION_APPROVED = 'registration_approved'; case REGISTRATION_REJECTED = 'registration_rejected'; case SHIFT_ASSIGNMENT = 'shift_assignment'; + case MFA_CODE = 'mfa_code'; public function label(): string { @@ -22,6 +23,7 @@ enum EmailTemplateType: string self::REGISTRATION_APPROVED => 'Registratie goedgekeurd', self::REGISTRATION_REJECTED => 'Registratie afgewezen', self::SHIFT_ASSIGNMENT => 'Diensttoewijzing', + self::MFA_CODE => 'MFA verificatiecode', }; } @@ -70,6 +72,12 @@ enum EmailTemplateType: string 'body_text' => 'Je bent ingedeeld voor de volgende dienst: {shift_title} op {shift_date} van {shift_start} tot {shift_end} bij {section_name}. Log in op het portaal voor meer details.', 'button_text' => 'Bekijk je diensten', ], + self::MFA_CODE => [ + 'subject' => 'Je verificatiecode voor Crewli', + 'heading' => 'Verificatiecode', + 'body_text' => 'Je verificatiecode is: {code}. Deze code is {expiry_minutes} minuten geldig. Als je dit niet hebt aangevraagd, wijzig dan direct je wachtwoord.', + 'button_text' => null, + ], }; } } diff --git a/api/app/Enums/MfaMethod.php b/api/app/Enums/MfaMethod.php new file mode 100644 index 00000000..083a64b9 --- /dev/null +++ b/api/app/Enums/MfaMethod.php @@ -0,0 +1,21 @@ + 'Authenticator app', + self::EMAIL => 'E-mailcode', + self::BACKUP_CODE => 'Backup code', + }; + } +} diff --git a/api/app/Http/Controllers/Api/V1/Admin/AdminUserController.php b/api/app/Http/Controllers/Api/V1/Admin/AdminUserController.php index 6349fb34..843f7072 100644 --- a/api/app/Http/Controllers/Api/V1/Admin/AdminUserController.php +++ b/api/app/Http/Controllers/Api/V1/Admin/AdminUserController.php @@ -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'); + } } diff --git a/api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php b/api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php new file mode 100644 index 00000000..6fe0b3f1 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php @@ -0,0 +1,168 @@ +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.'); + } +} diff --git a/api/app/Http/Controllers/Api/V1/Auth/MfaVerifyController.php b/api/app/Http/Controllers/Api/V1/Auth/MfaVerifyController.php new file mode 100644 index 00000000..d7d8ce54 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Auth/MfaVerifyController.php @@ -0,0 +1,90 @@ +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'); + } +} diff --git a/api/app/Http/Controllers/Api/V1/Auth/TrustedDeviceController.php b/api/app/Http/Controllers/Api/V1/Auth/TrustedDeviceController.php new file mode 100644 index 00000000..c9301641 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Auth/TrustedDeviceController.php @@ -0,0 +1,39 @@ +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); + } +} diff --git a/api/app/Http/Controllers/Api/V1/LoginController.php b/api/app/Http/Controllers/Api/V1/LoginController.php index 0b50a7c5..ff62b2fb 100644 --- a/api/app/Http/Controllers/Api/V1/LoginController.php +++ b/api/app/Http/Controllers/Api/V1/LoginController.php @@ -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', diff --git a/api/app/Http/Requests/Api/V1/Auth/MfaConfirmRequest.php b/api/app/Http/Requests/Api/V1/Auth/MfaConfirmRequest.php new file mode 100644 index 00000000..d0b7390d --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Auth/MfaConfirmRequest.php @@ -0,0 +1,22 @@ + ['required', 'string'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Auth/MfaDisableRequest.php b/api/app/Http/Requests/Api/V1/Auth/MfaDisableRequest.php new file mode 100644 index 00000000..601b5c0a --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Auth/MfaDisableRequest.php @@ -0,0 +1,28 @@ + ['required', 'string'], + 'method' => ['required', 'string', Rule::in([ + MfaMethod::TOTP->value, + MfaMethod::BACKUP_CODE->value, + ])], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Auth/MfaEmailSendRequest.php b/api/app/Http/Requests/Api/V1/Auth/MfaEmailSendRequest.php new file mode 100644 index 00000000..d845516e --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Auth/MfaEmailSendRequest.php @@ -0,0 +1,22 @@ + ['required', 'string'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Auth/MfaVerifyRequest.php b/api/app/Http/Requests/Api/V1/Auth/MfaVerifyRequest.php new file mode 100644 index 00000000..988fc19e --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Auth/MfaVerifyRequest.php @@ -0,0 +1,33 @@ + ['required', 'string'], + 'code' => ['required', 'string'], + 'method' => ['required', 'string', Rule::in([ + MfaMethod::TOTP->value, + MfaMethod::EMAIL->value, + MfaMethod::BACKUP_CODE->value, + ])], + 'trust_device' => ['sometimes', 'boolean'], + 'device_fingerprint' => ['required_if:trust_device,true', 'nullable', 'string'], + 'device_name' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/MeResource.php b/api/app/Http/Resources/Api/V1/MeResource.php index cde725bc..fe4559a6 100644 --- a/api/app/Http/Resources/Api/V1/MeResource.php +++ b/api/app/Http/Resources/Api/V1/MeResource.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Resources\Api\V1; use App\Models\Person; +use App\Services\MfaService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -45,6 +46,12 @@ final class MeResource extends JsonResource 'end_date' => $person->event->end_date?->toDateString(), ]) ), + 'mfa' => [ + 'enabled' => $this->mfa_enabled, + 'method' => $this->mfa_method, + 'confirmed_at' => $this->mfa_confirmed_at?->toIso8601String(), + 'setup_required' => app(MfaService::class)->isMfaRequired($this->resource) && ! $this->mfa_enabled, + ], ]; } } diff --git a/api/app/Http/Resources/Api/V1/TrustedDeviceResource.php b/api/app/Http/Resources/Api/V1/TrustedDeviceResource.php new file mode 100644 index 00000000..b83935d1 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/TrustedDeviceResource.php @@ -0,0 +1,23 @@ + $this->id, + 'device_name' => $this->device_name, + 'ip_address' => $this->ip_address, + 'trusted_until' => $this->trusted_until->toIso8601String(), + 'last_used_at' => $this->last_used_at?->toIso8601String(), + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/api/app/Models/MfaBackupCode.php b/api/app/Models/MfaBackupCode.php new file mode 100644 index 00000000..4707973e --- /dev/null +++ b/api/app/Models/MfaBackupCode.php @@ -0,0 +1,37 @@ + 'boolean', + 'used_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function scopeUnused(Builder $query): Builder + { + return $query->where('used', false); + } +} diff --git a/api/app/Models/MfaEmailCode.php b/api/app/Models/MfaEmailCode.php new file mode 100644 index 00000000..970061d6 --- /dev/null +++ b/api/app/Models/MfaEmailCode.php @@ -0,0 +1,38 @@ + 'datetime', + 'used' => 'boolean', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function scopeValid(Builder $query): Builder + { + return $query->where('used', false) + ->where('expires_at', '>', now()); + } +} diff --git a/api/app/Models/TrustedDevice.php b/api/app/Models/TrustedDevice.php new file mode 100644 index 00000000..619e0292 --- /dev/null +++ b/api/app/Models/TrustedDevice.php @@ -0,0 +1,42 @@ + 'datetime', + 'last_used_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('trusted_until', '>', now()); + } +} diff --git a/api/app/Models/User.php b/api/app/Models/User.php index 6713cb98..9b2abe93 100644 --- a/api/app/Models/User.php +++ b/api/app/Models/User.php @@ -32,6 +32,11 @@ final class User extends Authenticatable 'timezone', 'locale', 'avatar', + 'mfa_enabled', + 'mfa_method', + 'mfa_secret', + 'mfa_confirmed_at', + 'mfa_enforced', ]; public function getFullNameAttribute(): string @@ -47,6 +52,7 @@ final class User extends Authenticatable protected $hidden = [ 'password', 'remember_token', + 'mfa_secret', ]; protected function casts(): array @@ -55,6 +61,9 @@ final class User extends Authenticatable 'date_of_birth' => 'date', 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'mfa_enabled' => 'boolean', + 'mfa_confirmed_at' => 'datetime', + 'mfa_enforced' => 'boolean', ]; } @@ -92,6 +101,21 @@ final class User extends Authenticatable return $this->hasMany(UserOrganisationTag::class); } + public function mfaBackupCodes(): HasMany + { + return $this->hasMany(MfaBackupCode::class); + } + + public function mfaEmailCodes(): HasMany + { + return $this->hasMany(MfaEmailCode::class); + } + + public function trustedDevices(): HasMany + { + return $this->hasMany(TrustedDevice::class); + } + public function tagsForOrganisation(string $organisationId): HasMany { return $this->organisationTags()->where('organisation_id', $organisationId); diff --git a/api/app/Services/MfaService.php b/api/app/Services/MfaService.php new file mode 100644 index 00000000..038652b8 --- /dev/null +++ b/api/app/Services/MfaService.php @@ -0,0 +1,507 @@ +google2fa->generateSecretKey(32); + + $user->update([ + 'mfa_secret' => encrypt($secret), + 'mfa_method' => MfaMethod::TOTP->value, + 'mfa_confirmed_at' => null, + ]); + + $qrCodeUrl = $this->google2fa->getQRCodeUrl( + company: 'Crewli', + holder: $user->email, + secret: $secret, + ); + + return [ + 'secret' => $secret, + 'qr_code_url' => $qrCodeUrl, + 'provisioning_uri' => $qrCodeUrl, + ]; + } + + /** + * Confirm TOTP setup with a valid code from the authenticator app. + * Generates backup codes and activates MFA. + */ + public function confirmTotp(User $user, string $code): array + { + $secret = decrypt($user->mfa_secret); + + if (! $this->google2fa->verifyKey($secret, $code)) { + throw new \DomainException('Ongeldige verificatiecode.'); + } + + $user->update([ + 'mfa_enabled' => true, + 'mfa_confirmed_at' => now(), + ]); + + $backupCodes = $this->generateBackupCodes($user); + + activity('mfa') + ->causedBy($user) + ->performedOn($user) + ->log('mfa.totp.enabled'); + + return $backupCodes; + } + + // ─── EMAIL CODE SETUP ─── + + /** + * Setup email as MFA method (simpler than TOTP — no QR code). + * Sends a verification code to confirm. + */ + public function setupEmail(User $user): void + { + $user->update([ + 'mfa_method' => MfaMethod::EMAIL->value, + 'mfa_secret' => null, + 'mfa_confirmed_at' => null, + ]); + + $this->sendEmailCode($user); + } + + /** + * Confirm email MFA setup with the code received via email. + */ + public function confirmEmail(User $user, string $code): array + { + $this->verifyEmailCode($user, $code); + + $user->update([ + 'mfa_enabled' => true, + 'mfa_confirmed_at' => now(), + ]); + + $backupCodes = $this->generateBackupCodes($user); + + activity('mfa') + ->causedBy($user) + ->performedOn($user) + ->log('mfa.email.enabled'); + + return $backupCodes; + } + + // ─── EMAIL CODE SENDING ─── + + /** + * Send a 6-digit code via email for MFA verification. + */ + public function sendEmailCode(User $user): void + { + $rateLimitKey = 'mfa_email_rate:' . $user->id; + if (Cache::has($rateLimitKey)) { + throw new \DomainException('Wacht even voordat je een nieuwe code aanvraagt.'); + } + + // Invalidate previous unused codes + MfaEmailCode::where('user_id', $user->id) + ->where('used', false) + ->update(['used' => true]); + + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + + MfaEmailCode::create([ + 'user_id' => $user->id, + 'code' => $code, + 'expires_at' => now()->addMinutes(self::EMAIL_CODE_EXPIRY_MINUTES), + ]); + + $this->emailService->send( + type: EmailTemplateType::MFA_CODE, + recipientEmail: $user->email, + recipientName: $user->full_name ?: $user->email, + variables: [ + 'code' => $code, + 'expiry_minutes' => (string) self::EMAIL_CODE_EXPIRY_MINUTES, + ], + userId: $user->id, + ); + + Cache::put($rateLimitKey, true, self::EMAIL_CODE_RATE_LIMIT_SECONDS); + } + + /** + * Verify an email code. + */ + private function verifyEmailCode(User $user, string $code): void + { + $record = MfaEmailCode::where('user_id', $user->id) + ->where('code', $code) + ->where('used', false) + ->where('expires_at', '>', now()) + ->first(); + + if (! $record) { + throw new \DomainException('Ongeldige of verlopen code.'); + } + + $record->update(['used' => true]); + } + + // ─── VERIFICATION (LOGIN FLOW) ─── + + /** + * Create a temporary MFA session after successful password auth. + * Returns a session token that the frontend uses to submit the MFA code. + */ + public function createMfaSession(User $user, string $ipAddress): array + { + $sessionToken = Str::random(64); + + Cache::put( + self::MFA_SESSION_PREFIX . $sessionToken, + [ + 'user_id' => $user->id, + 'ip_address' => $ipAddress, + 'created_at' => now()->toISOString(), + ], + now()->addMinutes(self::MFA_SESSION_TTL_MINUTES), + ); + + $methods = []; + if ($user->mfa_method === MfaMethod::TOTP->value) { + $methods[] = MfaMethod::TOTP->value; + $methods[] = MfaMethod::EMAIL->value; + } else { + $methods[] = MfaMethod::EMAIL->value; + } + $methods[] = MfaMethod::BACKUP_CODE->value; + + return [ + 'mfa_session_token' => $sessionToken, + 'methods' => $methods, + 'preferred_method' => $user->mfa_method, + 'expires_in' => self::MFA_SESSION_TTL_MINUTES * 60, + ]; + } + + /** + * Verify an MFA code during login. + * Returns the user if valid. + */ + public function verifyMfaCode( + string $sessionToken, + string $code, + MfaMethod $method, + string $ipAddress, + ): User { + $cacheKey = self::MFA_SESSION_PREFIX . $sessionToken; + $session = Cache::get($cacheKey); + + if (! $session) { + throw new \DomainException('MFA-sessie verlopen. Log opnieuw in.'); + } + + if ($session['ip_address'] !== $ipAddress) { + Cache::forget($cacheKey); + throw new \DomainException('IP-adres gewijzigd. Log opnieuw in.'); + } + + $user = User::findOrFail($session['user_id']); + + match ($method) { + MfaMethod::TOTP => $this->verifyTotpCode($user, $code), + MfaMethod::EMAIL => $this->verifyEmailCode($user, $code), + MfaMethod::BACKUP_CODE => $this->verifyBackupCode($user, $code), + }; + + Cache::forget($cacheKey); + + activity('mfa') + ->causedBy($user) + ->withProperties(['method' => $method->value]) + ->log('mfa.verified'); + + return $user; + } + + /** + * Verify a TOTP code. + */ + private function verifyTotpCode(User $user, string $code): void + { + $secret = decrypt($user->mfa_secret); + + if (! $this->google2fa->verifyKey($secret, $code, 1)) { + throw new \DomainException('Ongeldige verificatiecode.'); + } + } + + // ─── BACKUP CODES ─── + + /** + * Generate 10 single-use backup codes. + * Returns the plain-text codes (shown to user once, then hashed). + */ + public function generateBackupCodes(User $user): array + { + MfaBackupCode::where('user_id', $user->id)->delete(); + + $plainCodes = []; + + for ($i = 0; $i < self::BACKUP_CODE_COUNT; $i++) { + $code = strtoupper(Str::random(4) . '-' . Str::random(4)); + $plainCodes[] = $code; + + MfaBackupCode::create([ + 'user_id' => $user->id, + 'code_hash' => Hash::make($code), + ]); + } + + return $plainCodes; + } + + /** + * Regenerate backup codes (user action from settings). + */ + public function regenerateBackupCodes(User $user): array + { + if (! $user->mfa_enabled) { + throw new \DomainException('MFA is niet ingeschakeld.'); + } + + $codes = $this->generateBackupCodes($user); + + activity('mfa') + ->causedBy($user) + ->performedOn($user) + ->log('mfa.backup_codes.regenerated'); + + return $codes; + } + + /** + * Verify a backup code. + */ + private function verifyBackupCode(User $user, string $code): void + { + $normalizedCode = strtoupper(str_replace([' ', '-'], '', $code)); + + $backupCodes = MfaBackupCode::where('user_id', $user->id) + ->where('used', false) + ->get(); + + foreach ($backupCodes as $backupCode) { + if (Hash::check($code, $backupCode->code_hash) || + Hash::check($normalizedCode, $backupCode->code_hash)) { + $backupCode->update([ + 'used' => true, + 'used_at' => now(), + ]); + + activity('mfa') + ->causedBy($user) + ->log('mfa.backup_code.used'); + + return; + } + } + + throw new \DomainException('Ongeldige backup code.'); + } + + /** + * Get remaining backup code count. + */ + public function getRemainingBackupCodeCount(User $user): int + { + return MfaBackupCode::where('user_id', $user->id) + ->where('used', false) + ->count(); + } + + // ─── TRUSTED DEVICES ─── + + /** + * Trust the current device for 30 days. + */ + public function trustDevice( + User $user, + string $fingerprint, + string $ipAddress, + ?string $deviceName = null, + ): TrustedDevice { + $deviceHash = hash('sha256', $fingerprint . $user->id); + + TrustedDevice::where('user_id', $user->id) + ->where('device_hash', $deviceHash) + ->delete(); + + return TrustedDevice::create([ + 'user_id' => $user->id, + 'device_hash' => $deviceHash, + 'device_name' => $deviceName, + 'ip_address' => $ipAddress, + 'trusted_until' => now()->addDays(self::TRUSTED_DEVICE_DAYS), + 'last_used_at' => now(), + ]); + } + + /** + * Check if the current device is trusted. + */ + public function isDeviceTrusted(User $user, string $fingerprint): bool + { + $deviceHash = hash('sha256', $fingerprint . $user->id); + + $device = TrustedDevice::where('user_id', $user->id) + ->where('device_hash', $deviceHash) + ->where('trusted_until', '>', now()) + ->first(); + + if ($device) { + $device->update(['last_used_at' => now()]); + + return true; + } + + return false; + } + + /** + * Get all trusted devices for a user. + */ + public function getTrustedDevices(User $user) + { + return TrustedDevice::where('user_id', $user->id) + ->where('trusted_until', '>', now()) + ->orderByDesc('last_used_at') + ->get(); + } + + /** + * Revoke a trusted device. + */ + public function revokeDevice(User $user, string $deviceId): void + { + TrustedDevice::where('id', $deviceId) + ->where('user_id', $user->id) + ->delete(); + + activity('mfa') + ->causedBy($user) + ->log('mfa.trusted_device.revoked'); + } + + /** + * Revoke all trusted devices. + */ + public function revokeAllDevices(User $user): void + { + TrustedDevice::where('user_id', $user->id)->delete(); + + activity('mfa') + ->causedBy($user) + ->log('mfa.trusted_devices.all_revoked'); + } + + // ─── DISABLE / RESET ─── + + /** + * Disable MFA (user action — requires current TOTP/backup code). + */ + public function disable(User $user): void + { + $user->update([ + 'mfa_enabled' => false, + 'mfa_method' => null, + 'mfa_secret' => null, + 'mfa_confirmed_at' => null, + 'mfa_enforced' => false, + ]); + + MfaBackupCode::where('user_id', $user->id)->delete(); + MfaEmailCode::where('user_id', $user->id)->delete(); + TrustedDevice::where('user_id', $user->id)->delete(); + + activity('mfa') + ->causedBy($user) + ->performedOn($user) + ->log('mfa.disabled'); + } + + /** + * Admin reset — force-disable MFA for a user (Platform Admin action). + */ + public function adminReset(User $admin, User $targetUser): void + { + $this->disable($targetUser); + + activity('mfa') + ->causedBy($admin) + ->performedOn($targetUser) + ->withProperties(['reset_by' => 'admin']) + ->log('mfa.admin_reset'); + } + + // ─── ENFORCEMENT ─── + + /** + * Check if a user is required to enable MFA. + */ + public function isMfaRequired(User $user): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + if ($user->hasRole('org_admin')) { + return true; + } + + foreach ($user->organisations as $org) { + $pivot = $org->pivot; + if ($pivot && in_array($pivot->role, ['org_admin', 'org_member'])) { + if ($org->settings['enforce_mfa'] ?? false) { + return true; + } + } + } + + return (bool) $user->mfa_enforced; + } +} diff --git a/api/composer.json b/api/composer.json index 73be565a..593ec259 100644 --- a/api/composer.json +++ b/api/composer.json @@ -12,6 +12,7 @@ "laravel/framework": "^12.0", "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1", + "pragmarx/google2fa": "^9.0", "spatie/laravel-activitylog": "^5.0", "spatie/laravel-medialibrary": "^11.21", "spatie/laravel-permission": "^7.2" diff --git a/api/composer.lock b/api/composer.lock index 3dcd59df..8c90425f 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7c846e38a93670c612ed4a8be21227d7", + "content-hash": "162fa15c2d2ac177259583cf0b271a30", "packages": [ { "name": "bacon/bacon-qr-code", @@ -3225,6 +3225,75 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -3300,6 +3369,58 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "pragmarx/google2fa", + "version": "v9.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" + }, + "time": "2025-09-19T22:51:08+00:00" + }, { "name": "psr/clock", "version": "1.0.0", diff --git a/api/database/migrations/2026_04_15_200000_add_mfa_columns_to_users_table.php b/api/database/migrations/2026_04_15_200000_add_mfa_columns_to_users_table.php new file mode 100644 index 00000000..33afd904 --- /dev/null +++ b/api/database/migrations/2026_04_15_200000_add_mfa_columns_to_users_table.php @@ -0,0 +1,34 @@ +boolean('mfa_enabled')->default(false)->after('email'); + $table->string('mfa_method', 20)->nullable()->after('mfa_enabled'); + $table->text('mfa_secret')->nullable()->after('mfa_method'); + $table->timestamp('mfa_confirmed_at')->nullable()->after('mfa_secret'); + $table->boolean('mfa_enforced')->default(false)->after('mfa_confirmed_at'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'mfa_enabled', + 'mfa_method', + 'mfa_secret', + 'mfa_confirmed_at', + 'mfa_enforced', + ]); + }); + } +}; diff --git a/api/database/migrations/2026_04_15_200001_create_mfa_backup_codes_table.php b/api/database/migrations/2026_04_15_200001_create_mfa_backup_codes_table.php new file mode 100644 index 00000000..67b1556d --- /dev/null +++ b/api/database/migrations/2026_04_15_200001_create_mfa_backup_codes_table.php @@ -0,0 +1,31 @@ +id(); + $table->ulid('user_id'); + $table->string('code_hash', 64); + $table->boolean('used')->default(false); + $table->timestamp('used_at')->nullable(); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users') + ->cascadeOnDelete(); + $table->index(['user_id', 'used']); + }); + } + + public function down(): void + { + Schema::dropIfExists('mfa_backup_codes'); + } +}; diff --git a/api/database/migrations/2026_04_15_200002_create_trusted_devices_table.php b/api/database/migrations/2026_04_15_200002_create_trusted_devices_table.php new file mode 100644 index 00000000..400fefd4 --- /dev/null +++ b/api/database/migrations/2026_04_15_200002_create_trusted_devices_table.php @@ -0,0 +1,33 @@ +ulid('id')->primary(); + $table->ulid('user_id'); + $table->string('device_hash', 64); + $table->string('device_name')->nullable(); + $table->string('ip_address', 45); + $table->timestamp('trusted_until'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users') + ->cascadeOnDelete(); + $table->index(['user_id', 'device_hash', 'trusted_until']); + }); + } + + public function down(): void + { + Schema::dropIfExists('trusted_devices'); + } +}; diff --git a/api/database/migrations/2026_04_15_200003_create_mfa_email_codes_table.php b/api/database/migrations/2026_04_15_200003_create_mfa_email_codes_table.php new file mode 100644 index 00000000..4730784c --- /dev/null +++ b/api/database/migrations/2026_04_15_200003_create_mfa_email_codes_table.php @@ -0,0 +1,31 @@ +id(); + $table->ulid('user_id'); + $table->string('code', 6); + $table->timestamp('expires_at'); + $table->boolean('used')->default(false); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users') + ->cascadeOnDelete(); + $table->index(['user_id', 'code', 'used', 'expires_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('mfa_email_codes'); + } +}; diff --git a/api/routes/api.php b/api/routes/api.php index ac60bc79..171b0e12 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -44,6 +44,9 @@ use App\Http\Controllers\Api\V1\Admin\AdminUserController; use App\Http\Controllers\Api\V1\Admin\AdminStatsController; use App\Http\Controllers\Api\V1\Admin\AdminActivityLogController; use App\Http\Controllers\Api\V1\Admin\AdminImpersonationController; +use App\Http\Controllers\Api\V1\Auth\MfaSetupController; +use App\Http\Controllers\Api\V1\Auth\MfaVerifyController; +use App\Http\Controllers\Api\V1\Auth\TrustedDeviceController; use App\Models\FestivalSection; use App\Models\Organisation; use Illuminate\Support\Facades\Gate; @@ -68,6 +71,10 @@ Route::get('/', fn () => response()->json([ // Public auth routes Route::post('auth/login', LoginController::class)->middleware('throttle:5,1'); +// MFA verification during login (NO auth middleware — uses session token) +Route::post('auth/mfa/verify', [MfaVerifyController::class, 'verify'])->middleware('throttle:10,1'); +Route::post('auth/mfa/email/send', [MfaVerifyController::class, 'sendEmailCode'])->middleware('throttle:5,1'); + // Public invitation routes (no auth required) Route::get('invitations/{token}', [InvitationController::class, 'show'])->middleware('throttle:10,1'); Route::post('invitations/{token}/accept', [InvitationController::class, 'accept'])->middleware('throttle:10,1'); @@ -100,6 +107,7 @@ Route::prefix('admin') // Users Route::apiResource('users', AdminUserController::class) ->except(['store']); + Route::post('users/{user}/reset-mfa', [AdminUserController::class, 'resetMfa']); // Platform statistics Route::get('stats', [AdminStatsController::class, 'index']); @@ -121,6 +129,20 @@ Route::middleware('auth:sanctum')->group(function () { Route::post('auth/logout', LogoutController::class); Route::post('auth/refresh', AuthRefreshController::class); + // MFA setup and management (authenticated) + Route::post('auth/mfa/setup/totp', [MfaSetupController::class, 'setupTotp']); + Route::post('auth/mfa/setup/totp/confirm', [MfaSetupController::class, 'confirmTotp']); + Route::post('auth/mfa/setup/email', [MfaSetupController::class, 'setupEmail']); + Route::post('auth/mfa/setup/email/confirm', [MfaSetupController::class, 'confirmEmail']); + Route::post('auth/mfa/disable', [MfaSetupController::class, 'disable']); + Route::post('auth/mfa/backup-codes', [MfaSetupController::class, 'regenerateBackupCodes']); + Route::get('auth/mfa/status', [MfaSetupController::class, 'status']); + + // Trusted devices + Route::get('auth/trusted-devices', [TrustedDeviceController::class, 'index']); + Route::delete('auth/trusted-devices/{device}', [TrustedDeviceController::class, 'destroy']); + Route::delete('auth/trusted-devices', [TrustedDeviceController::class, 'destroyAll']); + // Account management (self-service) Route::post('me/change-password', [AccountController::class, 'changePassword']); Route::post('me/change-email', [EmailChangeController::class, 'request']); diff --git a/api/tests/Feature/Auth/MfaLoginFlowTest.php b/api/tests/Feature/Auth/MfaLoginFlowTest.php new file mode 100644 index 00000000..59b4945e --- /dev/null +++ b/api/tests/Feature/Auth/MfaLoginFlowTest.php @@ -0,0 +1,272 @@ +google2fa = new Google2FA(); + } + + public function test_login_without_mfa_returns_token_directly(): void + { + $user = User::factory()->create(); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $response->assertOk() + ->assertJsonStructure([ + 'success', + 'data' => ['user' => ['id', 'email']], + ]) + ->assertJsonMissing(['mfa_required' => true]); + } + + public function test_login_with_mfa_returns_mfa_required_response(): void + { + $user = $this->createUserWithTotp(); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $response->assertOk() + ->assertJson(['mfa_required' => true]) + ->assertJsonStructure([ + 'mfa_session_token', + 'methods', + 'preferred_method', + 'expires_in', + ]); + + $this->assertContains('totp', $response->json('methods')); + $this->assertContains('backup_code', $response->json('methods')); + } + + public function test_login_with_trusted_device_skips_mfa(): void + { + $user = $this->createUserWithTotp(); + $fingerprint = 'trusted-device-fingerprint'; + + // Trust the device + app(MfaService::class)->trustDevice($user, $fingerprint, '127.0.0.1', 'Test Device'); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ], ['X-Device-Fingerprint' => $fingerprint]); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => ['user' => ['id', 'email']], + ]) + ->assertJsonMissing(['mfa_required' => true]); + } + + public function test_mfa_verify_totp_issues_token(): void + { + $user = $this->createUserWithTotp(); + $secret = decrypt($user->mfa_secret); + + // Login to get MFA session + $loginResponse = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $sessionToken = $loginResponse->json('mfa_session_token'); + $validCode = $this->google2fa->getCurrentOtp($secret); + + $response = $this->postJson('/api/v1/auth/mfa/verify', [ + 'mfa_session_token' => $sessionToken, + 'code' => $validCode, + 'method' => 'totp', + ]); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => ['user' => ['id', 'email']], + ]); + } + + public function test_mfa_verify_email_code_issues_token(): void + { + $user = User::factory()->create([ + 'mfa_enabled' => true, + 'mfa_method' => MfaMethod::EMAIL->value, + 'mfa_confirmed_at' => now(), + ]); + + // Login to get MFA session + $loginResponse = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $sessionToken = $loginResponse->json('mfa_session_token'); + + // Get the email code that was auto-sent + $emailCode = MfaEmailCode::where('user_id', $user->id) + ->where('used', false) + ->first(); + + $response = $this->postJson('/api/v1/auth/mfa/verify', [ + 'mfa_session_token' => $sessionToken, + 'code' => $emailCode->code, + 'method' => 'email', + ]); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => ['user' => ['id', 'email']], + ]); + } + + public function test_mfa_verify_backup_code_issues_token(): void + { + $user = $this->createUserWithTotp(); + + // Get a backup code + $backupCode = \App\Models\MfaBackupCode::where('user_id', $user->id) + ->where('used', false) + ->first(); + + // We need to know the plain code, so regenerate + $plainCodes = app(MfaService::class)->regenerateBackupCodes($user); + + $loginResponse = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $sessionToken = $loginResponse->json('mfa_session_token'); + + $response = $this->postJson('/api/v1/auth/mfa/verify', [ + 'mfa_session_token' => $sessionToken, + 'code' => $plainCodes[0], + 'method' => 'backup_code', + ]); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => ['user' => ['id', 'email']], + ]); + } + + public function test_mfa_verify_with_trust_device_creates_trusted_device(): void + { + $user = $this->createUserWithTotp(); + $secret = decrypt($user->mfa_secret); + + $loginResponse = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $sessionToken = $loginResponse->json('mfa_session_token'); + $validCode = $this->google2fa->getCurrentOtp($secret); + + $response = $this->postJson('/api/v1/auth/mfa/verify', [ + 'mfa_session_token' => $sessionToken, + 'code' => $validCode, + 'method' => 'totp', + 'trust_device' => true, + 'device_fingerprint' => 'my-device-fp', + 'device_name' => 'Test Browser', + ]); + + $response->assertOk(); + + $this->assertDatabaseHas('trusted_devices', [ + 'user_id' => $user->id, + 'device_name' => 'Test Browser', + ]); + } + + public function test_mfa_verify_expired_session_fails(): void + { + $response = $this->postJson('/api/v1/auth/mfa/verify', [ + 'mfa_session_token' => 'expired-token', + 'code' => '123456', + 'method' => 'totp', + ]); + + $response->assertStatus(422) + ->assertJson(['message' => 'MFA-sessie verlopen. Log opnieuw in.']); + } + + public function test_mfa_verify_wrong_code_fails(): void + { + $user = $this->createUserWithTotp(); + + $loginResponse = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $sessionToken = $loginResponse->json('mfa_session_token'); + + $response = $this->postJson('/api/v1/auth/mfa/verify', [ + 'mfa_session_token' => $sessionToken, + 'code' => '000000', + 'method' => 'totp', + ]); + + $response->assertStatus(422) + ->assertJson(['message' => 'Ongeldige verificatiecode.']); + } + + public function test_login_mfa_required_but_not_setup_flags_setup_required(): void + { + $this->seed(RoleSeeder::class); + + $user = User::factory()->create(); + $user->assignRole('super_admin'); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $response->assertOk() + ->assertJson(['mfa_setup_required' => true]) + ->assertJsonStructure([ + 'data' => ['user' => ['id', 'email']], + ]); + } + + private function createUserWithTotp(): User + { + $user = User::factory()->create(); + $mfaService = app(MfaService::class); + + $setupResult = $mfaService->setupTotp($user); + $secret = $setupResult['secret']; + $validCode = $this->google2fa->getCurrentOtp($secret); + $mfaService->confirmTotp($user, $validCode); + + return $user->refresh(); + } +} diff --git a/api/tests/Feature/Auth/MfaSetupControllerTest.php b/api/tests/Feature/Auth/MfaSetupControllerTest.php new file mode 100644 index 00000000..d9aafeb2 --- /dev/null +++ b/api/tests/Feature/Auth/MfaSetupControllerTest.php @@ -0,0 +1,192 @@ +google2fa = new Google2FA(); + } + + public function test_setup_totp_returns_qr_code(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->postJson('/api/v1/auth/mfa/setup/totp'); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => ['secret', 'qr_code_url', 'provisioning_uri'], + ]); + } + + public function test_confirm_totp_returns_backup_codes(): void + { + $user = User::factory()->create(); + + // Start setup + $setupResponse = $this->actingAs($user)->postJson('/api/v1/auth/mfa/setup/totp'); + $secret = $setupResponse->json('data.secret'); + $validCode = $this->google2fa->getCurrentOtp($secret); + + // Confirm + $response = $this->actingAs($user)->postJson('/api/v1/auth/mfa/setup/totp/confirm', [ + 'code' => $validCode, + ]); + + $response->assertOk() + ->assertJson([ + 'data' => [ + 'mfa_enabled' => true, + 'method' => 'totp', + ], + ]) + ->assertJsonStructure([ + 'data' => ['backup_codes'], + ]); + + $this->assertCount(10, $response->json('data.backup_codes')); + } + + public function test_confirm_totp_with_invalid_code_fails(): void + { + $user = User::factory()->create(); + $this->actingAs($user)->postJson('/api/v1/auth/mfa/setup/totp'); + + $response = $this->actingAs($user)->postJson('/api/v1/auth/mfa/setup/totp/confirm', [ + 'code' => '000000', + ]); + + $response->assertStatus(422) + ->assertJson(['message' => 'Ongeldige verificatiecode.']); + } + + public function test_disable_requires_valid_code(): void + { + $user = $this->createUserWithTotp(); + $secret = decrypt($user->mfa_secret); + $validCode = $this->google2fa->getCurrentOtp($secret); + + $response = $this->actingAs($user)->postJson('/api/v1/auth/mfa/disable', [ + 'code' => $validCode, + 'method' => 'totp', + ]); + + $response->assertOk(); + + $user->refresh(); + $this->assertFalse($user->mfa_enabled); + } + + public function test_disable_with_invalid_code_fails(): void + { + $user = $this->createUserWithTotp(); + + $response = $this->actingAs($user)->postJson('/api/v1/auth/mfa/disable', [ + 'code' => '000000', + 'method' => 'totp', + ]); + + $response->assertStatus(422) + ->assertJson(['message' => 'Ongeldige verificatiecode.']); + + $user->refresh(); + $this->assertTrue($user->mfa_enabled); + } + + public function test_regenerate_backup_codes_requires_mfa_enabled(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->postJson('/api/v1/auth/mfa/backup-codes', [ + 'code' => '123456', + ]); + + $response->assertStatus(422) + ->assertJson(['message' => 'MFA is niet ingeschakeld.']); + } + + public function test_regenerate_backup_codes_with_valid_totp(): void + { + $user = $this->createUserWithTotp(); + $secret = decrypt($user->mfa_secret); + $validCode = $this->google2fa->getCurrentOtp($secret); + + $response = $this->actingAs($user)->postJson('/api/v1/auth/mfa/backup-codes', [ + 'code' => $validCode, + ]); + + $response->assertOk() + ->assertJsonStructure(['data' => ['backup_codes']]); + + $this->assertCount(10, $response->json('data.backup_codes')); + } + + public function test_status_returns_correct_state(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->getJson('/api/v1/auth/mfa/status'); + + $response->assertOk() + ->assertJson([ + 'data' => [ + 'mfa_enabled' => false, + 'method' => null, + 'confirmed_at' => null, + 'backup_codes_remaining' => 0, + ], + ]); + } + + public function test_status_returns_enabled_state(): void + { + $user = $this->createUserWithTotp(); + + $response = $this->actingAs($user)->getJson('/api/v1/auth/mfa/status'); + + $response->assertOk() + ->assertJson([ + 'data' => [ + 'mfa_enabled' => true, + 'method' => 'totp', + 'backup_codes_remaining' => 10, + ], + ]); + } + + public function test_unauthenticated_cannot_access_setup(): void + { + $this->postJson('/api/v1/auth/mfa/setup/totp') + ->assertUnauthorized(); + } + + private function createUserWithTotp(): User + { + $user = User::factory()->create(); + $mfaService = app(MfaService::class); + + $setupResult = $mfaService->setupTotp($user); + $secret = $setupResult['secret']; + $validCode = $this->google2fa->getCurrentOtp($secret); + $mfaService->confirmTotp($user, $validCode); + + return $user->refresh(); + } +} diff --git a/api/tests/Feature/Auth/TrustedDeviceControllerTest.php b/api/tests/Feature/Auth/TrustedDeviceControllerTest.php new file mode 100644 index 00000000..db622fa6 --- /dev/null +++ b/api/tests/Feature/Auth/TrustedDeviceControllerTest.php @@ -0,0 +1,93 @@ +create(); + $mfaService = app(MfaService::class); + + $mfaService->trustDevice($user, 'fp-1', '192.168.1.1', 'Chrome on macOS'); + $mfaService->trustDevice($user, 'fp-2', '192.168.1.2', 'Firefox on Windows'); + + $response = $this->actingAs($user)->getJson('/api/v1/auth/trusted-devices'); + + $response->assertOk(); + $this->assertCount(2, $response->json('data')); + } + + public function test_index_excludes_expired_devices(): void + { + $user = User::factory()->create(); + $mfaService = app(MfaService::class); + + $device = $mfaService->trustDevice($user, 'fp-1', '192.168.1.1', 'Old Device'); + $device->update(['trusted_until' => now()->subDay()]); + + $mfaService->trustDevice($user, 'fp-2', '192.168.1.2', 'Active Device'); + + $response = $this->actingAs($user)->getJson('/api/v1/auth/trusted-devices'); + + $response->assertOk(); + $this->assertCount(1, $response->json('data')); + } + + public function test_destroy_revokes_device(): void + { + $user = User::factory()->create(); + $device = app(MfaService::class)->trustDevice($user, 'fp-1', '192.168.1.1', 'Chrome'); + + $response = $this->actingAs($user)->deleteJson("/api/v1/auth/trusted-devices/{$device->id}"); + + $response->assertNoContent(); + $this->assertDatabaseMissing('trusted_devices', ['id' => $device->id]); + } + + public function test_destroy_all_revokes_all_devices(): void + { + $user = User::factory()->create(); + $mfaService = app(MfaService::class); + + $mfaService->trustDevice($user, 'fp-1', '192.168.1.1', 'Device 1'); + $mfaService->trustDevice($user, 'fp-2', '192.168.1.2', 'Device 2'); + + $response = $this->actingAs($user)->deleteJson('/api/v1/auth/trusted-devices'); + + $response->assertNoContent(); + $this->assertDatabaseCount('trusted_devices', 0); + } + + public function test_cannot_manage_other_users_devices(): void + { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + + $device = app(MfaService::class)->trustDevice($otherUser, 'fp-1', '192.168.1.1', 'Other Device'); + + // Try to delete other user's device — should succeed silently (no record found for this user) + $response = $this->actingAs($user)->deleteJson("/api/v1/auth/trusted-devices/{$device->id}"); + + $response->assertNoContent(); + + // Other user's device should still exist + $this->assertDatabaseHas('trusted_devices', ['id' => $device->id]); + } + + public function test_unauthenticated_cannot_access_devices(): void + { + $this->getJson('/api/v1/auth/trusted-devices') + ->assertUnauthorized(); + } +} diff --git a/api/tests/Unit/Services/MfaServiceTest.php b/api/tests/Unit/Services/MfaServiceTest.php new file mode 100644 index 00000000..d5eb58c8 --- /dev/null +++ b/api/tests/Unit/Services/MfaServiceTest.php @@ -0,0 +1,341 @@ +google2fa = new Google2FA(); + $this->mfaService = app(MfaService::class); + $this->user = User::factory()->create(); + } + + public function test_setup_totp_generates_secret_and_qr_url(): void + { + $result = $this->mfaService->setupTotp($this->user); + + $this->assertArrayHasKey('secret', $result); + $this->assertArrayHasKey('qr_code_url', $result); + $this->assertArrayHasKey('provisioning_uri', $result); + $this->assertNotEmpty($result['secret']); + $this->assertStringContainsString('otpauth://totp/', $result['qr_code_url']); + + $this->user->refresh(); + $this->assertNotNull($this->user->mfa_secret); + $this->assertEquals(MfaMethod::TOTP->value, $this->user->mfa_method); + $this->assertNull($this->user->mfa_confirmed_at); + } + + public function test_confirm_totp_enables_mfa_and_generates_backup_codes(): void + { + $setupResult = $this->mfaService->setupTotp($this->user); + $secret = $setupResult['secret']; + $validCode = $this->google2fa->getCurrentOtp($secret); + + $backupCodes = $this->mfaService->confirmTotp($this->user, $validCode); + + $this->assertCount(10, $backupCodes); + $this->user->refresh(); + $this->assertTrue($this->user->mfa_enabled); + $this->assertNotNull($this->user->mfa_confirmed_at); + $this->assertDatabaseCount('mfa_backup_codes', 10); + } + + public function test_confirm_totp_with_invalid_code_fails(): void + { + $this->mfaService->setupTotp($this->user); + + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('Ongeldige verificatiecode.'); + + $this->mfaService->confirmTotp($this->user, '000000'); + } + + public function test_setup_email_sends_verification_code(): void + { + $this->mfaService->setupEmail($this->user); + + $this->user->refresh(); + $this->assertEquals(MfaMethod::EMAIL->value, $this->user->mfa_method); + $this->assertNull($this->user->mfa_confirmed_at); + $this->assertDatabaseCount('mfa_email_codes', 1); + } + + public function test_confirm_email_enables_mfa(): void + { + // Create a valid email code manually + $code = '123456'; + MfaEmailCode::create([ + 'user_id' => $this->user->id, + 'code' => $code, + 'expires_at' => now()->addMinutes(10), + ]); + + $this->user->update([ + 'mfa_method' => MfaMethod::EMAIL->value, + ]); + + $backupCodes = $this->mfaService->confirmEmail($this->user, $code); + + $this->assertCount(10, $backupCodes); + $this->user->refresh(); + $this->assertTrue($this->user->mfa_enabled); + $this->assertNotNull($this->user->mfa_confirmed_at); + } + + public function test_confirm_email_with_expired_code_fails(): void + { + MfaEmailCode::create([ + 'user_id' => $this->user->id, + 'code' => '123456', + 'expires_at' => now()->subMinute(), + ]); + + $this->user->update(['mfa_method' => MfaMethod::EMAIL->value]); + + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('Ongeldige of verlopen code.'); + + $this->mfaService->confirmEmail($this->user, '123456'); + } + + public function test_email_code_rate_limited(): void + { + $this->mfaService->sendEmailCode($this->user); + + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('Wacht even voordat je een nieuwe code aanvraagt.'); + + $this->mfaService->sendEmailCode($this->user); + } + + public function test_generate_backup_codes_creates_10_hashed_codes(): void + { + $codes = $this->mfaService->generateBackupCodes($this->user); + + $this->assertCount(10, $codes); + $this->assertDatabaseCount('mfa_backup_codes', 10); + + // Verify codes are hashed (not stored plain) + $stored = MfaBackupCode::where('user_id', $this->user->id)->first(); + $this->assertNotEquals($codes[0], $stored->code_hash); + $this->assertTrue(Hash::check($codes[0], $stored->code_hash)); + } + + public function test_verify_backup_code_marks_as_used(): void + { + // Setup MFA with backup codes + $setupResult = $this->mfaService->setupTotp($this->user); + $validCode = $this->google2fa->getCurrentOtp($setupResult['secret']); + $backupCodes = $this->mfaService->confirmTotp($this->user, $validCode); + + // Create MFA session and verify with backup code + $session = $this->mfaService->createMfaSession($this->user, '127.0.0.1'); + $this->mfaService->verifyMfaCode( + $session['mfa_session_token'], + $backupCodes[0], + MfaMethod::BACKUP_CODE, + '127.0.0.1', + ); + + $this->assertDatabaseHas('mfa_backup_codes', [ + 'user_id' => $this->user->id, + 'used' => true, + ]); + + // Verify 9 unused remain + $remaining = MfaBackupCode::where('user_id', $this->user->id) + ->where('used', false)->count(); + $this->assertEquals(9, $remaining); + } + + public function test_verify_backup_code_invalid_fails(): void + { + $this->mfaService->generateBackupCodes($this->user); + $this->user->update([ + 'mfa_enabled' => true, + 'mfa_method' => MfaMethod::TOTP->value, + 'mfa_confirmed_at' => now(), + ]); + + $session = $this->mfaService->createMfaSession($this->user, '127.0.0.1'); + + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('Ongeldige backup code.'); + + $this->mfaService->verifyMfaCode( + $session['mfa_session_token'], + 'INVALID-CODE', + MfaMethod::BACKUP_CODE, + '127.0.0.1', + ); + } + + public function test_trust_device_creates_record(): void + { + $device = $this->mfaService->trustDevice( + $this->user, + 'test-fingerprint', + '192.168.1.1', + 'Chrome on macOS', + ); + + $this->assertDatabaseHas('trusted_devices', [ + 'user_id' => $this->user->id, + 'device_name' => 'Chrome on macOS', + 'ip_address' => '192.168.1.1', + ]); + $this->assertNotNull($device->trusted_until); + } + + public function test_is_device_trusted_returns_true_within_expiry(): void + { + $this->mfaService->trustDevice($this->user, 'test-fingerprint', '192.168.1.1'); + + $this->assertTrue($this->mfaService->isDeviceTrusted($this->user, 'test-fingerprint')); + } + + public function test_is_device_trusted_returns_false_after_expiry(): void + { + $this->mfaService->trustDevice($this->user, 'test-fingerprint', '192.168.1.1'); + + // Manually expire the device + TrustedDevice::where('user_id', $this->user->id) + ->update(['trusted_until' => now()->subDay()]); + + $this->assertFalse($this->mfaService->isDeviceTrusted($this->user, 'test-fingerprint')); + } + + public function test_revoke_device_deletes_record(): void + { + $device = $this->mfaService->trustDevice($this->user, 'test-fingerprint', '192.168.1.1'); + + $this->mfaService->revokeDevice($this->user, $device->id); + + $this->assertDatabaseMissing('trusted_devices', ['id' => $device->id]); + } + + public function test_disable_clears_all_mfa_data(): void + { + // Setup full MFA + $setupResult = $this->mfaService->setupTotp($this->user); + $validCode = $this->google2fa->getCurrentOtp($setupResult['secret']); + $this->mfaService->confirmTotp($this->user, $validCode); + $this->mfaService->trustDevice($this->user, 'test-fingerprint', '192.168.1.1'); + + $this->mfaService->disable($this->user); + + $this->user->refresh(); + $this->assertFalse($this->user->mfa_enabled); + $this->assertNull($this->user->mfa_method); + $this->assertNull($this->user->mfa_secret); + $this->assertNull($this->user->mfa_confirmed_at); + $this->assertDatabaseCount('mfa_backup_codes', 0); + $this->assertDatabaseCount('trusted_devices', 0); + } + + public function test_admin_reset_disables_mfa_with_audit(): void + { + $admin = User::factory()->create(); + $setupResult = $this->mfaService->setupTotp($this->user); + $validCode = $this->google2fa->getCurrentOtp($setupResult['secret']); + $this->mfaService->confirmTotp($this->user, $validCode); + + $this->mfaService->adminReset($admin, $this->user); + + $this->user->refresh(); + $this->assertFalse($this->user->mfa_enabled); + + $this->assertDatabaseHas('activity_log', [ + 'description' => 'mfa.admin_reset', + 'causer_id' => $admin->id, + 'subject_id' => $this->user->id, + ]); + } + + public function test_mfa_required_for_super_admin(): void + { + $this->seed(RoleSeeder::class); + $this->user->assignRole('super_admin'); + + $this->assertTrue($this->mfaService->isMfaRequired($this->user)); + } + + public function test_mfa_not_required_for_volunteer(): void + { + $this->seed(RoleSeeder::class); + // User with no special roles — just a basic user + $this->assertFalse($this->mfaService->isMfaRequired($this->user)); + } + + public function test_mfa_session_expires(): void + { + $this->user->update([ + 'mfa_enabled' => true, + 'mfa_method' => MfaMethod::TOTP->value, + 'mfa_confirmed_at' => now(), + ]); + + $session = $this->mfaService->createMfaSession($this->user, '127.0.0.1'); + + // Manually expire the session + Cache::forget('mfa_session:' . $session['mfa_session_token']); + + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('MFA-sessie verlopen. Log opnieuw in.'); + + $this->mfaService->verifyMfaCode( + $session['mfa_session_token'], + '123456', + MfaMethod::TOTP, + '127.0.0.1', + ); + } + + public function test_mfa_session_ip_check(): void + { + $this->user->update([ + 'mfa_enabled' => true, + 'mfa_method' => MfaMethod::TOTP->value, + 'mfa_confirmed_at' => now(), + ]); + + $session = $this->mfaService->createMfaSession($this->user, '127.0.0.1'); + + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('IP-adres gewijzigd. Log opnieuw in.'); + + $this->mfaService->verifyMfaCode( + $session['mfa_session_token'], + '123456', + MfaMethod::TOTP, + '10.0.0.1', // Different IP + ); + } +} diff --git a/dev-docs/API.md b/dev-docs/API.md index 9390617b..8a661da6 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -13,6 +13,41 @@ Auth: Bearer token (Sanctum) — token delivered via httpOnly cookie, never in t - `POST /auth/forgot-password` — request password reset (public, rate-limited). Body: `{ email, app: "app"|"portal"|"admin" }`. Always returns 200 (no email enumeration). - `POST /auth/reset-password` — reset password with token (public). Body: `{ token, email, password, password_confirmation }`. +## MFA (Multi-Factor Authentication) + +### Login flow with MFA + +When MFA is enabled for a user, login becomes a two-step process: +1. `POST /auth/login` — if MFA is active, returns `{ mfa_required: true, mfa_session_token, methods, preferred_method, expires_in }` instead of the auth token +2. `POST /auth/mfa/verify` — submit the MFA code with the session token to complete login + +If the device is trusted (via `X-Device-Fingerprint` header), MFA is bypassed and login proceeds normally. + +### MFA verification during login (public, rate-limited) + +- `POST /auth/mfa/verify` — verify MFA code during login. Body: `{ mfa_session_token, code, method: "totp"|"email"|"backup_code", trust_device?: bool, device_fingerprint?: string, device_name?: string }`. Returns user data + auth cookie on success. +- `POST /auth/mfa/email/send` — send/resend email verification code during login. Body: `{ mfa_session_token }`. Rate-limited: 1 code per 60 seconds. + +### MFA setup and management (authenticated) + +- `POST /auth/mfa/setup/totp` — start TOTP setup, returns `{ secret, qr_code_url, provisioning_uri }` +- `POST /auth/mfa/setup/totp/confirm` — confirm TOTP with first code. Body: `{ code }`. Returns `{ mfa_enabled, method, backup_codes[] }` +- `POST /auth/mfa/setup/email` — start email MFA setup (sends verification code) +- `POST /auth/mfa/setup/email/confirm` — confirm email MFA. Body: `{ code }`. Returns `{ mfa_enabled, method, backup_codes[] }` +- `POST /auth/mfa/disable` — disable MFA. Body: `{ code, method: "totp"|"backup_code" }`. Requires valid verification code. +- `POST /auth/mfa/backup-codes` — regenerate backup codes. Body: `{ code }`. Requires valid TOTP code. +- `GET /auth/mfa/status` — current MFA status: `{ mfa_enabled, method, confirmed_at, backup_codes_remaining, is_required }` + +### Trusted devices (authenticated) + +- `GET /auth/trusted-devices` — list active trusted devices +- `DELETE /auth/trusted-devices/{id}` — revoke a specific device +- `DELETE /auth/trusted-devices` — revoke all devices + +### Admin MFA management (super_admin) + +- `POST /admin/users/{user}/reset-mfa` — force-disable MFA for a user. Activity logged. + ## Account Management (authenticated) - `POST /me/change-password` — change own password. Body: `{ current_password, password, password_confirmation }`. Revokes other sessions. diff --git a/dev-docs/AUTH_ARCHITECTURE.md b/dev-docs/AUTH_ARCHITECTURE.md index 8eddc7aa..36a4e488 100644 --- a/dev-docs/AUTH_ARCHITECTURE.md +++ b/dev-docs/AUTH_ARCHITECTURE.md @@ -171,3 +171,111 @@ Request | `FRONTEND_APP_URL` | `.env` / `config/app.php` | App SPA origin | | `FRONTEND_PORTAL_URL` | `.env` / `config/app.php` | Portal SPA origin | | `sanctum.expiration` | `config/sanctum.php` | Token TTL (7 days = 10080 minutes) | + +--- + +## 9. Multi-Factor Authentication (MFA) + +Crewli supports enterprise-grade MFA with three verification methods, trusted device management, role-based enforcement, and admin reset capability. + +### 9.1 Verification Methods + +| Method | Type | Expiry | Notes | +|--------|------|--------|-------| +| TOTP | Authenticator app (Google Authenticator, Authy, etc.) | 30s per code | Primary, most secure. Secret stored encrypted. | +| Email code | 6-digit code sent via EmailService | 10 min | Fallback for TOTP users, or standalone method. Rate-limited: 1 code per 60s. | +| Backup codes | 10 single-use codes (XXXX-XXXX format) | Never | Generated at MFA setup. Stored as bcrypt hashes. Last resort recovery. | + +### 9.2 Login Flow with MFA + +``` +Client API + │ │ + │ POST /auth/login │ + │ { email, password } │ + │ ─────────────────────────────────►│ + │ │ ── Validate credentials + │ │ ── Check MFA enabled? + │ │ + │ ┌── MFA NOT enabled ──────────┤ + │ │ Return auth token (cookie)│ + │ │ │ + │ └── MFA enabled ─────────────┤ + │ │ │ + │ ├── Trusted device? ─ YES ─ Return auth token (skip MFA) + │ │ │ + │ └── No trusted device ────┤ + │ Return mfa_required │ + │ + mfa_session_token │ + │ ◄────────────────────────────────│ + │ │ + │ POST /auth/mfa/verify │ + │ { mfa_session_token, code, │ + │ method, trust_device? } │ + │ ─────────────────────────────────►│ + │ │ ── Verify code + │ │ ── Optionally trust device + │ ◄── auth token (cookie) ─────────│ +``` + +### 9.3 MFA Session + +After successful password authentication, if MFA is required, the API creates a temporary MFA session: +- Stored in Redis/Cache with prefix `mfa_session:` +- TTL: 10 minutes +- Contains: `user_id`, `ip_address`, `created_at` +- IP address is checked on verification — if it changes, the session is invalidated +- Session is consumed (deleted) after successful MFA verification + +### 9.4 Trusted Devices + +Users can opt to trust a device during MFA verification. Trusted devices: +- Skip MFA on subsequent logins for 30 days +- Are identified by a SHA-256 hash of `device_fingerprint + user_id` +- The `X-Device-Fingerprint` header must be sent with login requests +- Can be listed, individually revoked, or all revoked by the user +- Are stored in the `trusted_devices` table with ULID primary key + +### 9.5 Backup Codes + +- 10 codes generated at MFA setup (format: `XXXX-XXXX`) +- Stored as bcrypt hashes — plain codes shown to user only once +- Each code is single-use (marked `used` + `used_at` on verification) +- Can be regenerated (requires TOTP verification) +- Input normalization: spaces and dashes stripped, case-insensitive + +### 9.6 Role-Based Enforcement + +MFA is required (enforced) for: +- `super_admin` — always +- `org_admin` — always +- Any user in an organisation with `settings.enforce_mfa = true` +- Any user with `mfa_enforced = true` flag + +When MFA is required but not yet set up, login succeeds but includes `mfa_setup_required: true` flag. The frontend should redirect to MFA setup. + +### 9.7 Admin Reset + +Platform admins (`super_admin`) can force-disable MFA for any user via `POST /admin/users/{user}/reset-mfa`. This: +- Clears all MFA data (secret, backup codes, email codes, trusted devices) +- Logs the action in the activity log with `mfa.admin_reset` event +- The user must re-enable MFA on next login if enforcement policies apply + +### 9.8 Database Tables + +| Table | PK Type | Purpose | +|-------|---------|---------| +| `users` (MFA columns) | — | `mfa_enabled`, `mfa_method`, `mfa_secret` (encrypted), `mfa_confirmed_at`, `mfa_enforced` | +| `mfa_backup_codes` | auto-increment | Hashed single-use recovery codes | +| `mfa_email_codes` | auto-increment | Temporary 6-digit email verification codes | +| `trusted_devices` | ULID | Device trust records with expiry | + +### 9.9 Key Files + +| File | Purpose | +|------|---------| +| `app/Services/MfaService.php` | Central MFA logic — setup, verification, backup codes, trusted devices, enforcement | +| `app/Enums/MfaMethod.php` | TOTP, EMAIL, BACKUP_CODE enum | +| `app/Http/Controllers/Api/V1/Auth/MfaSetupController.php` | Authenticated MFA setup/disable/status endpoints | +| `app/Http/Controllers/Api/V1/Auth/MfaVerifyController.php` | Login-flow MFA verification (unauthenticated) | +| `app/Http/Controllers/Api/V1/Auth/TrustedDeviceController.php` | Trusted device management | diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 15db0651..4b25c3af 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -43,7 +43,8 @@ ## Table of Contents 1. [3.5.1 Foundation](#351-foundation) -2. [3.5.2 Locations](#352-locations) +2. [3.5.1a Multi-Factor Authentication](#351a-multi-factor-authentication) +3. [3.5.2 Locations](#352-locations) 3. [3.5.3 Festival Sections, Time Slots & Shifts](#353-festival-sections-time-slots--shifts) 4. [3.5.4 Volunteer Profile & History](#354-volunteer-profile--history) 5. [3.5.5 Crowd Types, Persons & Crowd Lists](#355-crowd-types-persons--crowd-lists) @@ -68,6 +69,11 @@ | `first_name` | string | | | `last_name` | string | | | `email` | string | unique | +| `mfa_enabled` | boolean | default: false | +| `mfa_method` | string(20) nullable | `totp` or `email` | +| `mfa_secret` | text nullable | encrypted TOTP secret | +| `mfa_confirmed_at` | timestamp nullable | null = setup not yet verified | +| `mfa_enforced` | boolean | default: false — forced by policy or admin | | `password` | string | hashed | | `timezone` | string | default: Europe/Amsterdam | | `locale` | string | default: nl | @@ -75,7 +81,7 @@ | `email_verified_at` | timestamp nullable | | | `deleted_at` | timestamp nullable | Soft delete | -**Relations:** `belongsToMany` organisations (via `organisation_user`), `belongsToMany` events (via `event_user_roles`) +**Relations:** `belongsToMany` organisations (via `organisation_user`), `belongsToMany` events (via `event_user_roles`), `hasMany` mfa_backup_codes, `hasMany` mfa_email_codes, `hasMany` trusted_devices **Soft delete:** yes --- @@ -263,6 +269,67 @@ scopeFestivals() // WHERE event_type IN ('festival', 'series') --- +## 3.5.1a Multi-Factor Authentication + +> MFA tables supporting TOTP, email codes, backup codes, and trusted devices. +> See `/dev-docs/AUTH_ARCHITECTURE.md` section 9 for full architecture. + +### `mfa_backup_codes` + +| Column | Type | Notes | +| ----------- | ------------------ | ------------------------ | +| `id` | bigint | PK, auto-increment | +| `user_id` | ULID | FK → users | +| `code_hash` | string(64) | bcrypt hash of code | +| `used` | boolean | default: false | +| `used_at` | timestamp nullable | | +| `created_at`| timestamp | | +| `updated_at`| timestamp | | + +**Relations:** `belongsTo` User +**Indexes:** `(user_id, used)` +**Soft delete:** no (audit record) + +--- + +### `mfa_email_codes` + +| Column | Type | Notes | +| ----------- | ------------------ | ------------------------ | +| `id` | bigint | PK, auto-increment | +| `user_id` | ULID | FK → users | +| `code` | string(6) | 6-digit numeric code | +| `expires_at`| timestamp | 10 min from creation | +| `used` | boolean | default: false | +| `created_at`| timestamp | | +| `updated_at`| timestamp | | + +**Relations:** `belongsTo` User +**Indexes:** `(user_id, code, used, expires_at)` +**Soft delete:** no (audit record) + +--- + +### `trusted_devices` + +| Column | Type | Notes | +| -------------- | ------------------ | ------------------------------ | +| `id` | ULID | PK | +| `user_id` | ULID | FK → users | +| `device_hash` | string(64) | SHA-256 of fingerprint+user_id | +| `device_name` | string nullable | e.g. "Chrome on macOS" | +| `ip_address` | string(45) | IPv4 or IPv6 | +| `trusted_until`| timestamp | 30 days from creation | +| `last_used_at` | timestamp nullable | | +| `created_at` | timestamp | | +| `updated_at` | timestamp | | + +**Relations:** `belongsTo` User +**Indexes:** `(user_id, device_hash, trusted_until)` +**Soft delete:** no + +--- + ## 3.5.2 Locations > Locations are event-scoped and reusable across sections within an event.