diff --git a/api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php b/api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php index 6e700161..b5bb85be 100644 --- a/api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php +++ b/api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php @@ -11,6 +11,7 @@ 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 { @@ -140,9 +141,40 @@ final class MfaSetupController extends Controller '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)); diff --git a/api/app/Services/MfaService.php b/api/app/Services/MfaService.php index 038652b8..dd61b091 100644 --- a/api/app/Services/MfaService.php +++ b/api/app/Services/MfaService.php @@ -90,14 +90,16 @@ final class MfaService /** * 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 { - $user->update([ - 'mfa_method' => MfaMethod::EMAIL->value, - 'mfa_secret' => null, - 'mfa_confirmed_at' => null, - ]); + if (! $user->mfa_enabled) { + $user->update([ + 'mfa_method' => MfaMethod::EMAIL->value, + 'mfa_confirmed_at' => null, + ]); + } $this->sendEmailCode($user); } diff --git a/api/routes/api.php b/api/routes/api.php index 8fe034c4..e9f51369 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -137,6 +137,7 @@ Route::middleware('auth:sanctum')->group(function () { Route::post('auth/mfa/disable', [MfaSetupController::class, 'disable']); Route::post('auth/mfa/backup-codes', [MfaSetupController::class, 'regenerateBackupCodes']); Route::get('auth/mfa/status', [MfaSetupController::class, 'status']); + Route::put('auth/mfa/preferred-method', [MfaSetupController::class, 'setPreferredMethod']); // Trusted devices Route::get('auth/trusted-devices', [TrustedDeviceController::class, 'index']); diff --git a/apps/app/src/components/account-settings/SecurityTab.vue b/apps/app/src/components/account-settings/SecurityTab.vue index 0e6041e1..8a680de0 100644 --- a/apps/app/src/components/account-settings/SecurityTab.vue +++ b/apps/app/src/components/account-settings/SecurityTab.vue @@ -7,6 +7,7 @@ import { useRevokeDevice, useRevokeAllDevices, useRegenerateBackupCodes, + useSetPreferredMethod, } from '@/composables/api/useMfa' import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue' import MfaEmailSetupDialog from '@/components/settings/MfaEmailSetupDialog.vue' @@ -59,6 +60,8 @@ const revokeDeviceMutation = useRevokeDevice() const revokeAllMutation = useRevokeAllDevices() const regenerateCodesMutation = useRegenerateBackupCodes() +const setPreferredMethodMutation = useSetPreferredMethod() + const showTotpSetup = ref(false) const showEmailSetup = ref(false) const showDisableDialog = ref(false) @@ -69,8 +72,13 @@ const regenerateError = ref('') const isEnabled = computed(() => mfaStatus.value?.enabled ?? false) -const totpConfigured = computed(() => isEnabled.value && mfaStatus.value?.method === 'totp') -const emailConfigured = computed(() => isEnabled.value && mfaStatus.value?.method === 'email') +const totpConfigured = computed(() => mfaStatus.value?.totp_configured ?? false) +const emailConfigured = computed(() => mfaStatus.value?.email_configured ?? false) +const preferredMethod = computed(() => mfaStatus.value?.method ?? null) + +function handleSetPreferred(method: 'totp' | 'email') { + setPreferredMethodMutation.mutate(method) +} const backupCodesRemaining = computed(() => mfaStatus.value?.backup_codes_remaining ?? 0) const backupCodesColor = computed(() => { @@ -298,14 +306,30 @@ function copyRegeneratedCodes() { +

- Primaire methode -

-

Gebruik Google Authenticator, Authy of een andere app. @@ -315,6 +339,7 @@ function copyRegeneratedCodes() { v-if="totpConfigured" variant="tonal" size="small" + class="mt-2" @click="showTotpSetup = true" > Opnieuw instellen @@ -367,14 +392,30 @@ function copyRegeneratedCodes() { +

- Primaire methode -

-

Ontvang een code per e-mail bij elke login. @@ -384,6 +425,7 @@ function copyRegeneratedCodes() { v-if="emailConfigured" variant="tonal" size="small" + class="mt-2" @click="showEmailSetup = true" > Opnieuw instellen diff --git a/apps/app/src/composables/api/useMfa.ts b/apps/app/src/composables/api/useMfa.ts index abb52781..de68cb33 100644 --- a/apps/app/src/composables/api/useMfa.ts +++ b/apps/app/src/composables/api/useMfa.ts @@ -112,6 +112,21 @@ export function useMfaStatus() { }) } +export function useSetPreferredMethod() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (method: 'totp' | 'email') => { + const { data } = await apiClient.put>('/auth/mfa/preferred-method', { method }) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mfa-status'] }) + }, + }) +} + // ─── Trusted devices ─── export function useTrustedDevices() { diff --git a/apps/app/src/types/mfa.ts b/apps/app/src/types/mfa.ts index 905cdd8f..3bbb2555 100644 --- a/apps/app/src/types/mfa.ts +++ b/apps/app/src/types/mfa.ts @@ -12,6 +12,8 @@ export interface MfaStatus { confirmed_at: string | null backup_codes_remaining: number is_required: boolean + totp_configured: boolean + email_configured: boolean } export interface MfaUserStatus { diff --git a/apps/portal/src/composables/api/useMfa.ts b/apps/portal/src/composables/api/useMfa.ts index 6cac3623..cb8c0dc8 100644 --- a/apps/portal/src/composables/api/useMfa.ts +++ b/apps/portal/src/composables/api/useMfa.ts @@ -109,6 +109,21 @@ export function useMfaStatus() { }) } +export function useSetPreferredMethod() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (method: 'totp' | 'email') => { + const { data } = await apiClient.put>('/auth/mfa/preferred-method', { method }) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mfa-status'] }) + }, + }) +} + // ─── Trusted devices ─── export function useTrustedDevices() { diff --git a/apps/portal/src/pages/profiel.vue b/apps/portal/src/pages/profiel.vue index 4e0530b7..6a6f9288 100644 --- a/apps/portal/src/pages/profiel.vue +++ b/apps/portal/src/pages/profiel.vue @@ -7,6 +7,7 @@ import { useTrustedDevices, useRevokeDevice, useRevokeAllDevices, + useSetPreferredMethod, } from '@/composables/api/useMfa' import { apiClient } from '@/lib/axios' import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue' @@ -209,16 +210,19 @@ const { data: trustedDevices, refetch: refetchDevices } = useTrustedDevices() const revokeDeviceMutation = useRevokeDevice() const revokeAllMutation = useRevokeAllDevices() +const setPreferredMethodMutation = useSetPreferredMethod() + const showTotpSetup = ref(false) const showEmailSetup = ref(false) const showDisableDialog = ref(false) const isMfaEnabled = computed(() => mfaStatus.value?.enabled ?? false) -const mfaMethodLabel = computed(() => { - if (mfaStatus.value?.method === 'totp') return 'Authenticator app' - if (mfaStatus.value?.method === 'email') return 'E-mailcode' +const totpConfigured = computed(() => mfaStatus.value?.totp_configured ?? false) +const emailConfigured = computed(() => mfaStatus.value?.email_configured ?? false) +const preferredMethod = computed(() => mfaStatus.value?.method ?? null) - return null -}) +function handleSetPreferred(method: 'totp' | 'email') { + setPreferredMethodMutation.mutate(method) +} function onMfaSetupCompleted() { refetchMfaStatus() } function onMfaDisabled() { refetchMfaStatus(); refetchDevices() } @@ -668,28 +672,95 @@ function viewEvent(eventId: string) {