Skip to content

Commit fe0fd04

Browse files
authored
feat: password changed email notification (#2176)
This PR adds support for sending the user an email notification when their password has been changed. 3 new environment variables are introduced: - `GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION`: Email subject to use for password changed notification. - `GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION`: The URL to specify a custom template. - `GOTRUE_MAILER_NOTIFICATION_CONFIGURATIONS_PASSWORD_CHANGED_NOTIFICATION_ENABLED`: whether the notification is enabled or not. The feature is disabled by default. To enable it, the `GOTRUE_MAILER_NOTIFICATION_CONFIGURATIONS_PASSWORD_CHANGED_NOTIFICATION_ENABLED` environment variable must be set to `true`. The default email will look as follows: <img width="828" height="472" alt="default" src="https://github.com/user-attachments/assets/70588e93-b8aa-4bb9-82d9-b9e898aa7035" />
1 parent d475ac1 commit fe0fd04

File tree

13 files changed

+369
-9
lines changed

13 files changed

+369
-9
lines changed

README.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,10 @@ Email subject to use for magic link email. Defaults to `Your Magic Link`.
584584

585585
Email subject to use for email change confirmation. Defaults to `Confirm Email Change`.
586586

587+
`MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION` - `string`
588+
589+
Email subject to use for password changed notification. Defaults to `Your password has been changed`.
590+
587591
`MAILER_TEMPLATES_INVITE` - `string`
588592

589593
URL path to an email template to use when inviting a user. (e.g. `https://www.example.com/path-to-email-template.html`)
@@ -660,6 +664,27 @@ Default Content (if template is unavailable):
660664
<p><a href="{{ .ConfirmationURL }}">Change Email</a></p>
661665
```
662666

667+
`MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION` - `string`
668+
669+
URL path to an email template to use when notifying a user that their password has been changed. (e.g. `https://www.example.com/path-to-email-template.html`)
670+
`SiteURL` and `Email` variables are available.
671+
672+
Default Content (if template is unavailable):
673+
674+
```html
675+
<h2>Your password has been changed</h2>
676+
677+
<p>
678+
This is a confirmation that the password for your account {{ .Email }} has
679+
just been changed. If you did not make this change, please contact support
680+
immediately.
681+
</p>
682+
```
683+
684+
`MAILER_NOTIFICATION_CONFIGURATIONS_PASSWORD_CHANGED_NOTIFICATION_ENABLED` - `bool`
685+
686+
Whether to send a notification email when a user's password is changed. Defaults to `false`.
687+
663688
### Phone Auth
664689

665690
`SMS_AUTOCONFIRM` - `bool`
@@ -746,7 +771,7 @@ Returns the publicly available settings for this auth instance.
746771
"linkedin": true,
747772
"notion": true,
748773
"slack": true,
749-
"snapchat": true,
774+
"snapchat": true,
750775
"spotify": true,
751776
"twitch": true,
752777
"twitter": true,
@@ -869,8 +894,8 @@ if AUTOCONFIRM is enabled and the sign up is a duplicate, then the endpoint will
869894

870895
```json
871896
{
872-
"code":400,
873-
"msg":"User already registered"
897+
"code": 400,
898+
"msg": "User already registered"
874899
}
875900
```
876901

example.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ GOTRUE_MAILER_SUBJECTS_RECOVERY="Reset Your Password"
3535
GOTRUE_MAILER_SUBJECTS_MAGIC_LINK="Your Magic Link"
3636
GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE="Confirm Email Change"
3737
GOTRUE_MAILER_SUBJECTS_INVITE="You have been invited"
38+
GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION="Your password has been changed"
3839
GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true"
3940

4041
# Custom mailer template config
@@ -43,6 +44,10 @@ GOTRUE_MAILER_TEMPLATES_CONFIRMATION=""
4344
GOTRUE_MAILER_TEMPLATES_RECOVERY=""
4445
GOTRUE_MAILER_TEMPLATES_MAGIC_LINK=""
4546
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE=""
47+
GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION=""
48+
49+
# Account changes notifications configuration
50+
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED="false"
4651

4752
# Signup config
4853
GOTRUE_DISABLE_SIGNUP="false"

internal/api/api_test.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,18 @@ func init() {
2222
// setupAPIForTest creates a new API to run tests with.
2323
// Using this function allows us to keep track of the database connection
2424
// and cleaning up data between tests.
25-
func setupAPIForTest() (*API, *conf.GlobalConfiguration, error) {
26-
return setupAPIForTestWithCallback(nil)
25+
func setupAPIForTest(opts ...Option) (*API, *conf.GlobalConfiguration, error) {
26+
config, err := conf.LoadGlobal(apiTestConfig)
27+
if err != nil {
28+
return nil, nil, err
29+
}
30+
31+
conn, err := test.SetupDBConnection(config)
32+
if err != nil {
33+
return nil, nil, err
34+
}
35+
36+
return NewAPIWithVersion(config, conn, apiTestVersion, opts...), config, nil
2737
}
2838

2939
func setupAPIForTestWithCallback(cb func(*conf.GlobalConfiguration, *storage.Connection)) (*API, *conf.GlobalConfiguration, error) {

internal/api/mail.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,19 @@ func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models
548548
return nil
549549
}
550550

551+
func (a *API) sendPasswordChangedNotification(r *http.Request, tx *storage.Connection, u *models.User) error {
552+
if err := a.sendEmail(r, tx, u, mail.PasswordChangedNotification, "", "", ""); err != nil {
553+
if errors.Is(err, EmailRateLimitExceeded) {
554+
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error())
555+
} else if herr, ok := err.(*HTTPError); ok {
556+
return herr
557+
}
558+
return apierrors.NewInternalServerError("Error sending password changed notification email").WithInternalError(err)
559+
}
560+
561+
return nil
562+
}
563+
551564
func (a *API) validateEmail(email string) (string, error) {
552565
if email == "" {
553566
return "", apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "An email address is required")
@@ -701,6 +714,8 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User,
701714
err = mr.InviteMail(r, u, otp, referrerURL, externalURL)
702715
case mail.EmailChangeVerification:
703716
err = mr.EmailChangeMail(r, u, otpNew, otp, referrerURL, externalURL)
717+
case mail.PasswordChangedNotification:
718+
err = mr.PasswordChangedNotificationMail(r, u)
704719
default:
705720
err = errors.New("invalid email action type")
706721
}

internal/api/user.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"time"
77

88
"github.com/gofrs/uuid"
9+
"github.com/sirupsen/logrus"
910
"github.com/supabase/auth/internal/api/apierrors"
1011
"github.com/supabase/auth/internal/api/sms_provider"
1112
"github.com/supabase/auth/internal/mailer"
@@ -197,6 +198,14 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
197198
if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserUpdatePasswordAction, "", nil); terr != nil {
198199
return terr
199200
}
201+
202+
// send a Password Changed email notification to the user to inform them that their password has been changed
203+
if config.Mailer.Notifications.PasswordChangedEnabled && user.GetEmail() != "" {
204+
if err := a.sendPasswordChangedNotification(r, tx, user); err != nil {
205+
// we don't want to fail the whole request if the email can't be sent
206+
logrus.WithError(err).Warn("Unable to send password changed notification email")
207+
}
208+
}
200209
}
201210

202211
if params.Data != nil {

internal/api/user_test.go

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,27 @@ import (
1515
"github.com/stretchr/testify/suite"
1616
"github.com/supabase/auth/internal/conf"
1717
"github.com/supabase/auth/internal/crypto"
18+
"github.com/supabase/auth/internal/mailer"
19+
"github.com/supabase/auth/internal/mailer/mockclient"
1820
"github.com/supabase/auth/internal/models"
1921
)
2022

2123
type UserTestSuite struct {
2224
suite.Suite
2325
API *API
2426
Config *conf.GlobalConfiguration
27+
Mailer mailer.Mailer
2528
}
2629

2730
func TestUser(t *testing.T) {
28-
api, config, err := setupAPIForTest()
31+
mockMailer := &mockclient.MockMailer{}
32+
api, config, err := setupAPIForTest(WithMailer(mockMailer))
2933
require.NoError(t, err)
3034

3135
ts := &UserTestSuite{
3236
API: api,
3337
Config: config,
38+
Mailer: mockMailer,
3439
}
3540
defer api.db.Close()
3641

@@ -556,3 +561,72 @@ func (ts *UserTestSuite) TestUserUpdatePasswordLogoutOtherSessions() {
556561
ts.API.handler.ServeHTTP(w, req)
557562
require.NotEqual(ts.T(), http.StatusOK, w.Code)
558563
}
564+
565+
func (ts *UserTestSuite) TestUserUpdatePasswordSendsNotificationEmail() {
566+
cases := []struct {
567+
desc string
568+
password string
569+
notificationEnabled bool
570+
expectedNotificationsCalled int
571+
}{
572+
{
573+
desc: "Password change notification enabled",
574+
password: "newpassword123",
575+
notificationEnabled: true,
576+
expectedNotificationsCalled: 1,
577+
},
578+
{
579+
desc: "Password change notification disabled",
580+
password: "differentpassword456",
581+
notificationEnabled: false,
582+
expectedNotificationsCalled: 0,
583+
},
584+
}
585+
586+
for _, c := range cases {
587+
ts.Run(c.desc, func() {
588+
ts.Config.Security.UpdatePasswordRequireReauthentication = false
589+
ts.Config.Mailer.Autoconfirm = false
590+
ts.Config.Mailer.Notifications.PasswordChangedEnabled = c.notificationEnabled
591+
592+
u, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
593+
require.NoError(ts.T(), err)
594+
595+
// Confirm the test user
596+
now := time.Now()
597+
u.EmailConfirmedAt = &now
598+
require.NoError(ts.T(), ts.API.db.Update(u), "Error updating test user")
599+
600+
// Get the mock mailer and reset it
601+
mockMailer, ok := ts.Mailer.(*mockclient.MockMailer)
602+
require.True(ts.T(), ok, "Mailer is not of type *MockMailer")
603+
mockMailer.Reset()
604+
605+
token := ts.generateAccessTokenAndSession(u)
606+
607+
// Update password
608+
var buffer bytes.Buffer
609+
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
610+
"password": c.password,
611+
}))
612+
613+
req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
614+
req.Header.Set("Content-Type", "application/json")
615+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
616+
617+
w := httptest.NewRecorder()
618+
ts.API.handler.ServeHTTP(w, req)
619+
require.Equal(ts.T(), http.StatusOK, w.Code)
620+
621+
// Verify password was updated
622+
u, err = models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
623+
require.NoError(ts.T(), err)
624+
625+
// Assert that password change notification email was sent or not based on the instance's configuration
626+
require.Len(ts.T(), mockMailer.PasswordChangedMailCalls, c.expectedNotificationsCalled, fmt.Sprintf("Expected %d password change notification email(s) to be sent", c.expectedNotificationsCalled))
627+
if c.expectedNotificationsCalled > 0 {
628+
require.Equal(ts.T(), u.ID, mockMailer.PasswordChangedMailCalls[0].User.ID, "Email should be sent to the correct user")
629+
}
630+
})
631+
}
632+
}

internal/conf/configuration.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,14 @@ type EmailContentConfiguration struct {
375375
EmailChange string `json:"email_change" split_words:"true"`
376376
MagicLink string `json:"magic_link" split_words:"true"`
377377
Reauthentication string `json:"reauthentication"`
378+
379+
// Account Changes Notifications
380+
PasswordChangedNotification string `json:"password_changed_notification" split_words:"true"`
381+
}
382+
383+
// NotificationsConfiguration holds the configuration for notification email states to indicate whether they are enabled or disabled.
384+
type NotificationsConfiguration struct {
385+
PasswordChangedEnabled bool `json:"password_changed_enabled" split_words:"true" default:"false"`
378386
}
379387

380388
type ProviderConfiguration struct {
@@ -472,9 +480,10 @@ type MailerConfiguration struct {
472480
Autoconfirm bool `json:"autoconfirm"`
473481
AllowUnverifiedEmailSignIns bool `json:"allow_unverified_email_sign_ins" split_words:"true" default:"false"`
474482

475-
Subjects EmailContentConfiguration `json:"subjects"`
476-
Templates EmailContentConfiguration `json:"templates"`
477-
URLPaths EmailContentConfiguration `json:"url_paths"`
483+
Subjects EmailContentConfiguration `json:"subjects"`
484+
Templates EmailContentConfiguration `json:"templates"`
485+
URLPaths EmailContentConfiguration `json:"url_paths"`
486+
Notifications NotificationsConfiguration `json:"notifications" split_words:"true"`
478487

479488
SecureEmailChangeEnabled bool `json:"secure_email_change_enabled" split_words:"true" default:"true"`
480489

internal/mailer/mailer.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ const (
1818
EmailChangeCurrentVerification = "email_change_current"
1919
EmailChangeNewVerification = "email_change_new"
2020
ReauthenticationVerification = "reauthentication"
21+
22+
// Account Changes Notifications
23+
PasswordChangedNotification = "password_changed_notification"
2124
)
2225

2326
// Mailer defines the interface a mailer must implement.
@@ -29,6 +32,9 @@ type Mailer interface {
2932
EmailChangeMail(r *http.Request, user *models.User, otpNew, otpCurrent, referrerURL string, externalURL *url.URL) error
3033
ReauthenticateMail(r *http.Request, user *models.User, otp string) error
3134
GetEmailActionLink(user *models.User, actionType, referrerURL string, externalURL *url.URL) (string, error)
35+
36+
// Account Changes Notifications
37+
PasswordChangedNotificationMail(r *http.Request, user *models.User) error
3238
}
3339

3440
// TODO(cstockton): Mail(...) -> Mail(Email{...}) ?

0 commit comments

Comments
 (0)