Adds the ability for users to change their preferred/primary MFA method when both TOTP and email are available. Backend: - Add PUT /auth/mfa/preferred-method endpoint with validation (method must be totp/email, MFA must be enabled, TOTP must be configured if selecting totp) - Add totp_configured and email_configured fields to MFA status endpoint (totp = has secret + enabled, email = always when enabled) - Fix setupEmail() to preserve mfa_secret so TOTP config survives when email is set up as a second method Frontend (organizer + portal): - Add useSetPreferredMethod() composable to useMfa.ts - Add totp_configured/email_configured to MfaStatus type - SecurityTab method cards now show "Primaire methode" chip on the preferred method and "Als primair instellen" button on the other - Portal security section shows per-method rows with status chips and primary switching Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
201 lines
6.4 KiB
PHP
201 lines
6.4 KiB
PHP
<?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;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
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([
|
|
'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),
|
|
'totp_configured' => $user->mfa_enabled && $user->mfa_secret !== null,
|
|
'email_configured' => $user->mfa_enabled,
|
|
]);
|
|
}
|
|
|
|
public function setPreferredMethod(Request $request): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'method' => ['required', Rule::in(['totp', 'email'])],
|
|
]);
|
|
|
|
$user = $request->user();
|
|
|
|
if (! $user->mfa_enabled) {
|
|
return $this->error('MFA is niet ingeschakeld.', 422);
|
|
}
|
|
|
|
$method = $request->input('method');
|
|
|
|
if ($method === 'totp' && $user->mfa_secret === null) {
|
|
return $this->error('Authenticator app is niet geconfigureerd.', 422);
|
|
}
|
|
|
|
$user->update(['mfa_method' => $method]);
|
|
|
|
activity('mfa')
|
|
->causedBy($user)
|
|
->performedOn($user)
|
|
->withProperties(['method' => $method])
|
|
->log('mfa.preferred_method.changed');
|
|
|
|
return $this->success(['method' => $method], 'Primaire methode gewijzigd.');
|
|
}
|
|
|
|
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.');
|
|
}
|
|
}
|