2FA-Challenge: Fehlversuch crasht nicht mehr (Route two-factor.login fehlt)

Fortifys FailedTwoFactorLoginResponse leitet auf die Route
two-factor.login um, die bei views => false nie registriert wird
(RouteNotFoundException). Eigener Response leitet zurück auf die
Volt-Challenge-Seite und meldet den Fehler auf Deutsch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-07 10:58:17 +00:00
parent 62fbafa4ce
commit af9f238978
3 changed files with 65 additions and 0 deletions

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Responses;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\FailedTwoFactorLoginResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Ersetzt Fortifys FailedTwoFactorLoginResponse: Der Standard leitet auf die
* Fortify-Route `two-factor.login` um, die bei `views => false` nicht
* registriert ist (RouteNotFoundException). Wir leiten stattdessen zurück auf
* unsere Volt-Challenge-Seite.
*/
class FailedTwoFactorChallengeResponse implements FailedTwoFactorLoginResponse
{
public function toResponse($request): Response
{
[$key, $message] = $request->filled('recovery_code')
? ['recovery_code', __('Der eingegebene Wiederherstellungs-Code ist ungültig.')]
: ['code', __('Der eingegebene Code ist ungültig. Bitte versuchen Sie es mit einem aktuellen Code erneut.')];
if ($request->wantsJson()) {
throw ValidationException::withMessages([
$key => [$message],
]);
}
return redirect()->route('two-factor.challenge')->withErrors([$key => $message]);
}
}

View file

@ -6,6 +6,7 @@ use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Http\Responses\FailedTwoFactorChallengeResponse;
use App\Http\Responses\RoleAwareLoginResponse;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
@ -13,6 +14,7 @@ use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
use Laravel\Fortify\Contracts\FailedTwoFactorLoginResponse;
use Laravel\Fortify\Contracts\LoginResponse;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
use Laravel\Fortify\Fortify;
@ -27,6 +29,9 @@ class FortifyServiceProvider extends ServiceProvider
{
$this->app->singleton(LoginResponse::class, RoleAwareLoginResponse::class);
$this->app->singleton(TwoFactorLoginResponse::class, RoleAwareLoginResponse::class);
// Fortifys Default leitet auf die nicht registrierte Route
// `two-factor.login` (views => false) – zurück zur Volt-Challenge.
$this->app->singleton(FailedTwoFactorLoginResponse::class, FailedTwoFactorChallengeResponse::class);
}
/**

View file

@ -78,6 +78,35 @@ test('a valid two-factor code completes login with a role-aware redirect', funct
$this->assertAuthenticatedAs($customer);
});
test('an invalid two-factor code returns to the challenge page with an error', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => true]);
enableTwoFactor($user);
$this->withSession(['login.id' => $user->id, 'login.remember' => false])
->post('/two-factor-challenge', ['code' => '000000'])
->assertRedirect(route('two-factor.challenge'))
->assertSessionHasErrors('code');
$this->assertGuest();
});
test('an invalid recovery code returns to the challenge page with an error', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => true]);
enableTwoFactor($user);
$user->forceFill([
'two_factor_recovery_codes' => encrypt(json_encode(['gueltiger-code'])),
])->save();
$this->withSession(['login.id' => $user->id, 'login.remember' => false])
->post('/two-factor-challenge', ['recovery_code' => 'falscher-code'])
->assertRedirect(route('two-factor.challenge'))
->assertSessionHasErrors('recovery_code');
$this->assertGuest();
});
test('the fortify login post blocks inactive but verified users', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => false]);