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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,47 @@ Default Content (if template is unavailable):

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

`GOTRUE_MAILER_TEMPLATES_IDENTITY_LINKED_NOTIFICATION` - `string`

URL path to an email template to use when notifying a user that a new identity has been linked to their account. (e.g. `https://www.example.com/path-to-email-template.html`)
`Email` and `Provider` variables are available.

Default Content (if template is unavailable):

```html
<h2>A new identity has been linked</h2>

<p>
A new identity ({{ .Provider }}) has been linked to your account {{ .Email }}.
</p>
<p>If you did not make this change, please contact support immediately.</p>
```

`GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_LINKED_ENABLED` - `bool`

Whether to send a notification email when a new identity is linked to a user's account. Defaults to `false`.

`GOTRUE_MAILER_TEMPLATES_IDENTITY_UNLINKED_NOTIFICATION` - `string`

URL path to an email template to use when notifying a user that an identity has been unlinked from their account. (e.g. `https://www.example.com/path-to-email-template.html`)
`Email` and `Provider` variables are available.

Default Content (if template is unavailable):

```html
<h2>An identity has been unlinked</h2>

<p>
An identity ({{ .Provider }}) has been unlinked from your account {{ .Email
}}.
</p>
<p>If you did not make this change, please contact support immediately.</p>
```

`GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_UNLINKED_ENABLED` - `bool`

Whether to send a notification email when an identity is unlinked from a user's account. 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
6 changes: 6 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ 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_IDENTITY_LINKED_NOTIFICATION="A new identity has been linked"
GOTRUE_MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION="An identity has been unlinked"
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 @@ -51,13 +53,17 @@ 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_IDENTITY_LINKED_NOTIFICATION=""
GOTRUE_MAILER_TEMPLATES_IDENTITY_UNLINKED_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_IDENTITY_LINKED_ENABLED="false"
GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_UNLINKED_ENABLED="false"
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED="false"
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED="false"

Expand Down
19 changes: 19 additions & 0 deletions internal/api/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/fatih/structs"
"github.com/go-chi/chi/v5"
"github.com/gofrs/uuid"
"github.com/sirupsen/logrus"
"github.com/supabase/auth/internal/api/apierrors"
"github.com/supabase/auth/internal/api/provider"
"github.com/supabase/auth/internal/models"
Expand Down Expand Up @@ -50,6 +51,7 @@ func (a *API) DeleteIdentity(w http.ResponseWriter, r *http.Request) error {
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeIdentityNotFound, "Identity doesn't exist")
}

provider := identityToBeDeleted.Provider
err = db.Transaction(func(tx *storage.Connection) error {
if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.IdentityUnlinkAction, "", map[string]interface{}{
"identity_id": identityToBeDeleted.ID,
Expand Down Expand Up @@ -88,6 +90,14 @@ func (a *API) DeleteIdentity(w http.ResponseWriter, r *http.Request) error {
return err
}

// Send identity unlinked notification email if enabled and user has an email
if config.Mailer.Notifications.IdentityUnlinkedEnabled && user.GetEmail() != "" {
if err := a.sendIdentityUnlinkedNotification(r, db, user, provider); err != nil {
// Log the error but don't fail the unlinking
logrus.WithError(err).Warn("Unable to send identity unlinked notification email")
}
}

return sendJSON(w, http.StatusOK, map[string]interface{}{})
}

Expand Down Expand Up @@ -154,5 +164,14 @@ func (a *API) linkIdentityToUser(r *http.Request, ctx context.Context, tx *stora
if terr := targetUser.UpdateAppMetaDataProviders(tx); terr != nil {
return nil, terr
}

// Send identity linked notification email if enabled and user has an email
if a.config.Mailer.Notifications.IdentityLinkedEnabled && targetUser.GetEmail() != "" {
if terr := a.sendIdentityLinkedNotification(r, tx, targetUser, providerType); terr != nil {
// Log the error but don't fail the linking
logrus.WithError(terr).Warn("Unable to send identity linked notification email")
}
}

return targetUser, nil
}
119 changes: 118 additions & 1 deletion internal/api/identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,26 @@ import (
"github.com/supabase/auth/internal/api/apierrors"
"github.com/supabase/auth/internal/api/provider"
"github.com/supabase/auth/internal/conf"
mail "github.com/supabase/auth/internal/mailer"
"github.com/supabase/auth/internal/mailer/mockclient"
"github.com/supabase/auth/internal/models"
)

type IdentityTestSuite struct {
suite.Suite
API *API
Config *conf.GlobalConfiguration
Mailer mail.Mailer
}

func TestIdentity(t *testing.T) {
api, config, err := setupAPIForTest()
mockMailer := &mockclient.MockMailer{}
api, config, err := setupAPIForTest(WithMailer(mockMailer))
require.NoError(t, err)
ts := &IdentityTestSuite{
API: api,
Config: config,
Mailer: mockMailer,
}
defer api.db.Close()
suite.Run(t, ts)
Expand Down Expand Up @@ -226,3 +231,115 @@ func (ts *IdentityTestSuite) generateAccessTokenAndSession(u *models.User) strin
return token

}

func (ts *IdentityTestSuite) TestLinkIdentitySendsNotificationEmailEnabled() {
ts.Config.Mailer.Notifications.IdentityLinkedEnabled = true

u, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
require.NoError(ts.T(), err)
ctx := withTargetUser(context.Background(), u)

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

// Link a new identity
testValidUserData := &provider.UserProvidedData{
Metadata: &provider.Claims{
Subject: "test_subject",
},
}
r := httptest.NewRequest(http.MethodGet, "/identities", nil)
u, err = ts.API.linkIdentityToUser(r, ctx, ts.API.db, testValidUserData, "google")
require.NoError(ts.T(), err)

// Assert that identity linked notification email was sent
require.Len(ts.T(), mockMailer.IdentityLinkedMailCalls, 1, "Expected 1 identity linked notification email(s) to be sent")
require.Equal(ts.T(), u.ID, mockMailer.IdentityLinkedMailCalls[0].User.ID, "Email should be sent to the correct user")
require.Equal(ts.T(), "google", mockMailer.IdentityLinkedMailCalls[0].Provider, "Provider should match")
require.Equal(ts.T(), "[email protected]", mockMailer.IdentityLinkedMailCalls[0].User.GetEmail(), "Email should be sent to the correct email address")
}

func (ts *IdentityTestSuite) TestLinkIdentitySendsNotificationEmailDisabled() {
ts.Config.Mailer.Notifications.IdentityLinkedEnabled = false

u, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
require.NoError(ts.T(), err)
ctx := withTargetUser(context.Background(), u)

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

// Link a new identity
testValidUserData := &provider.UserProvidedData{
Metadata: &provider.Claims{
Subject: "test_subject_disabled",
},
}
r := httptest.NewRequest(http.MethodGet, "/identities", nil)
_, err = ts.API.linkIdentityToUser(r, ctx, ts.API.db, testValidUserData, "facebook")
require.NoError(ts.T(), err)

// Assert that identity linked notification email was not sent
require.Len(ts.T(), mockMailer.IdentityLinkedMailCalls, 0, "Expected 0 identity linked notification email(s) to be sent")
}

func (ts *IdentityTestSuite) TestUnlinkIdentitySendsNotificationEmailEnabled() {
ts.Config.Mailer.Notifications.IdentityUnlinkedEnabled = true
ts.Config.Security.ManualLinkingEnabled = true

u, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
require.NoError(ts.T(), err)

identity, err := models.FindIdentityByIdAndProvider(ts.API.db, u.ID.String(), "phone")
require.NoError(ts.T(), err)

// 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)
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/user/identities/%s", identity.ID), nil)
require.NoError(ts.T(), err)
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)

// Assert that identity unlinked notification email was sent
require.Len(ts.T(), mockMailer.IdentityUnlinkedMailCalls, 1, "Expected 1 identity unlinked notification email(s) to be sent")
require.Equal(ts.T(), u.ID, mockMailer.IdentityUnlinkedMailCalls[0].User.ID, "Email should be sent to the correct user")
require.Equal(ts.T(), "phone", mockMailer.IdentityUnlinkedMailCalls[0].Provider, "Provider should match")
require.Equal(ts.T(), "[email protected]", mockMailer.IdentityUnlinkedMailCalls[0].User.GetEmail(), "Email should be sent to the correct email address")
}

func (ts *IdentityTestSuite) TestUnlinkIdentitySendsNotificationEmailDisabled() {
ts.Config.Mailer.Notifications.IdentityUnlinkedEnabled = false
ts.Config.Security.ManualLinkingEnabled = true

u, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
require.NoError(ts.T(), err)

identity, err := models.FindIdentityByIdAndProvider(ts.API.db, u.ID.String(), "phone")
require.NoError(ts.T(), err)

// 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)
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/user/identities/%s", identity.ID), nil)
require.NoError(ts.T(), err)
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)

// Assert that identity unlinked notification email was not sent
require.Len(ts.T(), mockMailer.IdentityUnlinkedMailCalls, 0, "Expected 0 identity unlinked notification email(s) to be sent")
}
39 changes: 39 additions & 0 deletions internal/api/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,40 @@ func (a *API) sendPhoneChangedNotification(r *http.Request, tx *storage.Connecti
return nil
}

func (a *API) sendIdentityLinkedNotification(r *http.Request, tx *storage.Connection, u *models.User, provider string) error {
err := a.sendEmail(r, tx, u, sendEmailParams{
emailActionType: mail.IdentityLinkedNotification,
provider: provider,
})
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 identity linked notification email").WithInternalError(err)
}

return nil
}

func (a *API) sendIdentityUnlinkedNotification(r *http.Request, tx *storage.Connection, u *models.User, provider string) error {
err := a.sendEmail(r, tx, u, sendEmailParams{
emailActionType: mail.IdentityUnlinkedNotification,
provider: provider,
})
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 identity unlinked 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 @@ -715,6 +749,7 @@ type sendEmailParams struct {
tokenHashWithPrefix string
oldEmail string
oldPhone string
provider string
factorType string
}

Expand Down Expand Up @@ -838,6 +873,10 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User,
err = mr.EmailChangedNotificationMail(r, u, params.oldEmail)
case mail.PhoneChangedNotification:
err = mr.PhoneChangedNotificationMail(r, u, params.oldPhone)
case mail.IdentityLinkedNotification:
err = mr.IdentityLinkedNotificationMail(r, u, params.provider)
case mail.IdentityUnlinkedNotification:
err = mr.IdentityUnlinkedNotificationMail(r, u, params.provider)
case mail.MFAFactorEnrolledNotification:
err = mr.MFAFactorEnrolledNotificationMail(r, u, params.factorType)
case mail.MFAFactorUnenrolledNotification:
Expand Down
10 changes: 10 additions & 0 deletions internal/api/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models.
config := a.config

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

if params.Type == smsVerification {
Expand Down Expand Up @@ -430,6 +431,7 @@ func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models.
})); terr != nil {
return terr
}
phoneIdentityWasCreated = true
} else {
if terr := identity.UpdateIdentityData(tx, map[string]interface{}{
"phone": params.Phone,
Expand Down Expand Up @@ -467,6 +469,14 @@ func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models.
}
}

// Send identity linked notification email if a new phone identity was created
if phoneIdentityWasCreated && config.Mailer.Notifications.IdentityLinkedEnabled && user.GetEmail() != "" {
if err := a.sendIdentityLinkedNotification(r, conn, user, "phone"); err != nil {
// Log the error but don't fail the verification
logrus.WithError(err).Warn("Unable to send identity linked notification email")
}
}

return user, nil
}

Expand Down
4 changes: 4 additions & 0 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,8 @@ type EmailContentConfiguration struct {
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"`
IdentityLinkedNotification string `json:"identity_linked_notification" split_words:"true"`
IdentityUnlinkedNotification string `json:"identity_unlinked_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 @@ -403,6 +405,8 @@ 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"`
IdentityLinkedEnabled bool `json:"identity_linked_enabled" split_words:"true" default:"false"`
IdentityUnlinkedEnabled bool `json:"identity_unlinked_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
4 changes: 4 additions & 0 deletions internal/mailer/mailer.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const (
PasswordChangedNotification = "password_changed_notification"
EmailChangedNotification = "email_changed_notification"
PhoneChangedNotification = "phone_changed_notification"
IdentityLinkedNotification = "identity_linked_notification"
IdentityUnlinkedNotification = "identity_unlinked_notification"
MFAFactorEnrolledNotification = "mfa_factor_enrolled_notification"
MFAFactorUnenrolledNotification = "mfa_factor_unenrolled_notification"
)
Expand All @@ -41,6 +43,8 @@ type Mailer interface {
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
IdentityLinkedNotificationMail(r *http.Request, user *models.User, provider string) error
IdentityUnlinkedNotificationMail(r *http.Request, user *models.User, provider string) error
MFAFactorEnrolledNotificationMail(r *http.Request, user *models.User, factorType string) error
MFAFactorUnenrolledNotificationMail(r *http.Request, user *models.User, factorType string) error
}
Expand Down
Loading