diff --git a/app/Http/Controllers/Auth/TwoFactorSettingsController.php b/app/Http/Controllers/Auth/TwoFactorSettingsController.php index 00af30c..66236a0 100644 --- a/app/Http/Controllers/Auth/TwoFactorSettingsController.php +++ b/app/Http/Controllers/Auth/TwoFactorSettingsController.php @@ -65,7 +65,7 @@ class TwoFactorSettingsController extends Controller $redirect = $request->routeIs('two-factor.setup.confirm') ? redirect()->route('two-factor.setup') - : redirect()->route('profile.show'); + : redirect()->route('profile.security'); return $redirect ->with('success', 'Two-factor authentication is now enabled.') @@ -102,7 +102,7 @@ class TwoFactorSettingsController extends Controller )); $securityEvents->record('two_factor.disabled', $user, $request); - return redirect()->route('profile.show') + return redirect()->route('profile.security') ->with('success', 'Two-factor authentication has been disabled.'); } @@ -138,7 +138,7 @@ class TwoFactorSettingsController extends Controller $securityEvents->record('two_factor.recovery_codes_revealed', $user, $request); } - return redirect()->route('profile.show') + return redirect()->route('profile.security') ->with('success', $request->boolean('regenerate') ? 'New recovery codes have been generated. Previous codes no longer work.' : 'Recovery codes revealed.') @@ -158,7 +158,7 @@ class TwoFactorSettingsController extends Controller { return $request->routeIs('two-factor.setup.*') ? redirect()->route('two-factor.setup') - : redirect()->route('profile.show'); + : redirect()->route('profile.security'); } private function validatePasswordForPasswordUser(Request $request, User $user): void diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index 60b5043..0ccf0e0 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -2,26 +2,50 @@ namespace App\Http\Controllers; +use App\Http\Requests\Profile\UpdateProfileRequest; use App\Models\User; -use App\Services\Auth\TwoFactorEnrollmentState; -use Illuminate\Http\Request; +use App\Services\Auth\SecurityEventRecorder; +use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; class ProfileController extends Controller { /** - * Display the authenticated user's profile and security settings. + * Display the authenticated user's profile settings. */ - public function __invoke( - Request $request, - TwoFactorEnrollmentState $enrollmentState - ): Response { + public function show(): Response + { + return Inertia::render('Profile/Show'); + } + + /** + * Update the authenticated user's profile settings. + */ + public function update( + UpdateProfileRequest $request, + SecurityEventRecorder $securityEvents + ): RedirectResponse { /** @var User $user */ $user = $request->user(); + $emailChanged = $user->email !== $request->validated('email'); - return Inertia::render('Profile/Show', [ - 'twoFactor' => $enrollmentState->for($user), + $user->fill($request->validated()); + + if ($emailChanged) { + $user->email_verified_at = null; + } + + $user->save(); + $securityEvents->record('profile.updated', $user, $request, [ + 'email_changed' => $emailChanged, ]); + + if ($emailChanged) { + $user->sendEmailVerificationNotification(); + } + + return redirect()->route('profile.show') + ->with('success', 'Your profile has been updated.'); } } diff --git a/app/Http/Controllers/ProfileSecurityController.php b/app/Http/Controllers/ProfileSecurityController.php new file mode 100644 index 0000000..fbc78a1 --- /dev/null +++ b/app/Http/Controllers/ProfileSecurityController.php @@ -0,0 +1,71 @@ +user(); + + return Inertia::render('Profile/Security', [ + 'twoFactor' => $enrollmentState->for($user), + ]); + } + + /** + * Update the authenticated user's password and revoke other sessions. + */ + public function updatePassword( + UpdatePasswordRequest $request, + SecurityEventRecorder $securityEvents + ): RedirectResponse { + /** @var User $user */ + $user = $request->user(); + + $user->forceFill([ + 'password' => $request->validated('password'), + 'remember_token' => Str::random(60), + 'auth_session_version' => $user->auth_session_version + 1, + ])->save(); + + if (config('session.driver') === 'database') { + DB::table(config('session.table')) + ->where('user_id', $user->getKey()) + ->where('id', '!=', $request->session()->getId()) + ->delete(); + } + + $request->session()->put( + 'auth.session_version', + $user->auth_session_version + ); + + $user->notify(new PasswordChangedNotification( + (string) $request->ip(), + now()->toIso8601String() + )); + $securityEvents->record('password.changed', $user, $request); + + return redirect()->route('profile.security') + ->with('success', 'Your password has been updated. Other sessions were signed out.'); + } +} diff --git a/app/Http/Middleware/AddSecurityHeaders.php b/app/Http/Middleware/AddSecurityHeaders.php index 22bf1b5..25f9e57 100644 --- a/app/Http/Middleware/AddSecurityHeaders.php +++ b/app/Http/Middleware/AddSecurityHeaders.php @@ -52,7 +52,7 @@ class AddSecurityHeaders 'login', 'password.*', 'two-factor.*', - 'profile.show', + 'profile.*', ]); } } diff --git a/app/Http/Requests/Profile/UpdatePasswordRequest.php b/app/Http/Requests/Profile/UpdatePasswordRequest.php new file mode 100644 index 0000000..9e98b21 --- /dev/null +++ b/app/Http/Requests/Profile/UpdatePasswordRequest.php @@ -0,0 +1,31 @@ +user() !== null; + } + + /** + * @return array> + */ + public function rules(): array + { + /** @var User $user */ + $user = $this->user(); + + return [ + 'current_password' => $user->hasPassword() + ? ['required', 'string', 'current_password:web'] + : ['nullable', 'string'], + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]; + } +} diff --git a/app/Http/Requests/Profile/UpdateProfileRequest.php b/app/Http/Requests/Profile/UpdateProfileRequest.php new file mode 100644 index 0000000..ae7984e --- /dev/null +++ b/app/Http/Requests/Profile/UpdateProfileRequest.php @@ -0,0 +1,47 @@ +user() !== null; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'username' => [ + 'required', + 'string', + 'max:255', + 'alpha_dash', + function ($attribute, $value, $fail): void { + if (User::whereKeyNot($this->user()->getKey()) + ->whereRaw('LOWER(username) = ?', [strtolower($value)]) + ->exists()) { + $fail('The username has already been taken.'); + } + }, + ], + 'email' => [ + 'required', + 'string', + 'lowercase', + 'email', + 'max:255', + Rule::unique(User::class)->ignore($this->user()->getKey()), + ], + ]; + } +} diff --git a/app/Notifications/TwoFactorFailedNotification.php b/app/Notifications/TwoFactorFailedNotification.php index bbfbce2..bfd0f84 100644 --- a/app/Notifications/TwoFactorFailedNotification.php +++ b/app/Notifications/TwoFactorFailedNotification.php @@ -35,6 +35,6 @@ class TwoFactorFailedNotification extends Notification implements ShouldQueue ->line("IP address: {$this->ipAddress}") ->line("Browser: {$this->userAgent}") ->line('If this was not you, change your password immediately.') - ->action('Review account security', url('/profile')); + ->action('Review account security', url('/profile/security')); } } diff --git a/app/Notifications/TwoFactorSecurityNotification.php b/app/Notifications/TwoFactorSecurityNotification.php index 17238a7..5cf1d5e 100644 --- a/app/Notifications/TwoFactorSecurityNotification.php +++ b/app/Notifications/TwoFactorSecurityNotification.php @@ -34,6 +34,6 @@ class TwoFactorSecurityNotification extends Notification implements ShouldQueue ->line("Time: {$this->occurredAt}") ->line("IP address: {$this->ipAddress}") ->line('If you did not perform this action, change your password and contact support immediately.') - ->action('Review account security', url('/profile')); + ->action('Review account security', url('/profile/security')); } } diff --git a/eslint.config.mjs b/eslint.config.mjs index ed793aa..cea8be6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,9 @@ import antfu from '@antfu/eslint-config' export default antfu({ + ignores: [ + 'storage/inertia-devtools/**', + ], vue: true, typescript: true, }) diff --git a/resources/js/Pages/Dashboard.vue b/resources/js/Pages/Dashboard.vue index a2f1b3b..e39ac48 100644 --- a/resources/js/Pages/Dashboard.vue +++ b/resources/js/Pages/Dashboard.vue @@ -12,7 +12,11 @@ const { user } = useAuth()