From b121fb08fa2365611710a146a839534e719a03ca Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Tue, 6 Oct 2026 22:55:37 -0300 Subject: [PATCH] Word a provider account's first-password email as setting, not resetting Since GHSA-4r8h-mwfm-f5f4, an account that signs in through a provider gets its first password only from the reset link emailed to it. That email said "you are receiving this because we received a password reset request", to somebody who never had a password and may well ignore it. ResetPasswordNotification now takes firstPassword, which User::sendPasswordResetNotification() sets for an AuthSource::Social account: "Set your password", what the link is for, and that nothing changes if they did not ask. It is not taken from the customisable reset template, whose text is written about resetting. Accounts with a password get the reset email exactly as before. --- app/Models/User.php | 5 +++- .../ResetPasswordNotification.php | 15 +++++++++++ .../Identity/ProviderAccountPasswordTest.php | 26 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/app/Models/User.php b/app/Models/User.php index ebaac6a5..c675b0ed 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -152,7 +152,10 @@ class User extends Authenticatable implements HasLocalePreference */ public function sendPasswordResetNotification($token) { - $this->notify(new ResetPasswordNotification($token)); + // An account that signs in through a provider has never had a + // password here, so its link sets the first one rather than + // resetting anything (PasswordController::sendLink). + $this->notify(new ResetPasswordNotification($token, firstPassword: $this->auth_source === AuthSource::Social)); } /** diff --git a/app/Modules/Identity/Notifications/ResetPasswordNotification.php b/app/Modules/Identity/Notifications/ResetPasswordNotification.php index c70821f6..6bc3aeca 100644 --- a/app/Modules/Identity/Notifications/ResetPasswordNotification.php +++ b/app/Modules/Identity/Notifications/ResetPasswordNotification.php @@ -27,6 +27,7 @@ class ResetPasswordNotification extends Notification implements ShouldQueue public function __construct( public readonly string $token, + public readonly bool $firstPassword = false, ) {} /** @@ -46,6 +47,20 @@ class ResetPasswordNotification extends Notification implements ShouldQueue $expireMinutes = (int) config('auth.passwords.'.config('auth.defaults.passwords').'.expire'); + // The same link, for an account that signs in through a provider and + // has never had a password: this is now the only way it gets one, so + // an email that speaks of a reset nobody asked for is one people + // ignore. Not taken from the customisable reset template for the + // same reason, since that text is written about resetting. + if ($this->firstPassword) { + return (new MailMessage) + ->subject(__('Set your password')) + ->line(__('Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.')) + ->action(__('Set a password'), $url) + ->line(__('This link will expire in :count minutes.', ['count' => $expireMinutes])) + ->line(__('If you did not ask for this, no further action is required: you can keep signing in the way you do now.')); + } + if (($override = $this->overrideOrNull(EmailTemplateSlot::PasswordReset)) !== null) { return $this->mailFromOverride($override, [':count' => (string) $expireMinutes]) ->action(__('Reset Password'), $url); diff --git a/tests/Feature/Identity/ProviderAccountPasswordTest.php b/tests/Feature/Identity/ProviderAccountPasswordTest.php index a177dcdf..8a178c29 100644 --- a/tests/Feature/Identity/ProviderAccountPasswordTest.php +++ b/tests/Feature/Identity/ProviderAccountPasswordTest.php @@ -106,6 +106,32 @@ test('using the link signs out the session that asked for it', function () { $this->assertGuest(); }); +test('the email to a provider account is about setting a first password, not a reset', function () { + Notification::fake(); + $user = providerAccount(); + + $this->actingAs($user)->post('/settings/password/link'); + + Notification::assertSentTo($user, ResetPasswordNotification::class, function (ResetPasswordNotification $notification) use ($user): bool { + $mail = $notification->toMail($user); + + return $mail->subject === __('Set your password') + && $mail->actionText === __('Set a password') + && ! str_contains(implode(' ', $mail->introLines), 'reset'); + }); +}); + +test('an account with a password still gets the reset email', function () { + Notification::fake(); + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPasswordNotification::class, function (ResetPasswordNotification $notification) use ($user): bool { + return $notification->toMail($user)->subject === __('Reset Password Notification'); + }); +}); + test('an account that already has a password uses the form, not a link', function () { Notification::fake(); $user = User::factory()->create();