Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,27 @@ Default Content (if template is unavailable):

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

`GOTRUE_MAILER_TEMPLATES_PHONE_CHANGED_NOTIFICATION` - `string`

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`)
`Email`, `Phone`, and `OldPhone` variables are available.

Default Content (if template is unavailable):

```html
<h2>Your phone number has been changed</h2>

<p>
The phone number for your account {{ .Email }} has been changed from {{
.OldPhone }} to {{ .Phone }}.
</p>
<p>If you did not make this change, please contact support immediately.</p>
```

`GOTRUE_MAILER_NOTIFICATIONS_PHONE_CHANGED_ENABLED` - `bool`

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

`GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION` - `string`

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`)
Expand Down
3 changes: 3 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ 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_SUBJECTS_EMAIL_CHANGED_NOTIFICATION="Your email address has been changed"
GOTRUE_MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION="Your phone number has been changed"
GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION="MFA factor enrolled"
GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION="MFA factor unenrolled"
GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true"
Expand All @@ -49,12 +50,14 @@ GOTRUE_MAILER_TEMPLATES_MAGIC_LINK=""
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE=""
GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION=""
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION=""
GOTRUE_MAILER_TEMPLATES_PHONE_CHANGED_NOTIFICATION=""
GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION=""
GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_UNENROLLED_NOTIFICATION=""

# Account changes notifications configuration
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED="false"
GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED="false"
GOTRUE_MAILER_NOTIFICATIONS_PHONE_CHANGED_ENABLED="false"
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED="false"
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED="false"

Expand Down
20 changes: 20 additions & 0 deletions internal/api/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,23 @@ func (a *API) sendEmailChangedNotification(r *http.Request, tx *storage.Connecti
return nil
}

func (a *API) sendPhoneChangedNotification(r *http.Request, tx *storage.Connection, u *models.User, oldPhone string) error {
err := a.sendEmail(r, tx, u, sendEmailParams{
emailActionType: mail.PhoneChangedNotification,
oldPhone: oldPhone,
})
if 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 phone changed notification email").WithInternalError(err)
}

return nil
}

func (a *API) sendMFAFactorEnrolledNotification(r *http.Request, tx *storage.Connection, u *models.User, factorType string) error {
err := a.sendEmail(r, tx, u, sendEmailParams{
emailActionType: mail.MFAFactorEnrolledNotification,
Expand Down Expand Up @@ -697,6 +714,7 @@ type sendEmailParams struct {
otpNew string
tokenHashWithPrefix string
oldEmail string
oldPhone string
factorType string
}

Expand Down Expand Up @@ -818,6 +836,8 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User,
err = mr.PasswordChangedNotificationMail(r, u)
case mail.EmailChangedNotification:
err = mr.EmailChangedNotificationMail(r, u, params.oldEmail)
case mail.PhoneChangedNotification:
err = mr.PhoneChangedNotificationMail(r, u, params.oldPhone)
case mail.MFAFactorEnrolledNotification:
err = mr.MFAFactorEnrolledNotificationMail(r, u, params.factorType)
case mail.MFAFactorUnenrolledNotification:
Expand Down
24 changes: 22 additions & 2 deletions internal/api/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ func (a *API) recoverVerify(r *http.Request, conn *storage.Connection, user *mod
func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models.User, params *VerifyParams) (*models.User, error) {
config := a.config

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

if params.Type == smsVerification {
Expand Down Expand Up @@ -457,6 +458,15 @@ func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models.
if err != nil {
return nil, err
}

// Send phone changed notification email if enabled and phone was changed
if params.Type == phoneChangeVerification && config.Mailer.Notifications.PhoneChangedEnabled && user.GetEmail() != "" && phoneNumberChanged(oldPhone, user.GetPhone()) {
if err := a.sendPhoneChangedNotification(r, conn, user, oldPhone); err != nil {
// Log the error but don't fail the verification
logrus.WithError(err).Warn("Unable to send phone changed notification email")
}
}

return user, nil
}

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

// send an Email Changed email notification to the user
if config.Mailer.Notifications.EmailChangedEnabled && user.GetEmail() != "" {
// send an Email Changed email notification to the user's old email address
if config.Mailer.Notifications.EmailChangedEnabled && emailAddressChanged(oldEmail, user.GetEmail()) {
if err := a.sendEmailChangedNotification(r, conn, user, oldEmail); 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 email changed notification")
Expand Down Expand Up @@ -781,3 +791,13 @@ func isEmailOtpVerification(params *VerifyParams) bool {
func isUsingTokenHash(params *VerifyParams) bool {
return params.TokenHash != "" && params.Token == "" && params.Phone == "" && params.Email == ""
}

// emailAddressChanged checks if the email address has changed, ensuring neither is empty
func emailAddressChanged(oldEmail, newEmail string) bool {
return oldEmail != "" && newEmail != "" && oldEmail != newEmail
}

// phoneNumberChanged checks if the phone number has changed, ensuring neither is empty
func phoneNumberChanged(oldPhone, newPhone string) bool {
return oldPhone != "" && newPhone != "" && oldPhone != newPhone
}
145 changes: 145 additions & 0 deletions internal/api/verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/supabase/auth/internal/api/apierrors"
mail "github.com/supabase/auth/internal/mailer"
"github.com/supabase/auth/internal/mailer/mockclient"
"github.com/supabase/auth/internal/storage"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -1409,3 +1410,147 @@ func (ts *VerifyTestSuite) TestVeryEmailChangeSendsNotificationEmail() {
})
}
}

func (ts *VerifyTestSuite) TestVerifyPhoneChangeSendsNotificationEmailEnabled() {
ts.Config.Mailer.Notifications.PhoneChangedEnabled = true

u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
require.NoError(ts.T(), err)
u.Phone = "12345678"
u.PhoneChange = "1234567890"
require.NoError(ts.T(), ts.API.db.Update(u))

body := map[string]interface{}{
"type": phoneChangeVerification,
"token": "123456",
"phone": u.PhoneChange,
}
sentTime := time.Now()
expectedTokenHash := crypto.GenerateTokenHash(u.PhoneChange, "123456")

// 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()

// create user
require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID))

u.PhoneChangeSentAt = &sentTime
u.PhoneChangeToken = expectedTokenHash

require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.PhoneChangeToken, models.PhoneChangeToken))
require.NoError(ts.T(), ts.API.db.Update(u))

var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(body))

// Setup request
req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
req.Header.Set("Content-Type", "application/json")

// Setup response recorder
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
assert.Equal(ts.T(), http.StatusOK, w.Code)

// Assert that phone change notification email was sent
require.Len(ts.T(), mockMailer.PhoneChangedMailCalls, 1, "Expected 1 phone change notification email(s) to be sent")
require.Equal(ts.T(), u.ID, mockMailer.PhoneChangedMailCalls[0].User.ID, "Email should be sent to the correct user")
require.Equal(ts.T(), "12345678", mockMailer.PhoneChangedMailCalls[0].OldPhone, "Old phone should match")
require.Equal(ts.T(), "test@example.com", mockMailer.PhoneChangedMailCalls[0].User.GetEmail(), "Email should be sent to the correct email address")
}

func (ts *VerifyTestSuite) TestVerifyPhoneChangeSendsNotificationEmailEnabled_NoChange() {
ts.Config.Mailer.Notifications.PhoneChangedEnabled = true

u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
require.NoError(ts.T(), err)
u.Phone = storage.NullString("")
u.PhoneChange = "1234567890"
require.NoError(ts.T(), ts.API.db.Update(u))

body := map[string]interface{}{
"type": phoneChangeVerification,
"token": "123456",
"phone": u.PhoneChange,
}
sentTime := time.Now()
expectedTokenHash := crypto.GenerateTokenHash(u.PhoneChange, "123456")

// 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()

// create user
require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID))

u.PhoneChangeSentAt = &sentTime
u.PhoneChangeToken = expectedTokenHash

require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.PhoneChangeToken, models.PhoneChangeToken))
require.NoError(ts.T(), ts.API.db.Update(u))

var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(body))

// Setup request
req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
req.Header.Set("Content-Type", "application/json")

// Setup response recorder
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
assert.Equal(ts.T(), http.StatusOK, w.Code)

// Assert that phone change notification email was sent
require.Len(ts.T(), mockMailer.PhoneChangedMailCalls, 0, "Expected 0 phone change notification email(s) to be sent")
}

func (ts *VerifyTestSuite) TestVerifyPhoneChangeSendsNotificationEmailDisabled() {
ts.Config.Mailer.Notifications.PhoneChangedEnabled = false

u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
require.NoError(ts.T(), err)
u.Phone = "12345678"
u.PhoneChange = "1234567890"
require.NoError(ts.T(), ts.API.db.Update(u))

body := map[string]interface{}{
"type": phoneChangeVerification,
"token": "123456",
"phone": u.PhoneChange,
}
sentTime := time.Now()
expectedTokenHash := crypto.GenerateTokenHash(u.PhoneChange, "123456")

// 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()

// create user
require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID))

u.PhoneChangeSentAt = &sentTime
u.PhoneChangeToken = expectedTokenHash

require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.PhoneChangeToken, models.PhoneChangeToken))
require.NoError(ts.T(), ts.API.db.Update(u))

var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(body))

// Setup request
req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
req.Header.Set("Content-Type", "application/json")

// Setup response recorder
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
assert.Equal(ts.T(), http.StatusOK, w.Code)

// Assert that phone change notification email was not sent
require.Len(ts.T(), mockMailer.PhoneChangedMailCalls, 0, "Expected 0 phone change notification email(s) to be sent")
}
2 changes: 2 additions & 0 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ type EmailContentConfiguration struct {
// Account Changes Notifications
PasswordChangedNotification string `json:"password_changed_notification" split_words:"true"`
EmailChangedNotification string `json:"email_changed_notification" split_words:"true"`
PhoneChangedNotification string `json:"phone_changed_notification" split_words:"true"`
MFAFactorEnrolledNotification string `json:"mfa_factor_enrolled_notification" split_words:"true"`
MFAFactorUnenrolledNotification string `json:"mfa_factor_unenrolled_notification" split_words:"true"`
}
Expand All @@ -401,6 +402,7 @@ type EmailContentConfiguration struct {
type NotificationsConfiguration struct {
PasswordChangedEnabled bool `json:"password_changed_enabled" split_words:"true" default:"false"`
EmailChangedEnabled bool `json:"email_changed_enabled" split_words:"true" default:"false"`
PhoneChangedEnabled bool `json:"phone_changed_enabled" split_words:"true" default:"false"`
MFAFactorEnrolledEnabled bool `json:"mfa_factor_enrolled_enabled" split_words:"true" default:"false"`
MFAFactorUnenrolledEnabled bool `json:"mfa_factor_unenrolled_enabled" split_words:"true" default:"false"`
}
Expand Down
2 changes: 2 additions & 0 deletions internal/mailer/mailer.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const (
// Account Changes Notifications
PasswordChangedNotification = "password_changed_notification"
EmailChangedNotification = "email_changed_notification"
PhoneChangedNotification = "phone_changed_notification"
MFAFactorEnrolledNotification = "mfa_factor_enrolled_notification"
MFAFactorUnenrolledNotification = "mfa_factor_unenrolled_notification"
)
Expand All @@ -39,6 +40,7 @@ type Mailer interface {
// Account Changes Notifications
PasswordChangedNotificationMail(r *http.Request, user *models.User) error
EmailChangedNotificationMail(r *http.Request, user *models.User, oldEmail string) error
PhoneChangedNotificationMail(r *http.Request, user *models.User, oldPhone string) error
MFAFactorEnrolledNotificationMail(r *http.Request, user *models.User, factorType string) error
MFAFactorUnenrolledNotificationMail(r *http.Request, user *models.User, factorType string) error
}
Expand Down
15 changes: 15 additions & 0 deletions internal/mailer/mockclient/mockclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type MockMailer struct {

PasswordChangedMailCalls []PasswordChangedMailCall
EmailChangedMailCalls []EmailChangedMailCall
PhoneChangedMailCalls []PhoneChangedMailCall
MFAFactorEnrolledMailCalls []MFAFactorEnrolledMailCall
MFAFactorUnenrolledMailCalls []MFAFactorUnenrolledMailCall
}
Expand Down Expand Up @@ -82,6 +83,11 @@ type EmailChangedMailCall struct {
OldEmail string
}

type PhoneChangedMailCall struct {
User *models.User
OldPhone string
}

type MFAFactorEnrolledMailCall struct {
User *models.User
FactorType string
Expand Down Expand Up @@ -179,6 +185,14 @@ func (m *MockMailer) EmailChangedNotificationMail(r *http.Request, user *models.
return nil
}

func (m *MockMailer) PhoneChangedNotificationMail(r *http.Request, user *models.User, oldPhone string) error {
m.PhoneChangedMailCalls = append(m.PhoneChangedMailCalls, PhoneChangedMailCall{
User: user,
OldPhone: oldPhone,
})
return nil
}

func (m *MockMailer) MFAFactorEnrolledNotificationMail(r *http.Request, user *models.User, factorType string) error {
m.MFAFactorEnrolledMailCalls = append(m.MFAFactorEnrolledMailCalls, MFAFactorEnrolledMailCall{
User: user,
Expand Down Expand Up @@ -206,6 +220,7 @@ func (m *MockMailer) Reset() {

m.PasswordChangedMailCalls = nil
m.EmailChangedMailCalls = nil
m.PhoneChangedMailCalls = nil
m.MFAFactorEnrolledMailCalls = nil
m.MFAFactorUnenrolledMailCalls = nil
}
2 changes: 2 additions & 0 deletions internal/mailer/templatemailer/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,8 @@ func lookupEmailContentConfig(
return cfg.PasswordChangedNotification, true
case EmailChangedNotificationTemplate:
return cfg.EmailChangedNotification, true
case PhoneChangedNotificationTemplate:
return cfg.PhoneChangedNotification, true
case MFAFactorEnrolledNotificationTemplate:
return cfg.MFAFactorEnrolledNotification, true
case MFAFactorUnenrolledNotificationTemplate:
Expand Down
Loading