diff --git a/README.md b/README.md index a060b23e0..1706f90e2 100644 --- a/README.md +++ b/README.md @@ -592,6 +592,14 @@ Email subject to use for password changed notification. Defaults to `Your passwo Email subject to use for email changed notification. Defaults to `Your email address has been changed`. +`GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION` - `string` + +Email subject to use for MFA factor enrolled notification. Defaults to `MFA factor enrolled`. + +`GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION` - `string` + +Email subject to use for MFA factor unenrolled notification. Defaults to `MFA factor unenrolled`. + `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`) @@ -711,6 +719,48 @@ 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_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`) +`Email` and `FactorType` variables are available. + +Default Content (if template is unavailable): + +```html +
+ A new factor ({{ .FactorType }}) has been enrolled for your account {{ .Email + }}. +
+If you did not make this change, please contact support immediately.
+``` + +`GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED` - `bool` + +Whether to send a notification email when a user enrolls in a new MFA factor. Defaults to `false`. + +`GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_UNENROLLED_NOTIFICATION` - `string` + +URL path to an email template to use when notifying a user that they have unenrolled from an MFA factor. (e.g. `https://www.example.com/path-to-email-template.html`) +`Email` and `FactorType` variables are available. + +Default Content (if template is unavailable): + +```html ++ A factor ({{ .FactorType }}) has been unenrolled for your account {{ .Email + }}. +
+If you did not make this change, please contact support immediately.
+``` + +`GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED` - `bool` + +Whether to send a notification email when a user unenrolls from an MFA factor. Defaults to `false`. + ### Phone Auth `SMS_AUTOCONFIRM` - `bool` diff --git a/example.env b/example.env index dbec15670..107567d47 100644 --- a/example.env +++ b/example.env @@ -37,6 +37,8 @@ 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_MFA_FACTOR_ENROLLED_NOTIFICATION="MFA factor enrolled" +GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION="MFA factor unenrolled" GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true" # Custom mailer template config @@ -47,10 +49,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_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_MFA_FACTOR_ENROLLED_ENABLED="false" +GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED="false" # Signup config GOTRUE_DISABLE_SIGNUP="false" diff --git a/internal/api/mail.go b/internal/api/mail.go index c6aff0c85..59b5a8781 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -617,6 +617,40 @@ func (a *API) sendEmailChangedNotification(r *http.Request, tx *storage.Connecti 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, + factorType: factorType, + }) + 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 MFA factor enrolled notification email").WithInternalError(err) + } + + return nil +} + +func (a *API) sendMFAFactorUnenrolledNotification(r *http.Request, tx *storage.Connection, u *models.User, factorType string) error { + err := a.sendEmail(r, tx, u, sendEmailParams{ + emailActionType: mail.MFAFactorUnenrolledNotification, + factorType: factorType, + }) + 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 MFA factor unenrolled 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") @@ -663,6 +697,7 @@ type sendEmailParams struct { otpNew string tokenHashWithPrefix string oldEmail string + factorType string } func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, params sendEmailParams) error { @@ -783,6 +818,10 @@ 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.MFAFactorEnrolledNotification: + err = mr.MFAFactorEnrolledNotificationMail(r, u, params.factorType) + case mail.MFAFactorUnenrolledNotification: + err = mr.MFAFactorUnenrolledNotificationMail(r, u, params.factorType) default: err = errors.New("invalid email action type") } diff --git a/internal/api/mfa.go b/internal/api/mfa.go index dd98d66d6..81523363f 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -18,6 +18,7 @@ import ( "github.com/gofrs/uuid" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" + "github.com/sirupsen/logrus" "github.com/supabase/auth/internal/api/apierrors" "github.com/supabase/auth/internal/api/sms_provider" "github.com/supabase/auth/internal/conf" @@ -686,7 +687,7 @@ func (a *API) verifyTOTPFactor(w http.ResponseWriter, r *http.Request, params *V } var token *AccessTokenResponse - + verified := false err = db.Transaction(func(tx *storage.Connection) error { var terr error if terr = models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{ @@ -703,6 +704,7 @@ func (a *API) verifyTOTPFactor(w http.ResponseWriter, r *http.Request, params *V if terr = factor.UpdateStatus(tx, models.FactorStateVerified); terr != nil { return terr } + verified = true } if shouldReEncrypt && config.Security.DBEncryption.Encrypt { es, terr := crypto.NewEncryptedString(factor.ID.String(), []byte(secret), config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey) @@ -738,6 +740,14 @@ func (a *API) verifyTOTPFactor(w http.ResponseWriter, r *http.Request, params *V return err } + // Send MFA factor enrolled notification email if enabled and the factor was just verified + if verified && config.Mailer.Notifications.MFAFactorEnrolledEnabled && user.GetEmail() != "" { + if err := a.sendMFAFactorEnrolledNotification(r, db, user, factor.FactorType); err != nil { + // Log the error but don't fail the verification + logrus.WithError(err).Warn("Unable to send MFA factor enrolled notification email") + } + } + metering.RecordLogin(metering.LoginTypeMFA, user.ID, &metering.LoginData{ Provider: metering.ProviderMFATOTP, }) @@ -828,7 +838,7 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params * } var token *AccessTokenResponse - + verified := false err = db.Transaction(func(tx *storage.Connection) error { var terr error if terr = models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{ @@ -845,6 +855,7 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params * if terr = factor.UpdateStatus(tx, models.FactorStateVerified); terr != nil { return terr } + verified = true } user, terr = models.FindUserByID(tx, user.ID) if terr != nil { @@ -869,6 +880,14 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params * return err } + // Send MFA factor enrolled notification email if enabled and the factor was just verified + if verified && config.Mailer.Notifications.MFAFactorEnrolledEnabled && user.GetEmail() != "" { + if err := a.sendMFAFactorEnrolledNotification(r, db, user, factor.FactorType); err != nil { + // Log the error but don't fail the verification + logrus.WithError(err).Warn("Unable to send MFA factor enrolled notification email") + } + } + metering.RecordLogin(metering.LoginTypeMFA, user.ID, &metering.LoginData{ Provider: metering.ProviderMFAPhone, }) @@ -935,6 +954,7 @@ func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, param return apierrors.NewInternalServerError("Database error deleting challenge").WithInternalError(err) } var token *AccessTokenResponse + verified := false err = db.Transaction(func(tx *storage.Connection) error { var terr error if terr = models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{ @@ -952,6 +972,7 @@ func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, param if terr = factor.SaveWebAuthnCredential(tx, credential); terr != nil { return terr } + verified = true } if terr = factor.UpdateLastWebAuthnChallenge(tx, challenge, params.WebAuthn.Type, parsedResponse); terr != nil { @@ -979,6 +1000,14 @@ func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, param return err } + // Send MFA factor enrolled notification email if enabled and the factor was just verified + if verified && config.Mailer.Notifications.MFAFactorEnrolledEnabled && user.GetEmail() != "" { + if err := a.sendMFAFactorEnrolledNotification(r, db, user, factor.FactorType); err != nil { + // Log the error but don't fail the verification + logrus.WithError(err).Warn("Unable to send MFA factor enrolled notification email") + } + } + metering.RecordLogin(metering.LoginTypeMFA, user.ID, &metering.LoginData{ Provider: metering.ProviderMFAWebAuthn, }) @@ -1039,6 +1068,8 @@ func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error { return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeInsufficientAAL, "AAL2 required to unenroll verified factor") } + factorType := factor.FactorType + err = db.Transaction(func(tx *storage.Connection) error { var terr error if terr := tx.Destroy(factor); terr != nil { @@ -1060,6 +1091,14 @@ func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error { return err } + // Send MFA factor unenrolled notification email if enabled + if config.Mailer.Notifications.MFAFactorUnenrolledEnabled && user.GetEmail() != "" { + if err := a.sendMFAFactorUnenrolledNotification(r, db, user, factorType); err != nil { + // Log the error but don't fail the unenrollment + logrus.WithError(err).Warn("Unable to send MFA factor unenrolled notification email") + } + } + return sendJSON(w, http.StatusOK, &UnenrollFactorResponse{ ID: factor.ID, }) diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go index 6daba2374..5cc6ae384 100644 --- a/internal/api/mfa_test.go +++ b/internal/api/mfa_test.go @@ -17,6 +17,8 @@ import ( "github.com/supabase/auth/internal/api/sms_provider" "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" "github.com/supabase/auth/internal/utilities" @@ -29,6 +31,7 @@ type MFATestSuite struct { suite.Suite API *API Config *conf.GlobalConfiguration + Mailer mailer.Mailer TestDomain string TestEmail string TestOTPKey *otp.Key @@ -39,11 +42,13 @@ type MFATestSuite struct { } func TestMFA(t *testing.T) { - api, config, err := setupAPIForTest() + mockMailer := &mockclient.MockMailer{} + api, config, err := setupAPIForTest(WithMailer(mockMailer)) require.NoError(t, err) ts := &MFATestSuite{ API: api, Config: config, + Mailer: mockMailer, } defer api.db.Close() suite.Run(t, ts) @@ -1010,3 +1015,97 @@ func cleanupHook(ts *MFATestSuite, hookName string) { err := ts.API.db.RawQuery(cleanupHookSQL).Exec() require.NoError(ts.T(), err) } + +func (ts *MFATestSuite) TestMFAFactorEnrolledNotificationEnabled() { + ts.Config.Mailer.Notifications.MFAFactorEnrolledEnabled = true + + // 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() + + res := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword, true /* <- requireStatusOK */) + accessTokenResp := &AccessTokenResponse{} + require.NoError(ts.T(), json.NewDecoder(res.Body).Decode(&accessTokenResp)) + + // Assert that MFA factor enrolled notification email was sent or not based on the config + require.Len(ts.T(), mockMailer.MFAFactorEnrolledMailCalls, 1, "Expected one MFA factor enrolled notification email(s) to be sent") + require.Equal(ts.T(), accessTokenResp.User.ID, mockMailer.MFAFactorEnrolledMailCalls[0].User.ID, "Email should be sent to the correct user") + require.Equal(ts.T(), models.TOTP, mockMailer.MFAFactorEnrolledMailCalls[0].FactorType, "Email should specify the correct factor type") +} + +func (ts *MFATestSuite) TestMFAFactorEnrolledNotificationDisabled() { + ts.Config.Mailer.Notifications.MFAFactorEnrolledEnabled = false + + // 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() + + res := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword, true /* <- requireStatusOK */) + accessTokenResp := &AccessTokenResponse{} + require.NoError(ts.T(), json.NewDecoder(res.Body).Decode(&accessTokenResp)) + + // Assert that MFA factor enrolled notification email was sent or not based on the config + require.Len(ts.T(), mockMailer.MFAFactorEnrolledMailCalls, 0, "Expected 0 MFA factor enrolled notification email(s) to be sent") +} + +func (ts *MFATestSuite) TestMFAFactorUnenrolledNotificationEnabled() { + ts.Config.Mailer.Notifications.MFAFactorUnenrolledEnabled = true + + // 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() + + var buffer bytes.Buffer + f := ts.TestUser.Factors[0] + + token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID) + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "factor_id": f.ID, + })) + + w := ServeAuthenticatedRequest(ts, http.MethodDelete, fmt.Sprintf("/factors/%s", f.ID), token, buffer) + require.Equal(ts.T(), http.StatusOK, w.Code) + + _, err := models.FindFactorByFactorID(ts.API.db, f.ID) + require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error()) + session, _ := models.FindSessionByID(ts.API.db, ts.TestSecondarySession.ID, false) + require.Equal(ts.T(), models.AAL1.String(), session.GetAAL()) + require.Nil(ts.T(), session.FactorID) + + // Assert that MFA factor unenrolled notification email was sent or not based on the config + require.Len(ts.T(), mockMailer.MFAFactorUnenrolledMailCalls, 1, "Expected one MFA factor unenrolled notification email(s) to be sent") + require.Equal(ts.T(), ts.TestUser.ID, mockMailer.MFAFactorUnenrolledMailCalls[0].User.ID, "Email should be sent to the correct user") + require.Equal(ts.T(), models.TOTP, mockMailer.MFAFactorUnenrolledMailCalls[0].FactorType, "Email should specify the correct factor type") +} + +func (ts *MFATestSuite) TestMFAFactorUnenrolledNotificationDisabled() { + ts.Config.Mailer.Notifications.MFAFactorUnenrolledEnabled = false + + // 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() + + var buffer bytes.Buffer + f := ts.TestUser.Factors[0] + + token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID) + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "factor_id": f.ID, + })) + + w := ServeAuthenticatedRequest(ts, http.MethodDelete, fmt.Sprintf("/factors/%s", f.ID), token, buffer) + require.Equal(ts.T(), http.StatusOK, w.Code) + + _, err := models.FindFactorByFactorID(ts.API.db, f.ID) + require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error()) + session, _ := models.FindSessionByID(ts.API.db, ts.TestSecondarySession.ID, false) + require.Equal(ts.T(), models.AAL1.String(), session.GetAAL()) + require.Nil(ts.T(), session.FactorID) + + // Assert that MFA factor unenrolled notification email was sent or not based on the config + require.Len(ts.T(), mockMailer.MFAFactorUnenrolledMailCalls, 0, "Expected 0 MFA factor unenrolled notification email(s) to be sent") +} diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index c492be79c..ce1698c3d 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -391,14 +391,18 @@ type EmailContentConfiguration struct { Reauthentication string `json:"reauthentication"` // Account Changes Notifications - PasswordChangedNotification string `json:"password_changed_notification" split_words:"true"` - EmailChangedNotification string `json:"email_changed_notification" split_words:"true"` + PasswordChangedNotification string `json:"password_changed_notification" split_words:"true"` + EmailChangedNotification string `json:"email_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"` } // 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"` - EmailChangedEnabled bool `json:"email_changed_enabled" split_words:"true" default:"false"` + PasswordChangedEnabled bool `json:"password_changed_enabled" split_words:"true" default:"false"` + EmailChangedEnabled bool `json:"email_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"` } type ProviderConfiguration struct { diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index 908034b88..a61c64fc0 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -20,8 +20,10 @@ const ( ReauthenticationVerification = "reauthentication" // Account Changes Notifications - PasswordChangedNotification = "password_changed_notification" - EmailChangedNotification = "email_changed_notification" + PasswordChangedNotification = "password_changed_notification" + EmailChangedNotification = "email_changed_notification" + MFAFactorEnrolledNotification = "mfa_factor_enrolled_notification" + MFAFactorUnenrolledNotification = "mfa_factor_unenrolled_notification" ) // Mailer defines the interface a mailer must implement. @@ -37,6 +39,8 @@ type Mailer interface { // Account Changes Notifications PasswordChangedNotificationMail(r *http.Request, user *models.User) error EmailChangedNotificationMail(r *http.Request, user *models.User, oldEmail string) error + MFAFactorEnrolledNotificationMail(r *http.Request, user *models.User, factorType string) error + MFAFactorUnenrolledNotificationMail(r *http.Request, user *models.User, factorType string) error } // TODO(cstockton): Mail(...) -> Mail(Email{...}) ? diff --git a/internal/mailer/mockclient/mockclient.go b/internal/mailer/mockclient/mockclient.go index df68c06ef..043cc8957 100644 --- a/internal/mailer/mockclient/mockclient.go +++ b/internal/mailer/mockclient/mockclient.go @@ -17,8 +17,10 @@ type MockMailer struct { ReauthenticateMailCalls []ReauthenticateMailCall GetEmailActionLinkCalls []GetEmailActionLinkCall - PasswordChangedMailCalls []PasswordChangedMailCall - EmailChangedMailCalls []EmailChangedMailCall + PasswordChangedMailCalls []PasswordChangedMailCall + EmailChangedMailCalls []EmailChangedMailCall + MFAFactorEnrolledMailCalls []MFAFactorEnrolledMailCall + MFAFactorUnenrolledMailCalls []MFAFactorUnenrolledMailCall } type InviteMailCall struct { @@ -80,6 +82,16 @@ type EmailChangedMailCall struct { OldEmail string } +type MFAFactorEnrolledMailCall struct { + User *models.User + FactorType string +} + +type MFAFactorUnenrolledMailCall struct { + User *models.User + FactorType string +} + 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, @@ -167,6 +179,22 @@ func (m *MockMailer) EmailChangedNotificationMail(r *http.Request, user *models. return nil } +func (m *MockMailer) MFAFactorEnrolledNotificationMail(r *http.Request, user *models.User, factorType string) error { + m.MFAFactorEnrolledMailCalls = append(m.MFAFactorEnrolledMailCalls, MFAFactorEnrolledMailCall{ + User: user, + FactorType: factorType, + }) + return nil +} + +func (m *MockMailer) MFAFactorUnenrolledNotificationMail(r *http.Request, user *models.User, factorType string) error { + m.MFAFactorUnenrolledMailCalls = append(m.MFAFactorUnenrolledMailCalls, MFAFactorUnenrolledMailCall{ + User: user, + FactorType: factorType, + }) + return nil +} + func (m *MockMailer) Reset() { m.InviteMailCalls = nil m.ConfirmationMailCalls = nil @@ -178,4 +206,6 @@ func (m *MockMailer) Reset() { m.PasswordChangedMailCalls = nil m.EmailChangedMailCalls = nil + m.MFAFactorEnrolledMailCalls = nil + m.MFAFactorUnenrolledMailCalls = nil } diff --git a/internal/mailer/templatemailer/template.go b/internal/mailer/templatemailer/template.go index 847bf188c..fcfb8a11f 100644 --- a/internal/mailer/templatemailer/template.go +++ b/internal/mailer/templatemailer/template.go @@ -539,6 +539,10 @@ func lookupEmailContentConfig( return cfg.PasswordChangedNotification, true case EmailChangedNotificationTemplate: return cfg.EmailChangedNotification, true + case MFAFactorEnrolledNotificationTemplate: + return cfg.MFAFactorEnrolledNotification, true + case MFAFactorUnenrolledNotificationTemplate: + return cfg.MFAFactorUnenrolledNotification, true } } diff --git a/internal/mailer/templatemailer/templatemailer.go b/internal/mailer/templatemailer/templatemailer.go index eba11e583..aa93339fa 100644 --- a/internal/mailer/templatemailer/templatemailer.go +++ b/internal/mailer/templatemailer/templatemailer.go @@ -20,8 +20,10 @@ const ( ReauthenticationTemplate = "reauthentication" // Account Changes Notifications - PasswordChangedNotificationTemplate = "password_changed_notification" - EmailChangedNotificationTemplate = "email_changed_notification" + PasswordChangedNotificationTemplate = "password_changed_notification" + EmailChangedNotificationTemplate = "email_changed_notification" + MFAFactorEnrolledNotificationTemplate = "mfa_factor_enrolled_notification" + MFAFactorUnenrolledNotificationTemplate = "mfa_factor_unenrolled_notification" ) const defaultInviteMail = `If you did not make this change, please contact support.
` +const defaultMFAFactorEnrolledNotificationMail = `A new factor ({{ .FactorType }}) has been enrolled for your account {{ .Email }}.
+If you did not make this change, please contact support immediately.
+` + +const defaultMFAFactorUnenrolledNotificationMail = `A factor ({{ .FactorType }}) has been unenrolled for your account {{ .Email }}.
+If you did not make this change, please contact support immediately.
+` + var ( templateTypes = []string{ InviteTemplate, @@ -85,6 +99,8 @@ var ( // Account Changes Notifications PasswordChangedNotificationTemplate, EmailChangedNotificationTemplate, + MFAFactorEnrolledNotificationTemplate, + MFAFactorUnenrolledNotificationTemplate, } defaultTemplateSubjects = &conf.EmailContentConfiguration{ Invite: "You have been invited", @@ -95,7 +111,9 @@ var ( Reauthentication: "Confirm reauthentication", // Account Changes Notifications - PasswordChangedNotification: "Your password has been changed", + PasswordChangedNotification: "Your password has been changed", + MFAFactorEnrolledNotification: "MFA factor enrolled", + MFAFactorUnenrolledNotification: "MFA factor unenrolled", } defaultTemplateBodies = &conf.EmailContentConfiguration{ Invite: defaultInviteMail, @@ -106,8 +124,10 @@ var ( Reauthentication: defaultReauthenticateMail, // Account Changes Notifications - PasswordChangedNotification: defaultPasswordChangedNotificationMail, - EmailChangedNotification: defaultEmailChangedNotificationMail, + PasswordChangedNotification: defaultPasswordChangedNotificationMail, + EmailChangedNotification: defaultEmailChangedNotificationMail, + MFAFactorEnrolledNotification: defaultMFAFactorEnrolledNotificationMail, + MFAFactorUnenrolledNotification: defaultMFAFactorUnenrolledNotificationMail, } ) @@ -389,6 +409,24 @@ func (m *Mailer) EmailChangedNotificationMail(r *http.Request, user *models.User return m.mail(r.Context(), m.cfg, EmailChangedNotificationTemplate, oldEmail, data) } +func (m *Mailer) MFAFactorEnrolledNotificationMail(r *http.Request, user *models.User, factorType string) error { + data := map[string]any{ + "Email": user.GetEmail(), + "FactorType": factorType, + "Data": user.UserMetaData, + } + return m.mail(r.Context(), m.cfg, MFAFactorEnrolledNotificationTemplate, user.GetEmail(), data) +} + +func (m *Mailer) MFAFactorUnenrolledNotificationMail(r *http.Request, user *models.User, factorType string) error { + data := map[string]any{ + "Email": user.GetEmail(), + "FactorType": factorType, + "Data": user.UserMetaData, + } + return m.mail(r.Context(), m.cfg, MFAFactorUnenrolledNotificationTemplate, user.GetEmail(), data) +} + type emailParams struct { Token string Type string