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
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -660,6 +664,27 @@ Default Content (if template is unavailable):
<p><a href="{{ .ConfirmationURL }}">Change Email</a></p>
```

`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
<h2>Your password has been changed</h2>

<p>
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.
</p>
```

`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`
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
}
```

Expand Down
5 changes: 5 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
14 changes: 12 additions & 2 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions internal/api/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}
Expand Down
9 changes: 9 additions & 0 deletions internal/api/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
76 changes: 75 additions & 1 deletion internal/api/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,27 @@ 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"
)

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

Expand Down Expand Up @@ -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")
}
})
}
}
15 changes: 12 additions & 3 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"`

Expand Down
6 changes: 6 additions & 0 deletions internal/mailer/mailer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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{...}) ?
Expand Down
Loading