diff --git a/README.md b/README.md index 1c2098ee6..22f49d113 100644 --- a/README.md +++ b/README.md @@ -584,6 +584,10 @@ Email subject to use for magic link email. Defaults to `Your Magic Link`. Email subject to use for email change confirmation. Defaults to `Confirm Email Change`. +`MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION` - `string` + +Email subject to use for password changed notification. Defaults to `Your password has been changed`. + `MAILER_TEMPLATES_INVITE` - `string` 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):
``` +`MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION` - `string` + +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`) +`SiteURL` and `Email` variables are available. + +Default Content (if template is unavailable): + +```html ++ This is a confirmation that the password for your account {{ .Email }} has + just been changed. If you did not make this change, please contact support + immediately. +
+``` + +`MAILER_NOTIFICATION_CONFIGURATIONS_PASSWORD_CHANGED_NOTIFICATION_ENABLED` - `bool` + +Whether to send a notification email when a user's password is changed. Defaults to `false`. + ### Phone Auth `SMS_AUTOCONFIRM` - `bool` @@ -746,7 +771,7 @@ Returns the publicly available settings for this auth instance. "linkedin": true, "notion": true, "slack": true, - "snapchat": true, + "snapchat": true, "spotify": true, "twitch": true, "twitter": true, @@ -869,8 +894,8 @@ if AUTOCONFIRM is enabled and the sign up is a duplicate, then the endpoint will ```json { - "code":400, - "msg":"User already registered" + "code": 400, + "msg": "User already registered" } ``` diff --git a/example.env b/example.env index b0c3670b1..096bac815 100644 --- a/example.env +++ b/example.env @@ -35,6 +35,7 @@ GOTRUE_MAILER_SUBJECTS_RECOVERY="Reset Your Password" GOTRUE_MAILER_SUBJECTS_MAGIC_LINK="Your Magic Link" GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE="Confirm Email Change" GOTRUE_MAILER_SUBJECTS_INVITE="You have been invited" +GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION="Your password has been changed" GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true" # Custom mailer template config @@ -43,6 +44,10 @@ GOTRUE_MAILER_TEMPLATES_CONFIRMATION="" GOTRUE_MAILER_TEMPLATES_RECOVERY="" GOTRUE_MAILER_TEMPLATES_MAGIC_LINK="" GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE="" +GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION="" + +# Account changes notifications configuration +GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED="false" # Signup config GOTRUE_DISABLE_SIGNUP="false" diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 9111617c8..793c55c58 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -22,8 +22,18 @@ func init() { // setupAPIForTest creates a new API to run tests with. // Using this function allows us to keep track of the database connection // and cleaning up data between tests. -func setupAPIForTest() (*API, *conf.GlobalConfiguration, error) { - return setupAPIForTestWithCallback(nil) +func setupAPIForTest(opts ...Option) (*API, *conf.GlobalConfiguration, error) { + config, err := conf.LoadGlobal(apiTestConfig) + if err != nil { + return nil, nil, err + } + + conn, err := test.SetupDBConnection(config) + if err != nil { + return nil, nil, err + } + + return NewAPIWithVersion(config, conn, apiTestVersion, opts...), config, nil } func setupAPIForTestWithCallback(cb func(*conf.GlobalConfiguration, *storage.Connection)) (*API, *conf.GlobalConfiguration, error) { diff --git a/internal/api/mail.go b/internal/api/mail.go index cfbe57397..189c12a6e 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -548,6 +548,19 @@ func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models return nil } +func (a *API) sendPasswordChangedNotification(r *http.Request, tx *storage.Connection, u *models.User) error { + if err := a.sendEmail(r, tx, u, mail.PasswordChangedNotification, "", "", ""); err != nil { + if errors.Is(err, EmailRateLimitExceeded) { + return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) + } else if herr, ok := err.(*HTTPError); ok { + return herr + } + return apierrors.NewInternalServerError("Error sending password changed notification email").WithInternalError(err) + } + + return nil +} + func (a *API) validateEmail(email string) (string, error) { if email == "" { 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, err = mr.InviteMail(r, u, otp, referrerURL, externalURL) case mail.EmailChangeVerification: err = mr.EmailChangeMail(r, u, otpNew, otp, referrerURL, externalURL) + case mail.PasswordChangedNotification: + err = mr.PasswordChangedNotificationMail(r, u) default: err = errors.New("invalid email action type") } diff --git a/internal/api/user.go b/internal/api/user.go index 520a9bbd7..723bce144 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -6,6 +6,7 @@ import ( "time" "github.com/gofrs/uuid" + "github.com/sirupsen/logrus" "github.com/supabase/auth/internal/api/apierrors" "github.com/supabase/auth/internal/api/sms_provider" "github.com/supabase/auth/internal/mailer" @@ -197,6 +198,14 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserUpdatePasswordAction, "", nil); terr != nil { return terr } + + // send a Password Changed email notification to the user to inform them that their password has been changed + if config.Mailer.Notifications.PasswordChangedEnabled && user.GetEmail() != "" { + if err := a.sendPasswordChangedNotification(r, tx, user); err != nil { + // we don't want to fail the whole request if the email can't be sent + logrus.WithError(err).Warn("Unable to send password changed notification email") + } + } } if params.Data != nil { diff --git a/internal/api/user_test.go b/internal/api/user_test.go index ed6c585af..fee35392d 100644 --- a/internal/api/user_test.go +++ b/internal/api/user_test.go @@ -15,6 +15,8 @@ import ( "github.com/stretchr/testify/suite" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/crypto" + "github.com/supabase/auth/internal/mailer" + "github.com/supabase/auth/internal/mailer/mockclient" "github.com/supabase/auth/internal/models" ) @@ -22,15 +24,18 @@ type UserTestSuite struct { suite.Suite API *API Config *conf.GlobalConfiguration + Mailer mailer.Mailer } func TestUser(t *testing.T) { - api, config, err := setupAPIForTest() + mockMailer := &mockclient.MockMailer{} + api, config, err := setupAPIForTest(WithMailer(mockMailer)) require.NoError(t, err) ts := &UserTestSuite{ API: api, Config: config, + Mailer: mockMailer, } defer api.db.Close() @@ -556,3 +561,72 @@ func (ts *UserTestSuite) TestUserUpdatePasswordLogoutOtherSessions() { ts.API.handler.ServeHTTP(w, req) require.NotEqual(ts.T(), http.StatusOK, w.Code) } + +func (ts *UserTestSuite) TestUserUpdatePasswordSendsNotificationEmail() { + cases := []struct { + desc string + password string + notificationEnabled bool + expectedNotificationsCalled int + }{ + { + desc: "Password change notification enabled", + password: "newpassword123", + notificationEnabled: true, + expectedNotificationsCalled: 1, + }, + { + desc: "Password change notification disabled", + password: "differentpassword456", + notificationEnabled: false, + expectedNotificationsCalled: 0, + }, + } + + for _, c := range cases { + ts.Run(c.desc, func() { + ts.Config.Security.UpdatePasswordRequireReauthentication = false + ts.Config.Mailer.Autoconfirm = false + ts.Config.Mailer.Notifications.PasswordChangedEnabled = c.notificationEnabled + + u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + // Confirm the test user + now := time.Now() + u.EmailConfirmedAt = &now + require.NoError(ts.T(), ts.API.db.Update(u), "Error updating test user") + + // Get the mock mailer and reset it + mockMailer, ok := ts.Mailer.(*mockclient.MockMailer) + require.True(ts.T(), ok, "Mailer is not of type *MockMailer") + mockMailer.Reset() + + token := ts.generateAccessTokenAndSession(u) + + // Update password + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "password": c.password, + })) + + req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + // Verify password was updated + u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + // Assert that password change notification email was sent or not based on the instance's configuration + require.Len(ts.T(), mockMailer.PasswordChangedMailCalls, c.expectedNotificationsCalled, fmt.Sprintf("Expected %d password change notification email(s) to be sent", c.expectedNotificationsCalled)) + if c.expectedNotificationsCalled > 0 { + require.Equal(ts.T(), u.ID, mockMailer.PasswordChangedMailCalls[0].User.ID, "Email should be sent to the correct user") + } + }) + } +} diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index df64bcf7a..15bb36743 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -375,6 +375,14 @@ type EmailContentConfiguration struct { EmailChange string `json:"email_change" split_words:"true"` MagicLink string `json:"magic_link" split_words:"true"` Reauthentication string `json:"reauthentication"` + + // Account Changes Notifications + PasswordChangedNotification string `json:"password_changed_notification" split_words:"true"` +} + +// NotificationsConfiguration holds the configuration for notification email states to indicate whether they are enabled or disabled. +type NotificationsConfiguration struct { + PasswordChangedEnabled bool `json:"password_changed_enabled" split_words:"true" default:"false"` } type ProviderConfiguration struct { @@ -472,9 +480,10 @@ type MailerConfiguration struct { Autoconfirm bool `json:"autoconfirm"` AllowUnverifiedEmailSignIns bool `json:"allow_unverified_email_sign_ins" split_words:"true" default:"false"` - Subjects EmailContentConfiguration `json:"subjects"` - Templates EmailContentConfiguration `json:"templates"` - URLPaths EmailContentConfiguration `json:"url_paths"` + Subjects EmailContentConfiguration `json:"subjects"` + Templates EmailContentConfiguration `json:"templates"` + URLPaths EmailContentConfiguration `json:"url_paths"` + Notifications NotificationsConfiguration `json:"notifications" split_words:"true"` SecureEmailChangeEnabled bool `json:"secure_email_change_enabled" split_words:"true" default:"true"` diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index 5c8602578..ab6f4476d 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -18,6 +18,9 @@ const ( EmailChangeCurrentVerification = "email_change_current" EmailChangeNewVerification = "email_change_new" ReauthenticationVerification = "reauthentication" + + // Account Changes Notifications + PasswordChangedNotification = "password_changed_notification" ) // Mailer defines the interface a mailer must implement. @@ -29,6 +32,9 @@ type Mailer interface { EmailChangeMail(r *http.Request, user *models.User, otpNew, otpCurrent, referrerURL string, externalURL *url.URL) error ReauthenticateMail(r *http.Request, user *models.User, otp string) error GetEmailActionLink(user *models.User, actionType, referrerURL string, externalURL *url.URL) (string, error) + + // Account Changes Notifications + PasswordChangedNotificationMail(r *http.Request, user *models.User) error } // TODO(cstockton): Mail(...) -> Mail(Email{...}) ? diff --git a/internal/mailer/mockclient/mockclient.go b/internal/mailer/mockclient/mockclient.go new file mode 100644 index 000000000..20765eab5 --- /dev/null +++ b/internal/mailer/mockclient/mockclient.go @@ -0,0 +1,164 @@ +package mockclient + +import ( + "net/http" + "net/url" + + "github.com/supabase/auth/internal/models" +) + +// MockMailer implements the mailer.Mailer interface for testing +type MockMailer struct { + InviteMailCalls []InviteMailCall + ConfirmationMailCalls []ConfirmationMailCall + RecoveryMailCalls []RecoveryMailCall + MagicLinkMailCalls []MagicLinkMailCall + EmailChangeMailCalls []EmailChangeMailCall + ReauthenticateMailCalls []ReauthenticateMailCall + GetEmailActionLinkCalls []GetEmailActionLinkCall + PasswordChangedMailCalls []PasswordChangedMailCall +} + +type InviteMailCall struct { + User *models.User + OTP string + ReferrerURL string + ExternalURL *url.URL +} + +type ConfirmationMailCall struct { + User *models.User + OTP string + ReferrerURL string + ExternalURL *url.URL +} + +type RecoveryMailCall struct { + User *models.User + OTP string + ReferrerURL string + ExternalURL *url.URL +} + +type MagicLinkMailCall struct { + User *models.User + OTP string + ReferrerURL string + ExternalURL *url.URL +} + +type EmailChangeMailCall struct { + User *models.User + OTPNew string + OTPCurrent string + ReferrerURL string + ExternalURL *url.URL +} + +type ReauthenticateMailCall struct { + User *models.User + OTP string +} + +type GetEmailActionLinkCall struct { + User *models.User + ActionType string + ReferrerURL string + ExternalURL *url.URL + Result string + Error error +} + +type PasswordChangedMailCall struct { + User *models.User +} + +func (m *MockMailer) InviteMail(r *http.Request, user *models.User, otp, referrerURL string, externalURL *url.URL) error { + m.InviteMailCalls = append(m.InviteMailCalls, InviteMailCall{ + User: user, + OTP: otp, + ReferrerURL: referrerURL, + ExternalURL: externalURL, + }) + return nil +} + +func (m *MockMailer) ConfirmationMail(r *http.Request, user *models.User, otp, referrerURL string, externalURL *url.URL) error { + m.ConfirmationMailCalls = append(m.ConfirmationMailCalls, ConfirmationMailCall{ + User: user, + OTP: otp, + ReferrerURL: referrerURL, + ExternalURL: externalURL, + }) + return nil +} + +func (m *MockMailer) RecoveryMail(r *http.Request, user *models.User, otp, referrerURL string, externalURL *url.URL) error { + m.RecoveryMailCalls = append(m.RecoveryMailCalls, RecoveryMailCall{ + User: user, + OTP: otp, + ReferrerURL: referrerURL, + ExternalURL: externalURL, + }) + return nil +} + +func (m *MockMailer) MagicLinkMail(r *http.Request, user *models.User, otp, referrerURL string, externalURL *url.URL) error { + m.MagicLinkMailCalls = append(m.MagicLinkMailCalls, MagicLinkMailCall{ + User: user, + OTP: otp, + ReferrerURL: referrerURL, + ExternalURL: externalURL, + }) + return nil +} + +func (m *MockMailer) EmailChangeMail(r *http.Request, user *models.User, otpNew, otpCurrent, referrerURL string, externalURL *url.URL) error { + m.EmailChangeMailCalls = append(m.EmailChangeMailCalls, EmailChangeMailCall{ + User: user, + OTPNew: otpNew, + OTPCurrent: otpCurrent, + ReferrerURL: referrerURL, + ExternalURL: externalURL, + }) + return nil +} + +func (m *MockMailer) ReauthenticateMail(r *http.Request, user *models.User, otp string) error { + m.ReauthenticateMailCalls = append(m.ReauthenticateMailCalls, ReauthenticateMailCall{ + User: user, + OTP: otp, + }) + return nil +} + +func (m *MockMailer) GetEmailActionLink(user *models.User, actionType, referrerURL string, externalURL *url.URL) (string, error) { + call := GetEmailActionLinkCall{ + User: user, + ActionType: actionType, + ReferrerURL: referrerURL, + ExternalURL: externalURL, + Result: "http://example.com/action", + Error: nil, + } + m.GetEmailActionLinkCalls = append(m.GetEmailActionLinkCalls, call) + return call.Result, call.Error +} + +func (m *MockMailer) PasswordChangedNotificationMail(r *http.Request, user *models.User) error { + m.PasswordChangedMailCalls = append(m.PasswordChangedMailCalls, PasswordChangedMailCall{ + User: user, + }) + return nil +} + +func (m *MockMailer) Reset() { + m.InviteMailCalls = nil + m.ConfirmationMailCalls = nil + m.RecoveryMailCalls = nil + m.MagicLinkMailCalls = nil + m.EmailChangeMailCalls = nil + m.ReauthenticateMailCalls = nil + m.GetEmailActionLinkCalls = nil + m.PasswordChangedMailCalls = nil +} diff --git a/internal/mailer/templatemailer/template.go b/internal/mailer/templatemailer/template.go index 0263a7700..cb243178a 100644 --- a/internal/mailer/templatemailer/template.go +++ b/internal/mailer/templatemailer/template.go @@ -533,6 +533,10 @@ func lookupEmailContentConfig( return cfg.Reauthentication, true case MagicLinkTemplate: return cfg.MagicLink, true + + // Account Changes Notifications + case PasswordChangedNotificationTemplate: + return cfg.PasswordChangedNotification, true } } diff --git a/internal/mailer/templatemailer/templatemailer.go b/internal/mailer/templatemailer/templatemailer.go index c3c96eccd..9a77592d6 100644 --- a/internal/mailer/templatemailer/templatemailer.go +++ b/internal/mailer/templatemailer/templatemailer.go @@ -18,6 +18,9 @@ const ( EmailChangeTemplate = "email_change" MagicLinkTemplate = "magic_link" ReauthenticationTemplate = "reauthentication" + + // Account Changes Notifications + PasswordChangedNotificationTemplate = "password_changed_notification" ) const defaultInviteMail = `Enter the code: {{ .Token }}
` +// Account Changes Notifications + +// #nosec G101 -- No hardcoded credentials. +const defaultPasswordChangedNotificationMail = `This is a confirmation that the password for your account {{ .Email }} has just been changed. If you did not make this change, please contact support immediately.
+` + var ( templateTypes = []string{ InviteTemplate, @@ -63,6 +74,9 @@ var ( EmailChangeTemplate, MagicLinkTemplate, ReauthenticationTemplate, + + // Account Changes Notifications + PasswordChangedNotificationTemplate, } defaultTemplateSubjects = &conf.EmailContentConfiguration{ Invite: "You have been invited", @@ -71,6 +85,9 @@ var ( MagicLink: "Your Magic Link", EmailChange: "Confirm Email Change", Reauthentication: "Confirm reauthentication", + + // Account Changes Notifications + PasswordChangedNotification: "Your password has been changed", } defaultTemplateBodies = &conf.EmailContentConfiguration{ Invite: defaultInviteMail, @@ -79,6 +96,9 @@ var ( MagicLink: defaultMagicLinkMail, EmailChange: defaultEmailChangeMail, Reauthentication: defaultReauthenticateMail, + + // Account Changes Notifications + PasswordChangedNotification: defaultPasswordChangedNotificationMail, } ) @@ -343,6 +363,15 @@ func (m *Mailer) GetEmailActionLink(user *models.User, actionType, referrerURL s return externalURL.ResolveReference(path).String(), nil } +func (m *Mailer) PasswordChangedNotificationMail(r *http.Request, user *models.User) error { + data := map[string]any{ + "Email": user.Email, + "Data": user.UserMetaData, + "SendingTo": user.Email, + } + return m.mail(r.Context(), m.cfg, PasswordChangedNotificationTemplate, user.GetEmail(), data) +} + type emailParams struct { Token string Type string diff --git a/internal/reloader/testdata/50_example.env b/internal/reloader/testdata/50_example.env index 34631b5de..20e496af9 100644 --- a/internal/reloader/testdata/50_example.env +++ b/internal/reloader/testdata/50_example.env @@ -35,6 +35,7 @@ GOTRUE_MAILER_SUBJECTS_RECOVERY="Reset Your Password" GOTRUE_MAILER_SUBJECTS_MAGIC_LINK="Your Magic Link" GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE="Confirm Email Change" GOTRUE_MAILER_SUBJECTS_INVITE="You have been invited" +GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION="Your password has been changed" GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true" # Custom mailer template config @@ -43,6 +44,10 @@ GOTRUE_MAILER_TEMPLATES_CONFIRMATION="" GOTRUE_MAILER_TEMPLATES_RECOVERY="" GOTRUE_MAILER_TEMPLATES_MAGIC_LINK="" GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE="" +GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION="" + +# Account changes notifications configuration +GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED="false" # Signup config GOTRUE_DISABLE_SIGNUP="false" diff --git a/internal/reloader/testdata/60_example_newline.env b/internal/reloader/testdata/60_example_newline.env index 780de281f..185199290 100644 --- a/internal/reloader/testdata/60_example_newline.env +++ b/internal/reloader/testdata/60_example_newline.env @@ -35,6 +35,7 @@ GOTRUE_MAILER_SUBJECTS_RECOVERY="Reset Your Password" GOTRUE_MAILER_SUBJECTS_MAGIC_LINK="Your Magic Link" GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE="Confirm Email Change" GOTRUE_MAILER_SUBJECTS_INVITE="You have been invited" +GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION="Your password has been changed" GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true" # Custom mailer template config @@ -43,6 +44,10 @@ GOTRUE_MAILER_TEMPLATES_CONFIRMATION="" GOTRUE_MAILER_TEMPLATES_RECOVERY="" GOTRUE_MAILER_TEMPLATES_MAGIC_LINK="" GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE="" +GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION="" + +# Account changes notifications configuration +GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED="false" # Signup config GOTRUE_DISABLE_SIGNUP="false"