diff --git a/app/Http/Controllers/Admin/UserManagementController.php b/app/Http/Controllers/Admin/UserManagementController.php index 87204ef..6e17615 100644 --- a/app/Http/Controllers/Admin/UserManagementController.php +++ b/app/Http/Controllers/Admin/UserManagementController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin; use App\Actions\Permissions\PermissionService; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\StoreManagedUserRequest; use App\Http\Requests\Admin\SyncUserGlobalPermissionsRequest; use App\Http\Resources\PermissionOptionResource; use App\Http\Resources\UserResource; @@ -35,6 +36,26 @@ class UserManagementController extends Controller ]); } + public function create(): Response + { + return Inertia::render('admin/users/Create'); + } + + public function store(StoreManagedUserRequest $request): RedirectResponse + { + $validated = $request->validated(); + + User::query()->create([ + 'name' => Arr::get($validated, 'name'), + 'email' => Arr::get($validated, 'email'), + 'password' => Arr::get($validated, 'password'), + 'must_change_password_on_next_login' => (bool) Arr::get($validated, 'must_change_password_on_next_login', true), + ]); + + return to_route('admin.users.index') + ->with('status', 'User was created.'); + } + public function update(SyncUserGlobalPermissionsRequest $request, User $user): RedirectResponse { $validated = $request->validated(); diff --git a/app/Http/Controllers/Settings/SecurityController.php b/app/Http/Controllers/Settings/SecurityController.php index 4e47782..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,11 +29,18 @@ 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, + 'status' => session('status'), ]; if (Features::canManageTwoFactorAuthentication()) { @@ -45,17 +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 new file mode 100644 index 0000000..2edae2b --- /dev/null +++ b/app/Http/Middleware/RequirePasswordChange.php @@ -0,0 +1,43 @@ +user(); + + if ($user === null || ! $user->must_change_password_on_next_login) { + return $next($request); + } + + if ($request->routeIs([ + '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('password.force-change.edit') + ->with('status', 'You must change your password before continuing.'); + } +} diff --git a/app/Http/Requests/Admin/StoreManagedUserRequest.php b/app/Http/Requests/Admin/StoreManagedUserRequest.php new file mode 100644 index 0000000..6304b5b --- /dev/null +++ b/app/Http/Requests/Admin/StoreManagedUserRequest.php @@ -0,0 +1,36 @@ +user()?->can('access-admin') ?? false; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + ...$this->profileRules(), + 'must_change_password_on_next_login' => ['required', 'boolean'], + 'password' => $this->passwordRules(), + ]; + } +} 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/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 317fc44..353d589 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -19,6 +19,7 @@ class UserResource extends JsonResource 'name' => $this->name, 'email' => $this->email, 'email_verified_at' => $this->email_verified_at?->toISOString(), + 'must_change_password_on_next_login' => (bool) $this->must_change_password_on_next_login, 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), ]; diff --git a/app/Models/User.php b/app/Models/User.php index b3dc0ba..9ff2172 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -17,7 +17,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; -#[Fillable(['name', 'email', 'password'])] +#[Fillable(['name', 'email', 'password', 'must_change_password_on_next_login'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] class User extends Authenticatable { @@ -40,6 +40,7 @@ class User extends Authenticatable { return [ 'email_verified_at' => 'datetime', + 'must_change_password_on_next_login' => 'bool', 'password' => 'hashed', 'two_factor_confirmed_at' => 'datetime', ]; diff --git a/bootstrap/app.php b/bootstrap/app.php index 001ee1c..0e344cc 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use App\Http\Middleware\AuthenticateApiApplicationKey; use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; +use App\Http\Middleware\RequirePasswordChange; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -21,6 +22,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->web(append: [ HandleAppearance::class, HandleInertiaRequests::class, + RequirePasswordChange::class, AddLinkHeadersForPreloadedAssets::class, ]); diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index de5d2ba..1c93e9b 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -29,6 +29,7 @@ class UserFactory extends Factory 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), + 'must_change_password_on_next_login' => false, 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), 'two_factor_secret' => null, diff --git a/database/migrations/2026_05_22_124700_add_must_change_password_on_next_login_to_users_table.php b/database/migrations/2026_05_22_124700_add_must_change_password_on_next_login_to_users_table.php new file mode 100644 index 0000000..fd68301 --- /dev/null +++ b/database/migrations/2026_05_22_124700_add_must_change_password_on_next_login_to_users_table.php @@ -0,0 +1,30 @@ +boolean('must_change_password_on_next_login') + ->default(false) + ->after('password'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('must_change_password_on_next_login'); + }); + } +}; diff --git a/resources/js/pages/admin/users/Create.vue b/resources/js/pages/admin/users/Create.vue new file mode 100644 index 0000000..59a28bb --- /dev/null +++ b/resources/js/pages/admin/users/Create.vue @@ -0,0 +1,155 @@ + + + diff --git a/resources/js/pages/admin/users/Index.vue b/resources/js/pages/admin/users/Index.vue index aaf2775..cbf1efc 100644 --- a/resources/js/pages/admin/users/Index.vue +++ b/resources/js/pages/admin/users/Index.vue @@ -1,6 +1,6 @@ + + diff --git a/resources/js/pages/settings/Security.vue b/resources/js/pages/settings/Security.vue index 8b0b4a4..5e9a084 100644 --- a/resources/js/pages/settings/Security.vue +++ b/resources/js/pages/settings/Security.vue @@ -19,13 +19,17 @@ import type { BreadcrumbItem } from '@/types'; type Props = { canManageTwoFactor?: boolean; + mustChangePassword?: boolean; requiresConfirmation?: boolean; + status?: string | null; twoFactorEnabled?: boolean; }; withDefaults(defineProps(), { canManageTwoFactor: false, + mustChangePassword: false, requiresConfirmation: false, + status: null, twoFactorEnabled: false, }); @@ -49,6 +53,17 @@ onUnmounted(() => clearTwoFactorAuthData());

Security settings

+
+ {{ + mustChangePassword + ? 'You must change your password before continuing anywhere else in the app.' + : status + }} +
+
controller(UserManagementController::class) ->group(function (): void { Route::get('/', 'index')->name('index'); + Route::get('create', 'create')->name('create'); + Route::post('/', 'store')->name('store'); Route::patch('{user}/permissions', 'update')->name('update'); }); }); 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/Admin/UserManagementControllerTest.php b/tests/Feature/Admin/UserManagementControllerTest.php index 77c7e99..77b912d 100644 --- a/tests/Feature/Admin/UserManagementControllerTest.php +++ b/tests/Feature/Admin/UserManagementControllerTest.php @@ -46,6 +46,46 @@ class UserManagementControllerTest extends TestCase ->assertForbidden(); } + /** + * Ensure admins can view the create user page. + */ + public function test_admin_can_view_the_create_user_page(): void + { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->get(route('admin.users.create')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('admin/users/Create'), + ); + } + + /** + * Ensure admins can create managed users. + */ + public function test_admin_can_create_a_user_that_must_change_password_on_next_login(): void + { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->post(route('admin.users.store'), [ + 'name' => 'New Member', + 'email' => 'new-member@example.com', + 'password' => 'temporary-password', + 'password_confirmation' => 'temporary-password', + 'must_change_password_on_next_login' => true, + ]) + ->assertRedirect(route('admin.users.index')) + ->assertSessionHas('status', 'User was created.'); + + $this->assertDatabaseHas('users', [ + 'name' => 'New Member', + 'email' => 'new-member@example.com', + 'must_change_password_on_next_login' => true, + ]); + } + /** * Ensure admins can update another user's global permissions. */ @@ -126,4 +166,26 @@ class UserManagementControllerTest extends TestCase 'scope_type' => null, ]); } + + /** + * Ensure non-admin users cannot create managed users. + */ + public function test_non_admin_cannot_create_a_user(): void + { + $member = User::factory()->create(); + + $this->actingAs($member) + ->post(route('admin.users.store'), [ + 'name' => 'Blocked User', + 'email' => 'blocked-user@example.com', + 'password' => 'temporary-password', + 'password_confirmation' => 'temporary-password', + 'must_change_password_on_next_login' => true, + ]) + ->assertForbidden(); + + $this->assertDatabaseMissing('users', [ + 'email' => 'blocked-user@example.com', + ]); + } } diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 329226d..1ed655b 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Auth; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\RateLimiter; +use Inertia\Testing\AssertableInertia as Assert; use Laravel\Fortify\Features; use Tests\TestCase; @@ -32,6 +33,24 @@ class AuthenticationTest extends TestCase $response->assertRedirect(route('dashboard', absolute: false)); } + 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, + ]); + + $response = $this->followingRedirects()->post(route('login.store'), [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertInertia(fn (Assert $page) => $page + ->component('auth/ForcePasswordChange') + ->where('status', 'You must change your password before continuing.'), + ); + } + public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_challenge() { $this->skipUnlessFortifyFeature(Features::twoFactorAuthentication()); diff --git a/tests/Feature/Settings/SecurityTest.php b/tests/Feature/Settings/SecurityTest.php index 8b8150e..50816ea 100644 --- a/tests/Feature/Settings/SecurityTest.php +++ b/tests/Feature/Settings/SecurityTest.php @@ -89,6 +89,64 @@ class SecurityTest extends TestCase ); } + public function test_users_who_must_change_their_password_are_redirected_to_security_when_visiting_other_pages() + { + $user = User::factory()->create([ + 'must_change_password_on_next_login' => true, + ]); + + $this->actingAs($user) + ->get(route('dashboard')) + ->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() { $user = User::factory()->create(); @@ -109,6 +167,49 @@ class SecurityTest extends TestCase $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); } + public function test_password_update_clears_the_forced_password_change_flag() + { + $user = User::factory()->create([ + 'must_change_password_on_next_login' => true, + ]); + + $this + ->actingAs($user) + ->from(route('security.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); + } + + 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(); @@ -126,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)); + } }