Skip to content

Commit 21f3070

Browse files
authored
feat: notify users when their phone number has changed (#2184)
This PR adds support for sending the user an email notification when their phone number has been changed. 3 new environment variables are introduced: - `GOTRUE_MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION`: Email subject to use for phone changed notification. - `GOTRUE_MAILER_TEMPLATES_PHONE_CHANGED_NOTIFICATION`: The URL to specify a custom template. - `GOTRUE_MAILER_NOTIFICATIONS_PHONE_CHANGED_ENABLED`: whether the notification is enabled or not. The feature is disabled by default. To enable it, the `GOTRUE_MAILER_NOTIFICATIONS_PHONE_CHANGED_ENABLED` environment variable must be set to `true`.
1 parent 53db712 commit 21f3070

File tree

10 files changed

+253
-2
lines changed

10 files changed

+253
-2
lines changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,27 @@ Default Content (if template is unavailable):
719719

720720
Whether to send a notification email when a user's email is changed. Defaults to `false`.
721721

722+
`GOTRUE_MAILER_TEMPLATES_PHONE_CHANGED_NOTIFICATION` - `string`
723+
724+
URL path to an email template to use when notifying a user that their phone number has been changed. (e.g. `https://www.example.com/path-to-email-template.html`)
725+
`Email`, `Phone`, and `OldPhone` variables are available.
726+
727+
Default Content (if template is unavailable):
728+
729+
```html
730+
<h2>Your phone number has been changed</h2>
731+
732+
<p>
733+
The phone number for your account {{ .Email }} has been changed from {{
734+
.OldPhone }} to {{ .Phone }}.
735+
</p>
736+
<p>If you did not make this change, please contact support immediately.</p>
737+
```
738+
739+
`GOTRUE_MAILER_NOTIFICATIONS_PHONE_CHANGED_ENABLED` - `bool`
740+
741+
Whether to send a notification email when a user's phone number is changed. Defaults to `false`.
742+
722743
`GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION` - `string`
723744

724745
URL path to an email template to use when notifying a user that they have enrolled in a new MFA factor. (e.g. `https://www.example.com/path-to-email-template.html`)

example.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE="Confirm Email Change"
3737
GOTRUE_MAILER_SUBJECTS_INVITE="You have been invited"
3838
GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION="Your password has been changed"
3939
GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION="Your email address has been changed"
40+
GOTRUE_MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION="Your phone number has been changed"
4041
GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION="MFA factor enrolled"
4142
GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION="MFA factor unenrolled"
4243
GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true"
@@ -49,12 +50,14 @@ GOTRUE_MAILER_TEMPLATES_MAGIC_LINK=""
4950
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE=""
5051
GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION=""
5152
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION=""
53+
GOTRUE_MAILER_TEMPLATES_PHONE_CHANGED_NOTIFICATION=""
5254
GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION=""
5355
GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_UNENROLLED_NOTIFICATION=""
5456

5557
# Account changes notifications configuration
5658
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED="false"
5759
GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED="false"
60+
GOTRUE_MAILER_NOTIFICATIONS_PHONE_CHANGED_ENABLED="false"
5861
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED="false"
5962
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED="false"
6063

internal/api/mail.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,23 @@ func (a *API) sendEmailChangedNotification(r *http.Request, tx *storage.Connecti
617617
return nil
618618
}
619619

620+
func (a *API) sendPhoneChangedNotification(r *http.Request, tx *storage.Connection, u *models.User, oldPhone string) error {
621+
err := a.sendEmail(r, tx, u, sendEmailParams{
622+
emailActionType: mail.PhoneChangedNotification,
623+
oldPhone: oldPhone,
624+
})
625+
if err != nil {
626+
if errors.Is(err, EmailRateLimitExceeded) {
627+
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error())
628+
} else if herr, ok := err.(*HTTPError); ok {
629+
return herr
630+
}
631+
return apierrors.NewInternalServerError("Error sending phone changed notification email").WithInternalError(err)
632+
}
633+
634+
return nil
635+
}
636+
620637
func (a *API) sendMFAFactorEnrolledNotification(r *http.Request, tx *storage.Connection, u *models.User, factorType string) error {
621638
err := a.sendEmail(r, tx, u, sendEmailParams{
622639
emailActionType: mail.MFAFactorEnrolledNotification,
@@ -697,6 +714,7 @@ type sendEmailParams struct {
697714
otpNew string
698715
tokenHashWithPrefix string
699716
oldEmail string
717+
oldPhone string
700718
factorType string
701719
}
702720

@@ -818,6 +836,8 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User,
818836
err = mr.PasswordChangedNotificationMail(r, u)
819837
case mail.EmailChangedNotification:
820838
err = mr.EmailChangedNotificationMail(r, u, params.oldEmail)
839+
case mail.PhoneChangedNotification:
840+
err = mr.PhoneChangedNotificationMail(r, u, params.oldPhone)
821841
case mail.MFAFactorEnrolledNotification:
822842
err = mr.MFAFactorEnrolledNotificationMail(r, u, params.factorType)
823843
case mail.MFAFactorUnenrolledNotification:

internal/api/verify.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ func (a *API) recoverVerify(r *http.Request, conn *storage.Connection, user *mod
402402
func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models.User, params *VerifyParams) (*models.User, error) {
403403
config := a.config
404404

405+
oldPhone := user.GetPhone()
405406
err := conn.Transaction(func(tx *storage.Connection) error {
406407

407408
if params.Type == smsVerification {
@@ -457,6 +458,15 @@ func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models.
457458
if err != nil {
458459
return nil, err
459460
}
461+
462+
// Send phone changed notification email if enabled and phone was changed
463+
if params.Type == phoneChangeVerification && config.Mailer.Notifications.PhoneChangedEnabled && user.GetEmail() != "" && phoneNumberChanged(oldPhone, user.GetPhone()) {
464+
if err := a.sendPhoneChangedNotification(r, conn, user, oldPhone); err != nil {
465+
// Log the error but don't fail the verification
466+
logrus.WithError(err).Warn("Unable to send phone changed notification email")
467+
}
468+
}
469+
460470
return user, nil
461471
}
462472

@@ -605,8 +615,8 @@ func (a *API) emailChangeVerify(r *http.Request, conn *storage.Connection, param
605615
return nil, err
606616
}
607617

608-
// send an Email Changed email notification to the user
609-
if config.Mailer.Notifications.EmailChangedEnabled && user.GetEmail() != "" {
618+
// send an Email Changed email notification to the user's old email address
619+
if config.Mailer.Notifications.EmailChangedEnabled && emailAddressChanged(oldEmail, user.GetEmail()) {
610620
if err := a.sendEmailChangedNotification(r, conn, user, oldEmail); err != nil {
611621
// we don't want to fail the whole request if the email can't be sent
612622
logrus.WithError(err).Warn("Unable to send email changed notification")
@@ -781,3 +791,13 @@ func isEmailOtpVerification(params *VerifyParams) bool {
781791
func isUsingTokenHash(params *VerifyParams) bool {
782792
return params.TokenHash != "" && params.Token == "" && params.Phone == "" && params.Email == ""
783793
}
794+
795+
// emailAddressChanged checks if the email address has changed, ensuring neither is empty
796+
func emailAddressChanged(oldEmail, newEmail string) bool {
797+
return oldEmail != "" && newEmail != "" && oldEmail != newEmail
798+
}
799+
800+
// phoneNumberChanged checks if the phone number has changed, ensuring neither is empty
801+
func phoneNumberChanged(oldPhone, newPhone string) bool {
802+
return oldPhone != "" && newPhone != "" && oldPhone != newPhone
803+
}

internal/api/verify_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/supabase/auth/internal/api/apierrors"
1616
mail "github.com/supabase/auth/internal/mailer"
1717
"github.com/supabase/auth/internal/mailer/mockclient"
18+
"github.com/supabase/auth/internal/storage"
1819

1920
"github.com/stretchr/testify/assert"
2021
"github.com/stretchr/testify/require"
@@ -1409,3 +1410,147 @@ func (ts *VerifyTestSuite) TestVeryEmailChangeSendsNotificationEmail() {
14091410
})
14101411
}
14111412
}
1413+
1414+
func (ts *VerifyTestSuite) TestVerifyPhoneChangeSendsNotificationEmailEnabled() {
1415+
ts.Config.Mailer.Notifications.PhoneChangedEnabled = true
1416+
1417+
u, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
1418+
require.NoError(ts.T(), err)
1419+
u.Phone = "12345678"
1420+
u.PhoneChange = "1234567890"
1421+
require.NoError(ts.T(), ts.API.db.Update(u))
1422+
1423+
body := map[string]interface{}{
1424+
"type": phoneChangeVerification,
1425+
"token": "123456",
1426+
"phone": u.PhoneChange,
1427+
}
1428+
sentTime := time.Now()
1429+
expectedTokenHash := crypto.GenerateTokenHash(u.PhoneChange, "123456")
1430+
1431+
// Get the mock mailer and reset it
1432+
mockMailer, ok := ts.Mailer.(*mockclient.MockMailer)
1433+
require.True(ts.T(), ok, "Mailer is not of type *MockMailer")
1434+
mockMailer.Reset()
1435+
1436+
// create user
1437+
require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID))
1438+
1439+
u.PhoneChangeSentAt = &sentTime
1440+
u.PhoneChangeToken = expectedTokenHash
1441+
1442+
require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.PhoneChangeToken, models.PhoneChangeToken))
1443+
require.NoError(ts.T(), ts.API.db.Update(u))
1444+
1445+
var buffer bytes.Buffer
1446+
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(body))
1447+
1448+
// Setup request
1449+
req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
1450+
req.Header.Set("Content-Type", "application/json")
1451+
1452+
// Setup response recorder
1453+
w := httptest.NewRecorder()
1454+
ts.API.handler.ServeHTTP(w, req)
1455+
assert.Equal(ts.T(), http.StatusOK, w.Code)
1456+
1457+
// Assert that phone change notification email was sent
1458+
require.Len(ts.T(), mockMailer.PhoneChangedMailCalls, 1, "Expected 1 phone change notification email(s) to be sent")
1459+
require.Equal(ts.T(), u.ID, mockMailer.PhoneChangedMailCalls[0].User.ID, "Email should be sent to the correct user")
1460+
require.Equal(ts.T(), "12345678", mockMailer.PhoneChangedMailCalls[0].OldPhone, "Old phone should match")
1461+
require.Equal(ts.T(), "[email protected]", mockMailer.PhoneChangedMailCalls[0].User.GetEmail(), "Email should be sent to the correct email address")
1462+
}
1463+
1464+
func (ts *VerifyTestSuite) TestVerifyPhoneChangeSendsNotificationEmailEnabled_NoChange() {
1465+
ts.Config.Mailer.Notifications.PhoneChangedEnabled = true
1466+
1467+
u, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
1468+
require.NoError(ts.T(), err)
1469+
u.Phone = storage.NullString("")
1470+
u.PhoneChange = "1234567890"
1471+
require.NoError(ts.T(), ts.API.db.Update(u))
1472+
1473+
body := map[string]interface{}{
1474+
"type": phoneChangeVerification,
1475+
"token": "123456",
1476+
"phone": u.PhoneChange,
1477+
}
1478+
sentTime := time.Now()
1479+
expectedTokenHash := crypto.GenerateTokenHash(u.PhoneChange, "123456")
1480+
1481+
// Get the mock mailer and reset it
1482+
mockMailer, ok := ts.Mailer.(*mockclient.MockMailer)
1483+
require.True(ts.T(), ok, "Mailer is not of type *MockMailer")
1484+
mockMailer.Reset()
1485+
1486+
// create user
1487+
require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID))
1488+
1489+
u.PhoneChangeSentAt = &sentTime
1490+
u.PhoneChangeToken = expectedTokenHash
1491+
1492+
require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.PhoneChangeToken, models.PhoneChangeToken))
1493+
require.NoError(ts.T(), ts.API.db.Update(u))
1494+
1495+
var buffer bytes.Buffer
1496+
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(body))
1497+
1498+
// Setup request
1499+
req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
1500+
req.Header.Set("Content-Type", "application/json")
1501+
1502+
// Setup response recorder
1503+
w := httptest.NewRecorder()
1504+
ts.API.handler.ServeHTTP(w, req)
1505+
assert.Equal(ts.T(), http.StatusOK, w.Code)
1506+
1507+
// Assert that phone change notification email was sent
1508+
require.Len(ts.T(), mockMailer.PhoneChangedMailCalls, 0, "Expected 0 phone change notification email(s) to be sent")
1509+
}
1510+
1511+
func (ts *VerifyTestSuite) TestVerifyPhoneChangeSendsNotificationEmailDisabled() {
1512+
ts.Config.Mailer.Notifications.PhoneChangedEnabled = false
1513+
1514+
u, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
1515+
require.NoError(ts.T(), err)
1516+
u.Phone = "12345678"
1517+
u.PhoneChange = "1234567890"
1518+
require.NoError(ts.T(), ts.API.db.Update(u))
1519+
1520+
body := map[string]interface{}{
1521+
"type": phoneChangeVerification,
1522+
"token": "123456",
1523+
"phone": u.PhoneChange,
1524+
}
1525+
sentTime := time.Now()
1526+
expectedTokenHash := crypto.GenerateTokenHash(u.PhoneChange, "123456")
1527+
1528+
// Get the mock mailer and reset it
1529+
mockMailer, ok := ts.Mailer.(*mockclient.MockMailer)
1530+
require.True(ts.T(), ok, "Mailer is not of type *MockMailer")
1531+
mockMailer.Reset()
1532+
1533+
// create user
1534+
require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID))
1535+
1536+
u.PhoneChangeSentAt = &sentTime
1537+
u.PhoneChangeToken = expectedTokenHash
1538+
1539+
require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.PhoneChangeToken, models.PhoneChangeToken))
1540+
require.NoError(ts.T(), ts.API.db.Update(u))
1541+
1542+
var buffer bytes.Buffer
1543+
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(body))
1544+
1545+
// Setup request
1546+
req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
1547+
req.Header.Set("Content-Type", "application/json")
1548+
1549+
// Setup response recorder
1550+
w := httptest.NewRecorder()
1551+
ts.API.handler.ServeHTTP(w, req)
1552+
assert.Equal(ts.T(), http.StatusOK, w.Code)
1553+
1554+
// Assert that phone change notification email was not sent
1555+
require.Len(ts.T(), mockMailer.PhoneChangedMailCalls, 0, "Expected 0 phone change notification email(s) to be sent")
1556+
}

internal/conf/configuration.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ type EmailContentConfiguration struct {
393393
// Account Changes Notifications
394394
PasswordChangedNotification string `json:"password_changed_notification" split_words:"true"`
395395
EmailChangedNotification string `json:"email_changed_notification" split_words:"true"`
396+
PhoneChangedNotification string `json:"phone_changed_notification" split_words:"true"`
396397
MFAFactorEnrolledNotification string `json:"mfa_factor_enrolled_notification" split_words:"true"`
397398
MFAFactorUnenrolledNotification string `json:"mfa_factor_unenrolled_notification" split_words:"true"`
398399
}
@@ -401,6 +402,7 @@ type EmailContentConfiguration struct {
401402
type NotificationsConfiguration struct {
402403
PasswordChangedEnabled bool `json:"password_changed_enabled" split_words:"true" default:"false"`
403404
EmailChangedEnabled bool `json:"email_changed_enabled" split_words:"true" default:"false"`
405+
PhoneChangedEnabled bool `json:"phone_changed_enabled" split_words:"true" default:"false"`
404406
MFAFactorEnrolledEnabled bool `json:"mfa_factor_enrolled_enabled" split_words:"true" default:"false"`
405407
MFAFactorUnenrolledEnabled bool `json:"mfa_factor_unenrolled_enabled" split_words:"true" default:"false"`
406408
}

internal/mailer/mailer.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const (
2222
// Account Changes Notifications
2323
PasswordChangedNotification = "password_changed_notification"
2424
EmailChangedNotification = "email_changed_notification"
25+
PhoneChangedNotification = "phone_changed_notification"
2526
MFAFactorEnrolledNotification = "mfa_factor_enrolled_notification"
2627
MFAFactorUnenrolledNotification = "mfa_factor_unenrolled_notification"
2728
)
@@ -39,6 +40,7 @@ type Mailer interface {
3940
// Account Changes Notifications
4041
PasswordChangedNotificationMail(r *http.Request, user *models.User) error
4142
EmailChangedNotificationMail(r *http.Request, user *models.User, oldEmail string) error
43+
PhoneChangedNotificationMail(r *http.Request, user *models.User, oldPhone string) error
4244
MFAFactorEnrolledNotificationMail(r *http.Request, user *models.User, factorType string) error
4345
MFAFactorUnenrolledNotificationMail(r *http.Request, user *models.User, factorType string) error
4446
}

internal/mailer/mockclient/mockclient.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type MockMailer struct {
1919

2020
PasswordChangedMailCalls []PasswordChangedMailCall
2121
EmailChangedMailCalls []EmailChangedMailCall
22+
PhoneChangedMailCalls []PhoneChangedMailCall
2223
MFAFactorEnrolledMailCalls []MFAFactorEnrolledMailCall
2324
MFAFactorUnenrolledMailCalls []MFAFactorUnenrolledMailCall
2425
}
@@ -82,6 +83,11 @@ type EmailChangedMailCall struct {
8283
OldEmail string
8384
}
8485

86+
type PhoneChangedMailCall struct {
87+
User *models.User
88+
OldPhone string
89+
}
90+
8591
type MFAFactorEnrolledMailCall struct {
8692
User *models.User
8793
FactorType string
@@ -179,6 +185,14 @@ func (m *MockMailer) EmailChangedNotificationMail(r *http.Request, user *models.
179185
return nil
180186
}
181187

188+
func (m *MockMailer) PhoneChangedNotificationMail(r *http.Request, user *models.User, oldPhone string) error {
189+
m.PhoneChangedMailCalls = append(m.PhoneChangedMailCalls, PhoneChangedMailCall{
190+
User: user,
191+
OldPhone: oldPhone,
192+
})
193+
return nil
194+
}
195+
182196
func (m *MockMailer) MFAFactorEnrolledNotificationMail(r *http.Request, user *models.User, factorType string) error {
183197
m.MFAFactorEnrolledMailCalls = append(m.MFAFactorEnrolledMailCalls, MFAFactorEnrolledMailCall{
184198
User: user,
@@ -206,6 +220,7 @@ func (m *MockMailer) Reset() {
206220

207221
m.PasswordChangedMailCalls = nil
208222
m.EmailChangedMailCalls = nil
223+
m.PhoneChangedMailCalls = nil
209224
m.MFAFactorEnrolledMailCalls = nil
210225
m.MFAFactorUnenrolledMailCalls = nil
211226
}

internal/mailer/templatemailer/template.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,8 @@ func lookupEmailContentConfig(
539539
return cfg.PasswordChangedNotification, true
540540
case EmailChangedNotificationTemplate:
541541
return cfg.EmailChangedNotification, true
542+
case PhoneChangedNotificationTemplate:
543+
return cfg.PhoneChangedNotification, true
542544
case MFAFactorEnrolledNotificationTemplate:
543545
return cfg.MFAFactorEnrolledNotification, true
544546
case MFAFactorUnenrolledNotificationTemplate:

0 commit comments

Comments
 (0)