When MFA was already enabled and the user clicked "Opnieuw instellen" on the TOTP card, setupTotp() unconditionally set mfa_confirmed_at to null. If the user then cancelled the dialog without confirming, the login controller's check `mfa_enabled && mfa_confirmed_at` evaluated to false (true && null), silently skipping the MFA challenge. Fix: only set mfa_method and mfa_confirmed_at when MFA is not yet enabled (first-time setup). For re-setup or adding TOTP as a second method, only rotate the mfa_secret — matching the guard already applied to setupEmail(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
516 lines
14 KiB
PHP
516 lines
14 KiB
PHP
<?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.
|
|
* If MFA is already enabled (re-setup or adding as second method),
|
|
* only the secret is rotated — confirmed_at and preferred method
|
|
* are preserved so a cancelled setup doesn't break the login flow.
|
|
*/
|
|
public function setupTotp(User $user): array
|
|
{
|
|
$secret = $this->google2fa->generateSecretKey(32);
|
|
|
|
$updateData = ['mfa_secret' => encrypt($secret)];
|
|
|
|
if (! $user->mfa_enabled) {
|
|
$updateData['mfa_method'] = MfaMethod::TOTP->value;
|
|
$updateData['mfa_confirmed_at'] = null;
|
|
}
|
|
|
|
$user->update($updateData);
|
|
|
|
$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.
|
|
* Preserves the TOTP secret so both methods can coexist.
|
|
*/
|
|
public function setupEmail(User $user): void
|
|
{
|
|
if (! $user->mfa_enabled) {
|
|
$user->update([
|
|
'mfa_method' => MfaMethod::EMAIL->value,
|
|
'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;
|
|
}
|
|
}
|