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; } }