From 5bb6e57db454187ee191cf41ad6d79b104760873 Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 22 May 2026 18:57:10 +0000 Subject: [PATCH] Refine forced password change flow --- .../Settings/SecurityController.php | 27 ++++- app/Http/Middleware/RequirePasswordChange.php | 7 +- .../js/pages/auth/ForcePasswordChange.vue | 104 ++++++++++++++++++ routes/web/settings.php | 7 ++ tests/Feature/Auth/AuthenticationTest.php | 5 +- tests/Feature/Settings/SecurityTest.php | 60 +++++++++- 6 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 resources/js/pages/auth/ForcePasswordChange.vue diff --git a/app/Http/Controllers/Settings/SecurityController.php b/app/Http/Controllers/Settings/SecurityController.php index 053ac89..9efffbe 100644 --- a/app/Http/Controllers/Settings/SecurityController.php +++ b/app/Http/Controllers/Settings/SecurityController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Settings\PasswordUpdateRequest; use App\Http\Requests\Settings\TwoFactorAuthenticationRequest; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Routing\Controllers\HasMiddleware; use Illuminate\Routing\Controllers\Middleware; use Inertia\Inertia; @@ -28,9 +29,14 @@ class SecurityController extends Controller implements HasMiddleware /** * Show the user's security settings page. */ - public function edit(TwoFactorAuthenticationRequest $request): Response + public function edit(TwoFactorAuthenticationRequest $request): Response|RedirectResponse { $user = $request->user(); + + if ($user->must_change_password_on_next_login) { + return to_route('password.force-change.edit'); + } + $props = [ 'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(), 'mustChangePassword' => (bool) $user->must_change_password_on_next_login, @@ -47,18 +53,37 @@ class SecurityController extends Controller implements HasMiddleware return Inertia::render('settings/Security', $props); } + /** + * Show the dedicated forced password change page. + */ + public function editForcedPassword(Request $request): Response|RedirectResponse + { + if (! $request->user()->must_change_password_on_next_login) { + return to_route('dashboard'); + } + + return Inertia::render('auth/ForcePasswordChange', [ + 'status' => session('status'), + ]); + } + /** * Update the user's password. */ public function update(PasswordUpdateRequest $request): RedirectResponse { $user = $request->user(); + $mustChangePassword = (bool) $user->must_change_password_on_next_login; $user->update([ 'must_change_password_on_next_login' => false, 'password' => $request->password, ]); + if ($mustChangePassword) { + return redirect()->intended(route('dashboard')); + } + return back(); } } diff --git a/app/Http/Middleware/RequirePasswordChange.php b/app/Http/Middleware/RequirePasswordChange.php index 2bf7963..2edae2b 100644 --- a/app/Http/Middleware/RequirePasswordChange.php +++ b/app/Http/Middleware/RequirePasswordChange.php @@ -25,14 +25,19 @@ class RequirePasswordChange 'logout', 'password.confirm', 'password.confirm.*', + 'password.force-change.edit', 'security.edit', 'user-password.update', ])) { return $next($request); } + if ($request->isMethod('get') && ! $request->expectsJson()) { + $request->session()->put('url.intended', $request->fullUrl()); + } + return redirect() - ->route('security.edit') + ->route('password.force-change.edit') ->with('status', 'You must change your password before continuing.'); } } diff --git a/resources/js/pages/auth/ForcePasswordChange.vue b/resources/js/pages/auth/ForcePasswordChange.vue new file mode 100644 index 0000000..9c27640 --- /dev/null +++ b/resources/js/pages/auth/ForcePasswordChange.vue @@ -0,0 +1,104 @@ + + + diff --git a/routes/web/settings.php b/routes/web/settings.php index 21827fc..78233a9 100644 --- a/routes/web/settings.php +++ b/routes/web/settings.php @@ -19,6 +19,13 @@ Route::middleware('auth') }); }); +Route::middleware(['auth', 'verified']) + ->controller(SecurityController::class) + ->group(function (): void { + Route::get('user/force-password-change', 'editForcedPassword') + ->name('password.force-change.edit'); + }); + Route::middleware(['auth', 'verified']) ->prefix('settings') ->group(function (): void { diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 607166e..1ed655b 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -33,7 +33,7 @@ class AuthenticationTest extends TestCase $response->assertRedirect(route('dashboard', absolute: false)); } - public function test_users_who_must_change_their_password_are_redirected_to_security_settings_after_login() + public function test_users_who_must_change_their_password_are_redirected_to_the_dedicated_force_change_page_after_login() { $user = User::factory()->create([ 'must_change_password_on_next_login' => true, @@ -46,7 +46,8 @@ class AuthenticationTest extends TestCase $this->assertAuthenticated(); $response->assertInertia(fn (Assert $page) => $page - ->component('auth/ConfirmPassword'), + ->component('auth/ForcePasswordChange') + ->where('status', 'You must change your password before continuing.'), ); } diff --git a/tests/Feature/Settings/SecurityTest.php b/tests/Feature/Settings/SecurityTest.php index 7783609..9928d22 100644 --- a/tests/Feature/Settings/SecurityTest.php +++ b/tests/Feature/Settings/SecurityTest.php @@ -97,7 +97,40 @@ class SecurityTest extends TestCase $this->actingAs($user) ->get(route('dashboard')) - ->assertRedirect(route('security.edit')); + ->assertRedirect(route('password.force-change.edit')); + } + + public function test_users_who_must_change_their_password_can_view_the_dedicated_force_change_page() + { + $user = User::factory()->create([ + 'must_change_password_on_next_login' => true, + ]); + + $this->actingAs($user) + ->get(route('password.force-change.edit')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('auth/ForcePasswordChange'), + ); + } + + public function test_users_who_must_change_their_password_are_not_sent_to_the_normal_security_page() + { + $this->skipUnlessFortifyFeature(Features::twoFactorAuthentication()); + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + ]); + + $user = User::factory()->create([ + 'must_change_password_on_next_login' => true, + ]); + + $this->actingAs($user) + ->withSession(['auth.password_confirmed_at' => time()]) + ->get(route('security.edit')) + ->assertRedirect(route('password.force-change.edit')); } public function test_password_can_be_updated() @@ -135,11 +168,34 @@ class SecurityTest extends TestCase 'password_confirmation' => 'new-password', ]) ->assertSessionHasNoErrors() - ->assertRedirect(route('security.edit')); + ->assertRedirect(route('dashboard')); $this->assertFalse((bool) $user->refresh()->must_change_password_on_next_login); } + public function test_forced_password_change_returns_the_user_to_their_intended_page_after_updating() + { + $user = User::factory()->create([ + 'must_change_password_on_next_login' => true, + ]); + + $this->actingAs($user) + ->get(route('dashboard')); + + $this->actingAs($user) + ->from(route('password.force-change.edit')) + ->put(route('user-password.update'), [ + 'current_password' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]) + ->assertSessionHasNoErrors() + ->assertRedirect(route('dashboard')); + + $this->assertFalse((bool) $user->refresh()->must_change_password_on_next_login); + $this->assertTrue(Hash::check('new-password', $user->password)); + } + public function test_correct_password_must_be_provided_to_update_password() { $user = User::factory()->create();