Security: 2FA-Bypass beheben & Login-Pfade konsolidieren
Befund (Review 16.06.): Der Volt-Login machte direkt Auth::attempt() und umging Fortifys 2FA-Pipeline (2FA-Bypass); zusätzlich existierte der Fortify-POST /login parallel mit schwächeren Post-Login-Regeln. Fix (Volt-nativ): - Volt-Login prüft Credentials ohne sofortiges Login; bei aktivem 2FA wird der Session-Vertrag login.id/login.remember gesetzt und auf eine neue Volt- 2FA-Challenge-Seite (/two-factor-challenge) geleitet, die an Fortifys bestehenden Controller postet (TOTP + Recovery-Code). - Gemeinsame Post-Login-Logik in App\Support\LoginRedirect (rollengerechtes Home + 403-sicherer intended-Redirect), genutzt von Volt-Login UND Response. - RoleAwareLoginResponse implementiert jetzt LoginResponse UND TwoFactorLoginResponse und erzwingt einheitlich: unverifiziert → Notice, verifiziert-inaktiv → Logout+Fehler, sonst 403-sicherer Redirect. Damit ist auch der direkte Fortify-POST-Pfad gehärtet. Tests: 2FA-Übergabe, Challenge-Guard, voller TOTP-Flow, Fortify-POST blockt inaktive User und hält Customer aus dem Admin-Bereich. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d98d297524
commit
f4ca452c6b
8 changed files with 295 additions and 81 deletions
|
|
@ -2,17 +2,22 @@
|
|||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use App\Support\LoginRedirect;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||
use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract;
|
||||
|
||||
/**
|
||||
* Leitet Panel-User nach erfolgreichem Login je nach Rolle:
|
||||
* - Admin/Editor → /dashboard (Admin-Bereich)
|
||||
* - Customer → /admin/me (Mein Bereich)
|
||||
* Einheitliche Antwort für den Fortify-POST-Login UND den Abschluss der
|
||||
* 2FA-Challenge. Spiegelt dieselbe Policy wie der Volt-Login:
|
||||
* - unverifiziert → Verifizierungs-Notice
|
||||
* - verifiziert, aber inaktiv → Login blockiert (Logout + Fehler)
|
||||
* - sonst rollengerechter, 403-sicherer Redirect (intended nur wenn erreichbar)
|
||||
*/
|
||||
class RoleAwareLoginResponse implements LoginResponseContract
|
||||
class RoleAwareLoginResponse implements LoginResponseContract, TwoFactorLoginResponseContract
|
||||
{
|
||||
public function toResponse($request): RedirectResponse|JsonResponse
|
||||
{
|
||||
|
|
@ -21,31 +26,24 @@ class RoleAwareLoginResponse implements LoginResponseContract
|
|||
}
|
||||
|
||||
$user = $request->user();
|
||||
$intended = redirect()->intended();
|
||||
|
||||
if ($user?->canAccessAdmin()) {
|
||||
return $intended->setTargetUrl(
|
||||
$this->resolveTarget($intended->getTargetUrl(), route('dashboard'))
|
||||
);
|
||||
if ($user && ! $user->hasVerifiedEmail()) {
|
||||
return redirect()->route('verification.notice');
|
||||
}
|
||||
|
||||
if ($user?->canAccessCustomer()) {
|
||||
return $intended->setTargetUrl(
|
||||
$this->resolveTarget($intended->getTargetUrl(), route('me.dashboard'))
|
||||
);
|
||||
if ($user && ! $user->is_active) {
|
||||
Auth::guard('web')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->withErrors([
|
||||
'email' => __('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $intended->setTargetUrl(url('/'));
|
||||
}
|
||||
$default = LoginRedirect::homeFor($user);
|
||||
$intended = $request->session()->pull('url.intended');
|
||||
|
||||
/**
|
||||
* Übernimmt die Intended-URL nur, wenn sie nicht auf den Default-Home-Pfad zeigt.
|
||||
*/
|
||||
private function resolveTarget(string $intendedUrl, string $fallback): string
|
||||
{
|
||||
$homePath = (string) config('fortify.home', '/dashboard');
|
||||
$intendedPath = parse_url($intendedUrl, PHP_URL_PATH) ?: '/';
|
||||
|
||||
return $intendedPath === $homePath || $intendedPath === '/' ? $fallback : $intendedUrl;
|
||||
return redirect(LoginRedirect::safeTarget($user, $intended, $default));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use Illuminate\Support\ServiceProvider;
|
|||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
|
||||
use Laravel\Fortify\Contracts\LoginResponse;
|
||||
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ class FortifyServiceProvider extends ServiceProvider
|
|||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(LoginResponse::class, RoleAwareLoginResponse::class);
|
||||
$this->app->singleton(TwoFactorLoginResponse::class, RoleAwareLoginResponse::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
59
app/Support/LoginRedirect.php
Normal file
59
app/Support/LoginRedirect.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Gemeinsame Post-Login-Redirect-Logik für beide Login-Pfade (Volt-Login und
|
||||
* Fortify-LoginResponse), damit Rollen- und 403-Schutz nicht auseinanderlaufen.
|
||||
*/
|
||||
class LoginRedirect
|
||||
{
|
||||
/**
|
||||
* Rollengerechtes Home: Admin/Editor → Admin-Dashboard, Customer → Mein
|
||||
* Bereich, sonst Startseite.
|
||||
*/
|
||||
public static function homeFor(User $user): string
|
||||
{
|
||||
if ($user->canAccessAdmin()) {
|
||||
return route('dashboard', absolute: false);
|
||||
}
|
||||
|
||||
if ($user->canAccessCustomer()) {
|
||||
return route('me.dashboard', absolute: false);
|
||||
}
|
||||
|
||||
return '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt die intended-URL nur, wenn der User sie erreichen darf – sonst
|
||||
* das rollengerechte Home. Verhindert die 403-Sackgasse (z. B. ein Customer
|
||||
* mit intended=/admin/users).
|
||||
*/
|
||||
public static function safeTarget(User $user, ?string $intended, string $default): string
|
||||
{
|
||||
$intended = $intended ?: $default;
|
||||
$path = parse_url($intended, PHP_URL_PATH) ?: '/';
|
||||
|
||||
if (! $user->canAccessAdmin() && self::isAdminOnlyPath($path)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $intended;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Admin-Pfade: alles unter /admin außer dem Kundenbereich /admin/me
|
||||
* sowie das Admin-Dashboard /dashboard.
|
||||
*/
|
||||
public static function isAdminOnlyPath(string $path): bool
|
||||
{
|
||||
if ($path === '/dashboard' || str_starts_with($path, '/dashboard/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_starts_with($path, '/admin') && ! str_starts_with($path, '/admin/me');
|
||||
}
|
||||
}
|
||||
|
|
@ -77,7 +77,18 @@ Aus einer gezielten Auth-Prüfung umgesetzt:
|
|||
- **ContactAccess mutiert keine deaktivierten Bestands-Accounts** mehr: der `is_active`-Check läuft vor jeder Firmenzuordnung/jedem Linkversand.
|
||||
- **Magic-Link-Verbrauch ist atomar** (konditionales `UPDATE … whereNull(consumed_at)`): Single-Use auch bei parallelen Requests garantiert.
|
||||
|
||||
**Offen (Empfehlung):** Statt nur Honeypot + Rate-Limit beim Pressekontakt-Zugang ein echtes Captcha (Cloudflare Turnstile / hCaptcha) – benötigt Provider-Entscheidung, Keys und ein Paket. Bewusst noch nicht umgesetzt.
|
||||
**Captcha (dokumentiert, vorerst NICHT eingebaut):** Beim Pressekontakt-Zugang greifen aktuell Honeypot + Rate-Limit. Ein echtes Captcha (Cloudflare Turnstile / hCaptcha) ist sinnvoll, wird aber erst eingebaut, falls im Livebetrieb zu viel Missbrauch auftritt (Entscheidung 16.06.) – braucht Provider-Entscheidung, Keys und ein Paket.
|
||||
|
||||
## 6b. 2FA-Bypass behoben & Login-Pfade konsolidiert (Review 16.06., Teil 2)
|
||||
|
||||
**Befund:** Der sichtbare Volt-Login machte direkt `Auth::attempt()` und umging damit Fortifys 2FA-Pipeline (`RedirectIfTwoFactorAuthenticatable`) – für Accounts mit aktivem 2FA faktisch ein **2FA-Bypass**. Zusätzlich existierte der Fortify-POST `/login` parallel mit schwächeren Post-Login-Regeln (kein `is_active`-Block, `resolveTarget` neutralisierte nur `/` und `/dashboard`).
|
||||
|
||||
**Fix (Volt-nativ):**
|
||||
- Der Volt-Login prüft die Zugangsdaten jetzt OHNE sofortiges Einloggen; bei aktivem 2FA (`hasEnabledTwoFactorAuthentication()`) wird der Session-Vertrag `login.id`/`login.remember` gesetzt und auf eine neue **Volt-2FA-Challenge-Seite** (`/two-factor-challenge`) geleitet. Diese postet an Fortifys bestehenden `two-factor-challenge`-Controller (TOTP + Recovery-Code).
|
||||
- Gemeinsame Post-Login-Logik in `App\Support\LoginRedirect` (rollengerechtes Home + 403-sicherer intended-Redirect), genutzt von Volt-Login UND der Fortify-Response.
|
||||
- `RoleAwareLoginResponse` implementiert jetzt **beide** Contracts (`LoginResponse` **und** `TwoFactorLoginResponse`) und erzwingt einheitlich: unverifiziert → Notice, verifiziert-inaktiv → Logout+Fehler, sonst 403-sicherer Redirect. Damit ist auch der direkte Fortify-POST-Pfad gehärtet.
|
||||
|
||||
**Hinweis:** `fortify.views => false` bleibt; die Challenge wird vom Volt-Frontend bereitgestellt, die Verifizierung übernimmt Fortify.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
use App\Mail\MagicLoginLink;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Support\LoginRedirect;
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -36,7 +38,12 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
|
||||
// Zugangsdaten prüfen, OHNE schon einzuloggen – sonst würde die
|
||||
// Fortify-2FA-Pipeline umgangen (2FA-Bypass). Legacy-User ohne Passwort
|
||||
// (password = null) scheitern hier korrekt und nutzen Magic-Link/Reset.
|
||||
$user = User::query()->where('email', $this->email)->first();
|
||||
|
||||
if (! $user || ! $user->password || ! Hash::check($this->password, $user->password)) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
|
|
@ -44,21 +51,33 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
]);
|
||||
}
|
||||
|
||||
$authenticatedUser = Auth::user();
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
|
||||
// Unverifizierte Selbst-Registrierer (Konto angelegt, Mail noch nicht
|
||||
// bestätigt) gehören auf die Notice-Seite, nicht in die Panel-Logik.
|
||||
if ($authenticatedUser && ! $authenticatedUser->hasVerifiedEmail()) {
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
// 2FA aktiv: nicht einloggen, sondern in Fortifys Challenge übergeben
|
||||
// (Session-Vertrag login.id/login.remember wie RedirectIfTwoFactorAuthenticatable).
|
||||
if ($user->hasEnabledTwoFactorAuthentication()) {
|
||||
Session::put([
|
||||
'login.id' => $user->getKey(),
|
||||
'login.remember' => $this->remember,
|
||||
]);
|
||||
|
||||
$this->redirect(route('two-factor.challenge'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Auth::login($user, $this->remember);
|
||||
|
||||
// Unverifizierte Selbst-Registrierer → Notice-Seite.
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
Session::regenerate();
|
||||
$this->redirect(route('verification.notice', absolute: false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Verifiziert, aber deaktiviert: Login zentral blockieren (analog zum
|
||||
// Magic-Link-Consume), damit auch nur-auth/verified-Routen sicher sind.
|
||||
if ($authenticatedUser && ! $authenticatedUser->is_active) {
|
||||
// Verifiziert, aber deaktiviert: Login zentral blockieren.
|
||||
if (! $user->is_active) {
|
||||
Auth::logout();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
|
|
@ -66,26 +85,20 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
]);
|
||||
}
|
||||
|
||||
$authenticatedUser?->update([
|
||||
$user->update([
|
||||
'last_login_at' => now(),
|
||||
'last_login_ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
Session::regenerate();
|
||||
|
||||
// Rollen-basierter Default-Redirect:
|
||||
// Admin/Editor → /dashboard, Customer → /admin/me.
|
||||
// Ohne navigate:true, weil das Portal ein anderes Vite-Bundle nutzt
|
||||
// (build/portal mit FluxUI) als das Hub-Auth-Layout (build/web).
|
||||
// SPA-Navigation kann den Bundle-Wechsel nicht handhaben.
|
||||
$defaultRoute = $authenticatedUser?->canAccessAdmin()
|
||||
? route('dashboard', absolute: false)
|
||||
: ($authenticatedUser?->canAccessCustomer()
|
||||
? route('me.dashboard', absolute: false)
|
||||
: '/');
|
||||
|
||||
$this->redirect($this->safeRedirectTarget($authenticatedUser, $defaultRoute));
|
||||
// Rollengerechter, 403-sicherer Redirect. Ohne navigate:true, weil das
|
||||
// Portal ein anderes Vite-Bundle nutzt als das Hub-Auth-Layout.
|
||||
$this->redirect(LoginRedirect::safeTarget(
|
||||
$user,
|
||||
Session::pull('url.intended'),
|
||||
LoginRedirect::homeFor($user),
|
||||
));
|
||||
}
|
||||
|
||||
public function sendMagicLink(): void
|
||||
|
|
@ -167,39 +180,6 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt die intended-URL nur, wenn der User sie auch erreichen darf –
|
||||
* sonst Default. Verhindert, dass ein Customer mit intended=/admin/users
|
||||
* nach dem Login in der 403-Sackgasse landet.
|
||||
*/
|
||||
protected function safeRedirectTarget(?User $user, string $default): string
|
||||
{
|
||||
// Wie Laravels intended(): die gemerkte URL aus der Session ziehen und
|
||||
// entfernen. (In Livewire ist redirect() der Livewire-Redirector ohne
|
||||
// getTargetUrl(), daher direkt über die Session.)
|
||||
$intended = (string) session()->pull('url.intended', $default);
|
||||
$path = parse_url($intended, PHP_URL_PATH) ?: '/';
|
||||
|
||||
if ($user && ! $user->canAccessAdmin() && $this->isAdminOnlyPath($path)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $intended;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Admin-Pfade: alles unter /admin außer dem Kundenbereich /admin/me
|
||||
* sowie das Admin-Dashboard /dashboard.
|
||||
*/
|
||||
protected function isAdminOnlyPath(string $path): bool
|
||||
{
|
||||
if ($path === '/dashboard' || str_starts_with($path, '/dashboard/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_starts_with($path, '/admin') && ! str_starts_with($path, '/admin/me');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication rate limiting throttle key.
|
||||
*/
|
||||
|
|
|
|||
79
resources/views/livewire/auth/two-factor-challenge.blade.php
Normal file
79
resources/views/livewire/auth/two-factor-challenge.blade.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Zwei-Faktor-Bestätigung', 'eyebrow' => 'Sicherheitsprüfung', 'showFromBanner' => false])] class extends Component {
|
||||
public function mount(): void
|
||||
{
|
||||
// Ohne laufende Challenge (login.id aus dem ersten Schritt) hat die Seite
|
||||
// keinen Kontext – zurück zum Login.
|
||||
if (! session()->has('login.id')) {
|
||||
$this->redirect(route('login'), navigate: false);
|
||||
}
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div x-data="{ recovery: false }">
|
||||
@if ($errors->any())
|
||||
<div class="field-status mb-4" role="alert">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="text-[13.5px] text-ink-2 leading-[1.65] !-mt-2 mb-6">
|
||||
<span x-show="! recovery">
|
||||
Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.
|
||||
</span>
|
||||
<span x-show="recovery" x-cloak>
|
||||
Geben Sie einen Ihrer Wiederherstellungs-Codes ein.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{{-- Echter POST an Fortifys 2FA-Challenge-Controller (verifiziert gegen die
|
||||
login.id aus der Session und loggt bei Erfolg ein). --}}
|
||||
<form method="POST" action="/two-factor-challenge" class="space-y-[18px]">
|
||||
@csrf
|
||||
|
||||
<div x-show="! recovery">
|
||||
<label class="field-label" for="code">Authentifizierungs-Code</label>
|
||||
<input
|
||||
id="code"
|
||||
name="code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
class="field-input"
|
||||
placeholder="123456"
|
||||
x-bind:disabled="recovery"
|
||||
x-ref="code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div x-show="recovery" x-cloak>
|
||||
<label class="field-label" for="recovery_code">Wiederherstellungs-Code</label>
|
||||
<input
|
||||
id="recovery_code"
|
||||
name="recovery_code"
|
||||
type="text"
|
||||
autocomplete="one-time-code"
|
||||
class="field-input"
|
||||
placeholder="xxxxxxxx-xxxxxxxx"
|
||||
x-bind:disabled="! recovery"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-btn-primary !mt-[18px]">
|
||||
Bestätigen
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="auth-btn-outline !mt-0"
|
||||
x-on:click="recovery = ! recovery; $nextTick(() => (recovery ? document.getElementById('recovery_code') : $refs.code)?.focus())"
|
||||
>
|
||||
<span x-show="! recovery">Wiederherstellungs-Code verwenden</span>
|
||||
<span x-show="recovery" x-cloak>Code aus App verwenden</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -55,6 +55,12 @@ Route::group(['middleware' => config('fortify.middleware', ['web'])], function (
|
|||
})->middleware(['auth:'.config('fortify.guard'), 'throttle:6,1'])
|
||||
->name('verification.send');
|
||||
|
||||
// 2FA-Challenge-Seite (Volt-Frontend für Fortifys two-factor-challenge);
|
||||
// der eigentliche POST geht an Fortifys Controller.
|
||||
Volt::route('/two-factor-challenge', 'auth.two-factor-challenge')
|
||||
->middleware(['guest:'.config('fortify.guard')])
|
||||
->name('two-factor.challenge');
|
||||
|
||||
// Passwort bestätigen mit Livewire
|
||||
Volt::route('/confirm-password', 'auth.confirm-password')
|
||||
->middleware(['auth:'.config('fortify.guard')])
|
||||
|
|
|
|||
79
tests/Feature/Auth/TwoFactorLoginTest.php
Normal file
79
tests/Feature/Auth/TwoFactorLoginTest.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Tests\TestCase;
|
||||
|
||||
function enableTwoFactor(User $user): string
|
||||
{
|
||||
$secret = app(Google2FA::class)->generateSecretKey();
|
||||
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => encrypt($secret),
|
||||
'two_factor_confirmed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
test('a user with two-factor enabled is handed to the challenge, not logged in', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
enableTwoFactor($user);
|
||||
|
||||
Volt::test('auth.login')
|
||||
->set('email', $user->email)
|
||||
->set('password', 'password')
|
||||
->call('login')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('two-factor.challenge'));
|
||||
|
||||
$this->assertGuest();
|
||||
expect(session('login.id'))->toBe($user->id);
|
||||
});
|
||||
|
||||
test('the challenge page redirects to login without a pending challenge', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->get(route('two-factor.challenge'))->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('a valid two-factor code completes login with a role-aware redirect', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
$secret = enableTwoFactor($customer);
|
||||
|
||||
$otp = app(Google2FA::class)->getCurrentOtp($secret);
|
||||
|
||||
$this->withSession(['login.id' => $customer->id, 'login.remember' => false])
|
||||
->post('/two-factor-challenge', ['code' => $otp])
|
||||
->assertRedirect(route('me.dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticatedAs($customer);
|
||||
});
|
||||
|
||||
test('the fortify login post blocks inactive but verified users', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create(['is_active' => false]);
|
||||
|
||||
$this->post('/login', ['email' => $user->email, 'password' => 'password'])
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('the fortify login post keeps a customer out of the admin area on stale intended', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$this->withSession(['url.intended' => url('/admin/users')])
|
||||
->post('/login', ['email' => $customer->email, 'password' => 'password'])
|
||||
->assertRedirect(route('me.dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticatedAs($customer);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue