diff --git a/app/Http/Controllers/Settings/SecurityController.php b/app/Http/Controllers/Settings/SecurityController.php index 053ac89..0563e1e 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; @@ -31,10 +32,9 @@ class SecurityController extends Controller implements HasMiddleware public function edit(TwoFactorAuthenticationRequest $request): Response { $user = $request->user(); + $props = [ 'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(), - 'mustChangePassword' => (bool) $user->must_change_password_on_next_login, - 'status' => session('status'), ]; if (Features::canManageTwoFactorAuthentication()) { @@ -47,18 +47,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..baef01e 100644 --- a/app/Http/Middleware/RequirePasswordChange.php +++ b/app/Http/Middleware/RequirePasswordChange.php @@ -23,16 +23,18 @@ class RequirePasswordChange if ($request->routeIs([ 'logout', - 'password.confirm', - 'password.confirm.*', - 'security.edit', + 'password.force-change.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/app/Http/Requests/Settings/PasswordUpdateRequest.php b/app/Http/Requests/Settings/PasswordUpdateRequest.php index 0e5ff2f..6536ae5 100644 --- a/app/Http/Requests/Settings/PasswordUpdateRequest.php +++ b/app/Http/Requests/Settings/PasswordUpdateRequest.php @@ -19,7 +19,19 @@ class PasswordUpdateRequest extends FormRequest { return [ 'current_password' => $this->currentPasswordRules(), - 'password' => $this->passwordRules(), + 'password' => [...$this->passwordRules(), 'different:current_password'], + ]; + } + + /** + * Get the custom validation messages for the request. + * + * @return array + */ + public function messages(): array + { + return [ + 'password.different' => 'The new password must be different from your current password.', ]; } } diff --git a/config/fortify.php b/config/fortify.php index ce67e2c..eb256f1 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -144,7 +144,6 @@ return [ */ 'features' => [ - Features::registration(), Features::resetPasswords(), Features::emailVerification(), Features::twoFactorAuthentication([ diff --git a/resources/js/pages/auth/ForcePasswordChange.vue b/resources/js/pages/auth/ForcePasswordChange.vue new file mode 100644 index 0000000..29f9e79 --- /dev/null +++ b/resources/js/pages/auth/ForcePasswordChange.vue @@ -0,0 +1,115 @@ + + + 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..50816ea 100644 --- a/tests/Feature/Settings/SecurityTest.php +++ b/tests/Feature/Settings/SecurityTest.php @@ -97,7 +97,54 @@ 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') + ->where('status', null), + ); + } + + public function test_forced_password_change_page_exposes_a_logout_action() + { + $user = User::factory()->create([ + 'must_change_password_on_next_login' => true, + ]); + + $this->actingAs($user) + ->get(route('password.force-change.edit')) + ->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 +182,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(); @@ -157,4 +227,26 @@ class SecurityTest extends TestCase ->assertSessionHasErrors('current_password') ->assertRedirect(route('security.edit')); } + + public function test_new_password_must_be_different_from_the_current_password() + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from(route('security.edit')) + ->put(route('user-password.update'), [ + 'current_password' => 'password', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response + ->assertSessionHasErrors([ + 'password' => 'The new password must be different from your current password.', + ]) + ->assertRedirect(route('security.edit')); + + $this->assertTrue(Hash::check('password', $user->fresh()->password)); + } }