Files
crewli/api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php
bert.hausmans c62f377668 fix: MFA setup completion not updating UI state
Root cause: the MFA status endpoint returned `mfa_enabled` as the JSON
key but the TypeScript MfaStatus interface expected `enabled`. At
runtime, `mfaStatus.value?.enabled` was always `undefined`, so
`isEnabled` was always false — the banner never hid and the method
cards never showed "Geconfigureerd".

Additionally, the auth store had no way to re-fetch /auth/me after
initialization, so `mfaSetupRequired` was never properly refreshed
from the backend after MFA setup.

Fixes:
- Rename `mfa_enabled` → `enabled` in the MFA status endpoint response
  to match the TypeScript type (and the /auth/me MeResource which
  already used `enabled`)
- Add `refreshUser()` to the auth store for post-initialization
  re-fetching of /auth/me
- Call `refreshUser()` in onSetupCompleted so the store reflects the
  backend state without a full page reload
- Update backend tests to match the renamed response key

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:30:58 +02:00

169 lines
5.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;
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),
]);
}
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.');
}
}