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:
@@ -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,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
21
api/app/Enums/MfaMethod.php
Normal file
21
api/app/Enums/MfaMethod.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum MfaMethod: string
|
||||
{
|
||||
case TOTP = 'totp';
|
||||
case EMAIL = 'email';
|
||||
case BACKUP_CODE = 'backup_code';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::TOTP => 'Authenticator app',
|
||||
self::EMAIL => 'E-mailcode',
|
||||
self::BACKUP_CODE => 'Backup code',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
168
api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php
Normal file
168
api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
22
api/app/Http/Requests/Api/V1/Auth/MfaConfirmRequest.php
Normal file
22
api/app/Http/Requests/Api/V1/Auth/MfaConfirmRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class MfaConfirmRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
28
api/app/Http/Requests/Api/V1/Auth/MfaDisableRequest.php
Normal file
28
api/app/Http/Requests/Api/V1/Auth/MfaDisableRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1\Auth;
|
||||
|
||||
use App\Enums\MfaMethod;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class MfaDisableRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => ['required', 'string'],
|
||||
'method' => ['required', 'string', Rule::in([
|
||||
MfaMethod::TOTP->value,
|
||||
MfaMethod::BACKUP_CODE->value,
|
||||
])],
|
||||
];
|
||||
}
|
||||
}
|
||||
22
api/app/Http/Requests/Api/V1/Auth/MfaEmailSendRequest.php
Normal file
22
api/app/Http/Requests/Api/V1/Auth/MfaEmailSendRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class MfaEmailSendRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'mfa_session_token' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
33
api/app/Http/Requests/Api/V1/Auth/MfaVerifyRequest.php
Normal file
33
api/app/Http/Requests/Api/V1/Auth/MfaVerifyRequest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1\Auth;
|
||||
|
||||
use App\Enums\MfaMethod;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class MfaVerifyRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'mfa_session_token' => ['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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
23
api/app/Http/Resources/Api/V1/TrustedDeviceResource.php
Normal file
23
api/app/Http/Resources/Api/V1/TrustedDeviceResource.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class TrustedDeviceResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
api/app/Models/MfaBackupCode.php
Normal file
37
api/app/Models/MfaBackupCode.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class MfaBackupCode extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'code_hash',
|
||||
'used',
|
||||
'used_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'used' => 'boolean',
|
||||
'used_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function scopeUnused(Builder $query): Builder
|
||||
{
|
||||
return $query->where('used', false);
|
||||
}
|
||||
}
|
||||
38
api/app/Models/MfaEmailCode.php
Normal file
38
api/app/Models/MfaEmailCode.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class MfaEmailCode extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'code',
|
||||
'expires_at',
|
||||
'used',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => '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());
|
||||
}
|
||||
}
|
||||
42
api/app/Models/TrustedDevice.php
Normal file
42
api/app/Models/TrustedDevice.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class TrustedDevice extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'device_hash',
|
||||
'device_name',
|
||||
'ip_address',
|
||||
'trusted_until',
|
||||
'last_used_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'trusted_until' => '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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
507
api/app/Services/MfaService.php
Normal file
507
api/app/Services/MfaService.php
Normal file
@@ -0,0 +1,507 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\EmailTemplateType;
|
||||
use App\Enums\MfaMethod;
|
||||
use App\Models\MfaBackupCode;
|
||||
use App\Models\MfaEmailCode;
|
||||
use App\Models\TrustedDevice;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
|
||||
final class MfaService
|
||||
{
|
||||
private const MFA_SESSION_PREFIX = 'mfa_session:';
|
||||
private const MFA_SESSION_TTL_MINUTES = 10;
|
||||
private const BACKUP_CODE_COUNT = 10;
|
||||
private const EMAIL_CODE_EXPIRY_MINUTES = 10;
|
||||
private const TRUSTED_DEVICE_DAYS = 30;
|
||||
private const EMAIL_CODE_RATE_LIMIT_SECONDS = 60;
|
||||
|
||||
public function __construct(
|
||||
private Google2FA $google2fa,
|
||||
private EmailService $emailService,
|
||||
) {}
|
||||
|
||||
// ─── TOTP SETUP ───
|
||||
|
||||
/**
|
||||
* Begin TOTP setup: generate a secret and return QR code data.
|
||||
* MFA is NOT yet active — user must confirm with a valid code.
|
||||
*/
|
||||
public function setupTotp(User $user): array
|
||||
{
|
||||
$secret = $this->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user