user(); abort_if($user->hasTwoFactorEnabled(), 422, __('messages.two_factor_already_enabled')); $google2fa = new Google2FA(); $secret = $google2fa->generateSecretKey(); $user->forceFill([ 'two_factor_secret' => $secret, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, ])->save(); return response()->json([ 'secret' => $secret, 'otpauth_url' => $google2fa->getQRCodeUrl(config('app.name'), $user->email, $secret), ]); } /** * Confirma la activación con un código real de la app autenticadora. * Recién acá se generan y devuelven los códigos de recuperación (una sola vez). */ public function confirm(Request $request) { $data = $request->validate([ 'code' => ['required', 'string'], ]); $user = $request->user(); abort_if(! $user->two_factor_secret, 422, __('messages.two_factor_not_pending')); if (! (new Google2FA())->verifyKey($user->two_factor_secret, $data['code'])) { throw ValidationException::withMessages([ 'code' => __('messages.invalid_two_factor_code'), ]); } $recoveryCodes = $this->generateRecoveryCodes(); $user->forceFill([ 'two_factor_recovery_codes' => $recoveryCodes, 'two_factor_confirmed_at' => now(), ])->save(); AuditLog::record($request, 'user.2fa_enabled', 'user', $user->id, $user->email); return response()->json(['recovery_codes' => $recoveryCodes]); } public function disable(Request $request) { $data = $request->validate([ 'password' => ['required', 'string'], ]); $user = $request->user(); if (! Hash::check($data['password'], $user->password)) { throw ValidationException::withMessages([ 'password' => __('messages.invalid_password'), ]); } $user->forceFill([ 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, ])->save(); AuditLog::record($request, 'user.2fa_disabled', 'user', $user->id, $user->email); return response()->noContent(); } /** * Invalida los códigos de recuperación anteriores y genera un juego nuevo * (una sola vez más, igual que en la confirmación inicial). */ public function regenerateRecoveryCodes(Request $request) { $data = $request->validate([ 'password' => ['required', 'string'], ]); $user = $request->user(); abort_unless($user->hasTwoFactorEnabled(), 422, __('messages.two_factor_not_enabled')); if (! Hash::check($data['password'], $user->password)) { throw ValidationException::withMessages([ 'password' => __('messages.invalid_password'), ]); } $recoveryCodes = $this->generateRecoveryCodes(); $user->forceFill(['two_factor_recovery_codes' => $recoveryCodes])->save(); AuditLog::record($request, 'user.2fa_recovery_regenerated', 'user', $user->id, $user->email); return response()->json(['recovery_codes' => $recoveryCodes]); } private function generateRecoveryCodes(): array { return collect(range(1, 8)) ->map(fn () => Str::upper(Str::random(4).'-'.Str::random(4))) ->all(); } }