41 KiB
Account Recovery Implementation Plan (Phase 1)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add account recovery to Clusev's custom auth — 2FA backup codes, a forgot-password flow, and a CLI lockout fallback.
Architecture: Recovery codes live encrypted on the users row (encrypted:array), generated on 2FA confirm, usable at the existing TOTP challenge and as the forgot-password proof. Forgot-password is 2FA-code-gated (email link only when SMTP is set). A clusev:reset-admin command is the shell last-resort.
Tech Stack: Laravel 13, Livewire 3, pragmarx/google2fa-qrcode (existing TOTP), Pest/PHPUnit. All tooling in the app container (R8). UI copy bilingual via lang/{de,en}/auth.php (R16). Password policy reused: Password::min(12)->mixedCase()->numbers().
Conventions:
- Tests:
docker compose exec -T app php artisan test --filter=<Name> - Migrate (test DB auto): tests use
RefreshDatabase; dev:docker compose exec -T app php artisan migrate - Pint:
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty - Commits: Conventional Commits + the Co-Authored-By trailer.
Task 1: Recovery-code storage + User methods
Files:
-
Create:
database/migrations/2026_06_14_000001_add_two_factor_recovery_codes_to_users_table.php -
Modify:
app/Models/User.php -
Test:
tests/Feature/UserRecoveryCodesTest.php -
Step 1: Migration
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->text('two_factor_recovery_codes')->nullable()->after('two_factor_secret');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('two_factor_recovery_codes');
});
}
};
- Step 2: Write the failing test
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserRecoveryCodesTest extends TestCase
{
use RefreshDatabase;
public function test_replace_generates_eight_unique_codes_and_persists(): void
{
$user = User::factory()->create();
$codes = $user->replaceRecoveryCodes();
$this->assertCount(8, $codes);
$this->assertCount(8, array_unique($codes));
$this->assertSame($codes, $user->fresh()->recoveryCodes());
$this->assertTrue($user->fresh()->hasRecoveryCodes());
}
public function test_use_recovery_code_consumes_once(): void
{
$user = User::factory()->create();
$codes = $user->replaceRecoveryCodes();
$one = $codes[0];
$this->assertTrue($user->useRecoveryCode(' '.$one.' ')); // trims
$this->assertFalse($user->fresh()->useRecoveryCode($one)); // already consumed
$this->assertFalse($user->fresh()->useRecoveryCode('not-a-code'));
$this->assertCount(7, $user->fresh()->recoveryCodes());
}
}
- Step 3: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=UserRecoveryCodesTest
Expected: FAIL (replaceRecoveryCodes undefined).
- Step 4: Add cast + methods to
app/Models/User.php
Add 'two_factor_recovery_codes' => 'encrypted:array' to casts(). Add two_factor_recovery_codes to the #[Hidden(...)] attribute list. Add use Illuminate\Support\Str; and these methods:
/** @return array<int, string> The 8 freshly generated one-time codes (also persisted). */
public function replaceRecoveryCodes(): array
{
$codes = array_map(
fn () => Str::random(10).'-'.Str::random(10),
range(1, 8),
);
$this->forceFill(['two_factor_recovery_codes' => $codes])->save();
return $codes;
}
/** @return array<int, string> */
public function recoveryCodes(): array
{
return $this->two_factor_recovery_codes ?? [];
}
public function hasRecoveryCodes(): bool
{
return count($this->recoveryCodes()) > 0;
}
/** Consume one recovery code (one-time). Returns true if it was valid + removed. */
public function useRecoveryCode(string $code): bool
{
$code = trim($code);
$codes = $this->recoveryCodes();
if (! in_array($code, $codes, true)) {
return false;
}
$this->forceFill([
'two_factor_recovery_codes' => array_values(array_filter($codes, fn ($c) => $c !== $code)),
])->save();
return true;
}
- Step 5: Run test to verify it passes
Run: docker compose exec -T app php artisan test --filter=UserRecoveryCodesTest
Expected: PASS (2 tests).
- Step 6: Migrate dev DB + commit
docker compose exec -T app php artisan migrate
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
git add database/migrations/2026_06_14_000001_add_two_factor_recovery_codes_to_users_table.php app/Models/User.php tests/Feature/UserRecoveryCodesTest.php
git commit -m "feat(auth): store 2FA recovery codes (encrypted) on users"
Task 2: Challenge accepts a backup code
Files:
-
Modify:
app/Livewire/Auth/TwoFactorChallenge.php -
Modify:
resources/views/livewire/auth/two-factor-challenge.blade.php -
Modify:
lang/de/auth.php,lang/en/auth.php -
Test:
tests/Feature/TwoFactorChallengeRecoveryTest.php -
Step 1: Write the failing test
<?php
namespace Tests\Feature;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class TwoFactorChallengeRecoveryTest extends TestCase
{
use RefreshDatabase;
private function enrolledUser(): User
{
return User::factory()->create([
'two_factor_secret' => (new \PragmaRX\Google2FAQRCode\Google2FA)->generateSecretKey(),
'two_factor_confirmed_at' => now(),
]);
}
public function test_recovery_code_logs_in_and_is_consumed(): void
{
$user = $this->enrolledUser();
$code = $user->replaceRecoveryCodes()[0];
session()->put('2fa.user', $user->id);
Livewire::test(TwoFactorChallenge::class)
->set('code', $code)
->call('verify');
$this->assertAuthenticatedAs($user);
$this->assertFalse($user->fresh()->useRecoveryCode($code)); // consumed
}
public function test_invalid_code_fails(): void
{
$user = $this->enrolledUser();
$user->replaceRecoveryCodes();
session()->put('2fa.user', $user->id);
Livewire::test(TwoFactorChallenge::class)
->set('code', '000000')
->call('verify')
->assertHasErrors('code');
$this->assertGuest();
}
}
- Step 2: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=TwoFactorChallengeRecoveryTest
Expected: FAIL (recovery code rejected → not authenticated).
- Step 3: Accept a recovery code in
verify()
In app/Livewire/Auth/TwoFactorChallenge.php, replace the validity check block:
$valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code));
if (! $valid) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
}
with (TOTP first, then a one-time recovery code):
$valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code))
|| $user->useRecoveryCode($this->code);
if (! $valid) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
}
- Step 4: Run test to verify it passes
Run: docker compose exec -T app php artisan test --filter=TwoFactorChallengeRecoveryTest
Expected: PASS (2 tests).
- Step 5: Add the challenge hint (view + lang)
lang/de/auth.php add: 'challenge_recovery_hint' => 'Authenticator verloren? Gib einen deiner Backup-Codes ein.',
lang/en/auth.php add: 'challenge_recovery_hint' => 'Lost your authenticator? Enter one of your backup codes.',
In resources/views/livewire/auth/two-factor-challenge.blade.php, after the code input's @error(...) block, add:
<p class="mt-1.5 font-mono text-[11px] text-ink-4">{{ __('auth.challenge_recovery_hint') }}</p>
- Step 6: Pint + commit
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
git add app/Livewire/Auth/TwoFactorChallenge.php resources/views/livewire/auth/two-factor-challenge.blade.php lang/de/auth.php lang/en/auth.php tests/Feature/TwoFactorChallengeRecoveryTest.php
git commit -m "feat(auth): accept a 2FA backup code at the login challenge"
Task 3: Recovery-codes view (show once + download + regenerate) + setup hook
Files:
-
Create:
app/Livewire/Auth/RecoveryCodes.php -
Create:
resources/views/livewire/auth/recovery-codes.blade.php -
Modify:
app/Livewire/Auth/TwoFactorSetup.php,routes/web.php,lang/de/auth.php,lang/en/auth.php -
Modify:
resources/views/livewire/settings/index.blade.php(add a link — see step 6) -
Test:
tests/Feature/RecoveryCodesViewTest.php -
Step 1: Write the failing test
<?php
namespace Tests\Feature;
use App\Livewire\Auth\RecoveryCodes;
use App\Livewire\Auth\TwoFactorSetup;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;
class RecoveryCodesViewTest extends TestCase
{
use RefreshDatabase;
public function test_confirming_2fa_generates_codes_and_redirects_to_recovery(): void
{
$user = User::factory()->create(['two_factor_secret' => null, 'two_factor_confirmed_at' => null]);
$this->actingAs($user);
$secret = (new Google2FA)->generateSecretKey();
$code = (new Google2FA)->getCurrentOtp($secret);
Livewire::test(TwoFactorSetup::class)
->set('secret', $secret)
->set('code', $code)
->call('confirm')
->assertRedirect(route('two-factor.recovery'));
$this->assertCount(8, $user->fresh()->recoveryCodes());
}
public function test_regenerate_replaces_codes(): void
{
$user = User::factory()->create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]);
$old = $user->replaceRecoveryCodes();
$this->actingAs($user);
Livewire::test(RecoveryCodes::class)->call('regenerate');
$this->assertNotSame($old, $user->fresh()->recoveryCodes());
$this->assertCount(8, $user->fresh()->recoveryCodes());
}
public function test_download_returns_codes_as_text(): void
{
$user = User::factory()->create();
$user->replaceRecoveryCodes();
$this->actingAs($user);
$res = $this->get(route('two-factor.recovery.download'));
$res->assertOk();
$res->assertHeader('content-type', 'text/plain; charset=UTF-8');
$this->assertStringContainsString($user->recoveryCodes()[0], $res->streamedContent());
}
}
- Step 2: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=RecoveryCodesViewTest
Expected: FAIL (route/component missing).
- Step 3: Create
app/Livewire/Auth/RecoveryCodes.php
<?php
namespace App\Livewire\Auth;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.auth')]
class RecoveryCodes extends Component
{
/** Regenerate the current user's backup codes (invalidates the old set). */
public function regenerate(): void
{
Auth::user()->replaceRecoveryCodes();
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name ?? 'system',
'action' => 'two_factor.recovery_regenerate',
'target' => Auth::user()->email,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('auth.recovery_regenerated'));
}
public function render()
{
return view('livewire.auth.recovery-codes', [
'codes' => Auth::user()->recoveryCodes(),
])->title(__('auth.title_recovery'));
}
}
- Step 4: View
resources/views/livewire/auth/recovery-codes.blade.php
<div class="space-y-5">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('auth.two_factor') }}</p>
<h1 class="mt-1 font-display text-2xl font-semibold text-ink">{{ __('auth.recovery_heading') }}</h1>
<p class="mt-1.5 text-sm text-ink-2">{{ __('auth.recovery_subtitle') }}</p>
</div>
<div class="flex items-start gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-4 py-3">
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p class="text-sm text-ink-2">{{ __('auth.recovery_warning') }}</p>
</div>
<div class="grid grid-cols-2 gap-2 rounded-md border border-line bg-inset p-4">
@foreach ($codes as $c)
<span class="font-mono text-sm text-ink">{{ $c }}</span>
@endforeach
</div>
<div class="flex flex-wrap items-center gap-2">
<x-btn href="{{ route('two-factor.recovery.download') }}" variant="accent">
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
</x-btn>
<x-btn variant="secondary" wire:click="regenerate" wire:confirm="{{ __('auth.recovery_regenerate_confirm') }}">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('auth.recovery_regenerate') }}
</x-btn>
<x-btn href="{{ route('dashboard') }}" variant="primary" class="ml-auto">{{ __('auth.recovery_done') }}</x-btn>
</div>
</div>
- Step 5: Hook setup + routes + download
In app/Livewire/Auth/TwoFactorSetup.php::confirm(), after the forceFill([... 'two_factor_confirmed_at' => now()])->save(); add Auth::user()->replaceRecoveryCodes(); and change the redirect to return $this->redirect(route('two-factor.recovery'), navigate: true);.
In routes/web.php, inside the auth group (before the EnsureSecurityOnboarded group, so a just-enrolled user can see codes before the dashboard gate — place beside two-factor.setup):
Route::get('/two-factor/recovery-codes', Auth\RecoveryCodes::class)->name('two-factor.recovery');
Route::get('/two-factor/recovery-codes/download', function () {
$codes = \Illuminate\Support\Facades\Auth::user()->recoveryCodes();
return response()->streamDownload(
fn () => print(implode("\n", $codes)."\n"),
'clusev-recovery-codes.txt',
['Content-Type' => 'text/plain; charset=UTF-8'],
);
})->name('two-factor.recovery.download');
(use App\Livewire\Auth; already imported.)
- Step 6: Settings link + lang
In resources/views/livewire/settings/index.blade.php, in the security/2FA section add (near the disable-2FA control):
<x-btn href="{{ route('two-factor.recovery') }}" variant="secondary">{{ __('auth.recovery_manage') }}</x-btn>
lang/de/auth.php add:
'title_recovery' => 'Backup-Codes — Clusev',
'recovery_heading' => 'Backup-Codes',
'recovery_subtitle' => 'Mit diesen Einmal-Codes meldest du dich an, wenn du keinen Authenticator hast.',
'recovery_warning' => 'Speichere diese Codes jetzt sicher ab — jeder Code funktioniert genau einmal und sie werden nicht erneut im Klartext gezeigt, wenn du sie nicht herunterlädst.',
'recovery_download' => 'Als Datei herunterladen',
'recovery_regenerate' => 'Neu erzeugen',
'recovery_regenerate_confirm' => 'Neue Codes erzeugen? Die alten werden ungültig.',
'recovery_regenerated' => 'Neue Backup-Codes erzeugt.',
'recovery_done' => 'Gespeichert — weiter',
'recovery_manage' => 'Backup-Codes verwalten',
lang/en/auth.php add:
'title_recovery' => 'Backup codes — Clusev',
'recovery_heading' => 'Backup codes',
'recovery_subtitle' => 'Use these one-time codes to sign in when you do not have your authenticator.',
'recovery_warning' => 'Save these codes somewhere safe now — each works exactly once and they are not shown again in plain text unless you download them.',
'recovery_download' => 'Download as file',
'recovery_regenerate' => 'Regenerate',
'recovery_regenerate_confirm' => 'Generate new codes? The old ones stop working.',
'recovery_regenerated' => 'New backup codes generated.',
'recovery_done' => 'Saved — continue',
'recovery_manage' => 'Manage backup codes',
- Step 7: Run tests, Pint, build, commit
Run: docker compose exec -T app php artisan test --filter=RecoveryCodesViewTest → PASS (3).
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
docker compose exec -T -u "1002:1002" app npm run build
git add app/Livewire/Auth/RecoveryCodes.php resources/views/livewire/auth/recovery-codes.blade.php app/Livewire/Auth/TwoFactorSetup.php routes/web.php resources/views/livewire/settings/index.blade.php lang/de/auth.php lang/en/auth.php tests/Feature/RecoveryCodesViewTest.php
git commit -m "feat(auth): show + download + regenerate 2FA backup codes"
Task 4: Forgot-password (2FA-code proof)
Files:
-
Create:
app/Livewire/Auth/ForgotPassword.php,resources/views/livewire/auth/forgot-password.blade.php -
Modify:
routes/web.php,resources/views/livewire/auth/login.blade.php,lang/de/auth.php,lang/en/auth.php -
Test:
tests/Feature/ForgotPasswordTest.php -
Step 1: Write the failing test
<?php
namespace Tests\Feature;
use App\Livewire\Auth\ForgotPassword;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;
class ForgotPasswordTest extends TestCase
{
use RefreshDatabase;
private function user(): User
{
return User::factory()->create([
'email' => 'admin@clusev.local',
'two_factor_secret' => (new Google2FA)->generateSecretKey(),
'two_factor_confirmed_at' => now(),
]);
}
public function test_totp_resets_password(): void
{
$user = $this->user();
$otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret);
Livewire::test(ForgotPassword::class)
->set('email', $user->email)->set('code', $otp)
->set('password', 'NewPass1234')->set('password_confirmation', 'NewPass1234')
->call('reset')->assertHasNoErrors();
$this->assertTrue(Hash::check('NewPass1234', $user->fresh()->password));
}
public function test_backup_code_resets_and_is_consumed(): void
{
$user = $this->user();
$code = $user->replaceRecoveryCodes()[0];
Livewire::test(ForgotPassword::class)
->set('email', $user->email)->set('code', $code)
->set('password', 'NewPass1234')->set('password_confirmation', 'NewPass1234')
->call('reset')->assertHasNoErrors();
$this->assertTrue(Hash::check('NewPass1234', $user->fresh()->password));
$this->assertFalse($user->fresh()->useRecoveryCode($code));
}
public function test_wrong_code_does_not_reset(): void
{
$user = $this->user();
$old = $user->password;
Livewire::test(ForgotPassword::class)
->set('email', $user->email)->set('code', '000000')
->set('password', 'NewPass1234')->set('password_confirmation', 'NewPass1234')
->call('reset')->assertHasErrors('code');
$this->assertSame($old, $user->fresh()->password);
}
public function test_weak_password_rejected(): void
{
$user = $this->user();
$otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret);
Livewire::test(ForgotPassword::class)
->set('email', $user->email)->set('code', $otp)
->set('password', 'short')->set('password_confirmation', 'short')
->call('reset')->assertHasErrors('password');
}
}
- Step 2: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=ForgotPasswordTest
Expected: FAIL (component missing).
- Step 3: Create
app/Livewire/Auth/ForgotPassword.php
<?php
namespace App\Livewire\Auth;
use App\Models\AuditEvent;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')]
class ForgotPassword extends Component
{
public string $email = '';
public string $code = '';
public string $password = '';
public string $password_confirmation = '';
/** True when a real mailer is configured (then the email-link option is offered too). */
public function mailEnabled(): bool
{
return ! in_array(config('mail.default'), ['log', 'array', null], true);
}
/** Reset the password by proving 2FA possession (TOTP or a backup code). */
public function reset()
{
$this->validate([
'email' => ['required', 'email'],
'code' => ['required', 'string'],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
]);
$key = 'forgot:'.md5(strtolower($this->email).'|'.request()->ip());
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
$user = User::where('email', $this->email)->first();
$clean = preg_replace('/\s+/', '', $this->code);
$ok = $user
&& $user->hasTwoFactorEnabled()
&& ((new Google2FA)->verifyKey($user->two_factor_secret, $clean) || $user->useRecoveryCode($this->code));
if (! $ok) {
RateLimiter::hit($key, 60);
// Generic message — never reveal whether the email exists.
throw ValidationException::withMessages(['code' => __('auth.reset_invalid')]);
}
RateLimiter::clear($key);
$user->forceFill(['password' => Hash::make($this->password), 'must_change_password' => false])->save();
AuditEvent::create([
'user_id' => $user->id,
'actor' => $user->name ?? 'system',
'action' => 'password.reset',
'target' => $user->email,
'ip' => request()->ip(),
]);
session()->flash('status', __('auth.reset_done'));
return $this->redirect(route('login'), navigate: true);
}
public function render()
{
return view('livewire.auth.forgot-password')->title(__('auth.title_forgot'));
}
}
- Step 4: View
resources/views/livewire/auth/forgot-password.blade.php
Mirror the login view's field classes ($label/$field/$err can be redefined inline as in login). Minimal structure:
@php
$label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$field = 'h-11 w-full rounded-md border border-line bg-inset px-3 text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp
<div class="space-y-5">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('auth.security') }}</p>
<h1 class="mt-1 font-display text-2xl font-semibold text-ink">{{ __('auth.forgot_heading') }}</h1>
<p class="mt-1.5 text-sm text-ink-2">{{ __('auth.forgot_subtitle') }}</p>
</div>
<form wire:submit="reset" class="space-y-4">
<div>
<label for="email" class="{{ $label }}">{{ __('auth.email') }}</label>
<input wire:model="email" id="email" type="email" autocomplete="username" class="{{ $field }}" placeholder="admin@clusev.local" />
@error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label for="code" class="{{ $label }}">{{ __('auth.forgot_code') }}</label>
<input wire:model="code" id="code" type="text" inputmode="text" autocomplete="one-time-code" class="{{ $field }} font-mono" placeholder="123456 / xxxxx-xxxxx" />
@error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
<p class="mt-1.5 font-mono text-[11px] text-ink-4">{{ __('auth.forgot_code_hint') }}</p>
</div>
<div>
<label for="password" class="{{ $label }}">{{ __('auth.new_password') }}</label>
<input wire:model="password" id="password" type="password" autocomplete="new-password" class="{{ $field }}" placeholder="••••••••" />
@error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label for="password_confirmation" class="{{ $label }}">{{ __('auth.confirm_new_password') }}</label>
<input wire:model="password_confirmation" id="password_confirmation" type="password" autocomplete="new-password" class="{{ $field }}" placeholder="••••••••" />
</div>
<x-btn variant="primary" size="lg" type="submit" class="w-full">{{ __('auth.forgot_submit') }}</x-btn>
</form>
@if ($this->mailEnabled())
<p class="text-center font-mono text-[11px] text-ink-4">{{ __('auth.forgot_email_alt') }}</p>
@endif
<a href="{{ route('login') }}" wire:navigate class="block text-center font-mono text-[11px] text-ink-3 hover:text-ink-2">{{ __('auth.back_to_login') }}</a>
</div>
- Step 5: Route + login link
routes/web.php guest group, add:
Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request');
resources/views/livewire/auth/login.blade.php — after the password field's @error block (before the remember-me label), add:
<a href="{{ route('password.request') }}" wire:navigate class="mt-1.5 block font-mono text-[11px] text-ink-3 hover:text-accent-text">{{ __('auth.forgot_link') }}</a>
- Step 6: Lang keys
lang/de/auth.php add: 'title_forgot' => 'Passwort vergessen — Clusev', 'forgot_link' => 'Passwort vergessen?', 'forgot_heading' => 'Passwort zurücksetzen', 'forgot_subtitle' => 'Setze dein Passwort mit deinem 2FA-Code oder einem Backup-Code zurück.', 'forgot_code' => '2FA-Code oder Backup-Code', 'forgot_code_hint' => '6-stelliger Authenticator-Code oder einer deiner Backup-Codes.', 'forgot_submit' => 'Passwort zurücksetzen', 'forgot_email_alt' => 'E-Mail-Reset ist konfiguriert — Link auf Anfrage verfügbar.', 'reset_invalid' => 'E-Mail oder Code ungültig.', 'reset_done' => 'Passwort geändert. Bitte neu anmelden.'
lang/en/auth.php add: 'title_forgot' => 'Forgot password — Clusev', 'forgot_link' => 'Forgot password?', 'forgot_heading' => 'Reset password', 'forgot_subtitle' => 'Reset your password with your 2FA code or a backup code.', 'forgot_code' => '2FA code or backup code', 'forgot_code_hint' => 'Your 6-digit authenticator code or one of your backup codes.', 'forgot_submit' => 'Reset password', 'forgot_email_alt' => 'Email reset is configured — link available on request.', 'reset_invalid' => 'Invalid email or code.', 'reset_done' => 'Password changed. Please sign in again.'
- Step 7: Run tests, Pint, build, commit
Run: docker compose exec -T app php artisan test --filter=ForgotPasswordTest → PASS (4).
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
docker compose exec -T -u "1002:1002" app npm run build
git add app/Livewire/Auth/ForgotPassword.php resources/views/livewire/auth/forgot-password.blade.php routes/web.php resources/views/livewire/auth/login.blade.php lang/de/auth.php lang/en/auth.php tests/Feature/ForgotPasswordTest.php
git commit -m "feat(auth): forgot-password reset via 2FA or backup code"
Task 4b: Email reset link (gated on SMTP)
Builds the secondary email-link path so it activates the moment a real mailer is configured. With MAIL_MAILER=log it stays hidden in the UI; tested with Mail::fake + a mail.default override. User already uses Laravel's CanResetPassword trait, and password_reset_tokens exists.
Files:
-
Create:
app/Livewire/Auth/ResetPassword.php,resources/views/livewire/auth/reset-password.blade.php -
Modify:
app/Livewire/Auth/ForgotPassword.php(addsendResetLink()),resources/views/livewire/auth/forgot-password.blade.php(conditional button),routes/web.php,lang/de/auth.php,lang/en/auth.php -
Test:
tests/Feature/EmailResetTest.php -
Step 1: Write the failing test
<?php
namespace Tests\Feature;
use App\Livewire\Auth\ForgotPassword;
use App\Livewire\Auth\ResetPassword;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use Livewire\Livewire;
use Tests\TestCase;
class EmailResetTest extends TestCase
{
use RefreshDatabase;
public function test_link_option_hidden_when_mailer_is_log(): void
{
config(['mail.default' => 'log']);
Livewire::test(ForgotPassword::class)->assertReturned(fn () => true); // smoke
$this->assertFalse((new ForgotPassword)->mailEnabled());
}
public function test_send_link_dispatches_notification_when_mail_configured(): void
{
config(['mail.default' => 'smtp']);
Notification::fake();
$user = User::factory()->create(['email' => 'admin@clusev.local']);
Livewire::test(ForgotPassword::class)
->set('email', 'admin@clusev.local')
->call('sendResetLink')
->assertHasNoErrors();
Notification::assertSentTo($user, ResetPasswordNotification::class);
}
public function test_reset_password_with_token_sets_new_password(): void
{
$user = User::factory()->create();
$token = Password::createToken($user);
Livewire::test(ResetPassword::class, ['token' => $token, 'email' => $user->email])
->set('password', 'NewPass1234')->set('password_confirmation', 'NewPass1234')
->call('reset')->assertHasNoErrors();
$this->assertTrue(Hash::check('NewPass1234', $user->fresh()->password));
}
}
- Step 2: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=EmailResetTest
Expected: FAIL (sendResetLink/ResetPassword missing).
- Step 3: Add
sendResetLink()toForgotPassword
/** Secondary path: email a reset link (only meaningful when a real mailer is set). */
public function sendResetLink(): void
{
if (! $this->mailEnabled()) {
return;
}
$this->validate(['email' => ['required', 'email']]);
\Illuminate\Support\Facades\Password::sendResetLink(['email' => $this->email]);
// Generic — never reveal whether the email exists.
$this->dispatch('notify', message: __('auth.reset_link_sent'));
}
- Step 4: Create
app/Livewire/Auth/ResetPassword.php
<?php
namespace App\Livewire\Auth;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.auth')]
class ResetPassword extends Component
{
public string $token = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
public function mount(string $token): void
{
$this->token = $token;
$this->email = (string) request()->query('email', '');
}
public function reset()
{
$this->validate([
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', PasswordRule::min(12)->mixedCase()->numbers()],
]);
$status = Password::reset(
['email' => $this->email, 'password' => $this->password, 'password_confirmation' => $this->password_confirmation, 'token' => $this->token],
function ($user) {
$user->forceFill([
'password' => Hash::make($this->password),
'must_change_password' => false,
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
},
);
if ($status !== Password::PasswordReset) {
throw ValidationException::withMessages(['email' => __('auth.reset_token_invalid')]);
}
session()->flash('status', __('auth.reset_done'));
return $this->redirect(route('login'), navigate: true);
}
public function render()
{
return view('livewire.auth.reset-password')->title(__('auth.title_forgot'));
}
}
- Step 5: View + route + conditional button + lang
resources/views/livewire/auth/reset-password.blade.php: same field classes as forgot-password; fields email (readonly-ish), password, password_confirmation; wire:submit="reset"; submit auth.forgot_submit; back-to-login link.
routes/web.php guest group: Route::get('/reset-password/{token}', Auth\ResetPassword::class)->name('password.reset');
In forgot-password.blade.php, replace the @if ($this->mailEnabled()) notice with an actionable button:
@if ($this->mailEnabled())
<x-btn variant="secondary" class="w-full" wire:click="sendResetLink">{{ __('auth.forgot_send_email') }}</x-btn>
@endif
Lang — lang/de/auth.php: 'forgot_send_email' => 'Reset-Link per E-Mail senden', 'reset_link_sent' => 'Falls die E-Mail existiert, wurde ein Reset-Link versendet.', 'reset_token_invalid' => 'Reset-Link ungültig oder abgelaufen.'. lang/en/auth.php: 'forgot_send_email' => 'Email me a reset link', 'reset_link_sent' => 'If the email exists, a reset link has been sent.', 'reset_token_invalid' => 'Reset link is invalid or expired.'. (Remove the now-unused forgot_email_alt from Task 4 if present, or leave it unused.)
- Step 6: Run tests, Pint, build, commit
Run: docker compose exec -T app php artisan test --filter=EmailResetTest → PASS (3).
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
docker compose exec -T -u "1002:1002" app npm run build
git add app/Livewire/Auth/ResetPassword.php resources/views/livewire/auth/reset-password.blade.php app/Livewire/Auth/ForgotPassword.php resources/views/livewire/auth/forgot-password.blade.php routes/web.php lang/de/auth.php lang/en/auth.php tests/Feature/EmailResetTest.php
git commit -m "feat(auth): email reset-link path (gated on a configured mailer)"
Task 5: CLI lockout fallback clusev:reset-admin
Files:
-
Create:
app/Console/Commands/ResetAdmin.php -
Test:
tests/Feature/ResetAdminCommandTest.php -
Step 1: Write the failing test
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class ResetAdminCommandTest extends TestCase
{
use RefreshDatabase;
public function test_resets_password_and_disables_2fa(): void
{
$user = User::factory()->create([
'email' => 'admin@clusev.local',
'two_factor_secret' => 'x', 'two_factor_confirmed_at' => now(),
]);
$user->replaceRecoveryCodes();
$this->artisan('clusev:reset-admin', ['--email' => 'admin@clusev.local', '--password' => 'BrandNew1234', '--disable-2fa' => true])
->assertExitCode(0);
$user->refresh();
$this->assertTrue(Hash::check('BrandNew1234', $user->password));
$this->assertNull($user->two_factor_secret);
$this->assertNull($user->two_factor_confirmed_at);
$this->assertFalse($user->hasRecoveryCodes());
}
public function test_unknown_email_fails(): void
{
$this->artisan('clusev:reset-admin', ['--email' => 'nope@x.test'])->assertExitCode(1);
}
}
- Step 2: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=ResetAdminCommandTest
Expected: FAIL (command missing).
- Step 3: Create
app/Console/Commands/ResetAdmin.php
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class ResetAdmin extends Command
{
protected $signature = 'clusev:reset-admin
{--email= : E-Mail des Admin-Kontos (sonst der einzige Benutzer)}
{--password= : Neues Passwort (zufällig erzeugt, falls leer)}
{--disable-2fa : 2FA + Backup-Codes ebenfalls zurücksetzen}';
protected $description = 'Notfall-Reset: setzt das Admin-Passwort (und optional 2FA) per Shell zurück';
public function handle(): int
{
$email = (string) $this->option('email');
$user = $email !== ''
? User::where('email', $email)->first()
: (User::count() === 1 ? User::first() : null);
if (! $user) {
$this->error($email !== '' ? "Kein Benutzer mit E-Mail {$email}." : 'Kein eindeutiger Benutzer — bitte --email angeben.');
return self::FAILURE;
}
$password = (string) ($this->option('password') ?: Str::password(20));
$user->forceFill(['password' => Hash::make($password), 'must_change_password' => false]);
if ($this->option('disable-2fa')) {
$user->forceFill([
'two_factor_secret' => null,
'two_factor_confirmed_at' => null,
'two_factor_recovery_codes' => null,
]);
}
$user->save();
$this->info("Passwort für {$user->email} zurückgesetzt.");
if (! $this->option('password')) {
$this->warn("Einmal-Passwort (jetzt notieren): {$password}");
}
if ($this->option('disable-2fa')) {
$this->info('2FA + Backup-Codes entfernt — beim nächsten Login neu einrichten.');
}
return self::SUCCESS;
}
}
- Step 4: Run test to verify it passes
Run: docker compose exec -T app php artisan test --filter=ResetAdminCommandTest
Expected: PASS (2).
- Step 5: Pint + commit
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
git add app/Console/Commands/ResetAdmin.php tests/Feature/ResetAdminCommandTest.php
git commit -m "feat(auth): clusev:reset-admin CLI lockout fallback"
Task 6: Full verification (R12 + Codex)
- Step 1: Full suite + Pint
Run: docker compose exec -T app php artisan test (only the pre-existing ExampleTest may fail).
Run: docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --test
- Step 2: Build
Run: docker compose exec -T -u "1002:1002" app npm run build
- Step 3: R12 browser — DE + EN, 375 / 768 / 1280
Temp /__peek recipe (remove before commit). Flows: login shows "Passwort vergessen?" → forgot-password page renders + a wrong code shows the German error; 2FA setup → recovery-codes page shows 8 codes + download returns a .txt; settings → "Backup-Codes verwalten" link works. HTTP 200, 0 console, DOM scan (R17).
- Step 4: Codex review (R15)
Run: export PATH="$HOME/.npm-global/bin:$PATH"; codex review --base <pre-feature-sha> — fix + re-run until clean.
- Step 5: Update handoff + release
Note the feature in docs/session-handoff.md. Release the version bump per the session cadence (separate commit + tag + sanitized push) when the user approves.
Self-Review
Spec coverage:
- Data model (column + cast + 4 methods) → Task 1. ✓
- 2FA backup codes: generation (Task 3), challenge use (Task 2), show/download/regenerate (Task 3), settings link (Task 3). ✓
- Forgot-password 2FA-code primary → Task 4. ✓
- Email-link secondary (gated on
mail.default) → Task 4b (sendResetLink+Auth\ResetPassword+/reset-password/{token}), hidden in the UI whileMAIL_MAILER=log, tested viaNotification::fake+ amail.defaultoverride. ✓ - CLI fallback → Task 5. ✓
- i18n DE+EN → keys in Tasks 2-5. ✓
- Testing + R12 + Codex → Tasks 1-6. ✓
Placeholder scan: none — every step has concrete code.
Type consistency: replaceRecoveryCodes()/recoveryCodes()/useRecoveryCode()/hasRecoveryCodes() defined in Task 1, used in Tasks 2-5. Routes two-factor.recovery, two-factor.recovery.download, password.request consistent across tasks. mailEnabled() defined + used in Task 4.
Execution order: Task 1 → 2 → 3 → 4 → 4b → 5 → 6. Tasks 4 and 4b both touch ForgotPassword/its view/routes/auth.php; finish + commit 4 before starting 4b.