Skip to content

Commit 53db712

Browse files
authored
feat: MFA factor enrollment notifications (#2183)
This PR adds support for sending email notifications to a user when a factor has been enrolled/unenrolled for their account. 6 new environment variables are introduced: - `GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_{ENROLLED|UNENROLLED}_NOTIFICATION`: Email subject to use for password changed notification. - `GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_{ENROLLED|UNENROLLED}_NOTIFICATION`: The URL to specify a custom template. - `GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_{ENROLLED|UNENROLLED}_ENABLED`: whether the notification is enabled or not. The feature is disabled by default. To enable it, the `GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_{ENROLLED|UNENROLLED}_ENABLED` environment variable must be set to `true`.
1 parent 047f851 commit 53db712

File tree

10 files changed

+329
-16
lines changed

10 files changed

+329
-16
lines changed

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,14 @@ Email subject to use for password changed notification. Defaults to `Your passwo
592592

593593
Email subject to use for email changed notification. Defaults to `Your email address has been changed`.
594594

595+
`GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION` - `string`
596+
597+
Email subject to use for MFA factor enrolled notification. Defaults to `MFA factor enrolled`.
598+
599+
`GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION` - `string`
600+
601+
Email subject to use for MFA factor unenrolled notification. Defaults to `MFA factor unenrolled`.
602+
595603
`MAILER_TEMPLATES_INVITE` - `string`
596604

597605
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):
711719

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

722+
`GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION` - `string`
723+
724+
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`)
725+
`Email` and `FactorType` variables are available.
726+
727+
Default Content (if template is unavailable):
728+
729+
```html
730+
<h2>MFA factor has been enrolled</h2>
731+
732+
<p>
733+
A new factor ({{ .FactorType }}) has been enrolled for your account {{ .Email
734+
}}.
735+
</p>
736+
<p>If you did not make this change, please contact support immediately.</p>
737+
```
738+
739+
`GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED` - `bool`
740+
741+
Whether to send a notification email when a user enrolls in a new MFA factor. Defaults to `false`.
742+
743+
`GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_UNENROLLED_NOTIFICATION` - `string`
744+
745+
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`)
746+
`Email` and `FactorType` variables are available.
747+
748+
Default Content (if template is unavailable):
749+
750+
```html
751+
<h2>MFA factor has been unenrolled</h2>
752+
753+
<p>
754+
A factor ({{ .FactorType }}) has been unenrolled for your account {{ .Email
755+
}}.
756+
</p>
757+
<p>If you did not make this change, please contact support immediately.</p>
758+
```
759+
760+
`GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED` - `bool`
761+
762+
Whether to send a notification email when a user unenrolls from an MFA factor. Defaults to `false`.
763+
714764
### Phone Auth
715765

716766
`SMS_AUTOCONFIRM` - `bool`

example.env

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ 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_MFA_FACTOR_ENROLLED_NOTIFICATION="MFA factor enrolled"
41+
GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION="MFA factor unenrolled"
4042
GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true"
4143

4244
# Custom mailer template config
@@ -47,10 +49,14 @@ GOTRUE_MAILER_TEMPLATES_MAGIC_LINK=""
4749
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE=""
4850
GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION=""
4951
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION=""
52+
GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION=""
53+
GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_UNENROLLED_NOTIFICATION=""
5054

5155
# Account changes notifications configuration
5256
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED="false"
5357
GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED="false"
58+
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED="false"
59+
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED="false"
5460

5561
# Signup config
5662
GOTRUE_DISABLE_SIGNUP="false"

internal/api/mail.go

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

620+
func (a *API) sendMFAFactorEnrolledNotification(r *http.Request, tx *storage.Connection, u *models.User, factorType string) error {
621+
err := a.sendEmail(r, tx, u, sendEmailParams{
622+
emailActionType: mail.MFAFactorEnrolledNotification,
623+
factorType: factorType,
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 MFA factor enrolled notification email").WithInternalError(err)
632+
}
633+
634+
return nil
635+
}
636+
637+
func (a *API) sendMFAFactorUnenrolledNotification(r *http.Request, tx *storage.Connection, u *models.User, factorType string) error {
638+
err := a.sendEmail(r, tx, u, sendEmailParams{
639+
emailActionType: mail.MFAFactorUnenrolledNotification,
640+
factorType: factorType,
641+
})
642+
if err != nil {
643+
if errors.Is(err, EmailRateLimitExceeded) {
644+
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error())
645+
} else if herr, ok := err.(*HTTPError); ok {
646+
return herr
647+
}
648+
return apierrors.NewInternalServerError("Error sending MFA factor unenrolled notification email").WithInternalError(err)
649+
}
650+
651+
return nil
652+
}
653+
620654
func (a *API) validateEmail(email string) (string, error) {
621655
if email == "" {
622656
return "", apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "An email address is required")
@@ -663,6 +697,7 @@ type sendEmailParams struct {
663697
otpNew string
664698
tokenHashWithPrefix string
665699
oldEmail string
700+
factorType string
666701
}
667702

668703
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,
783818
err = mr.PasswordChangedNotificationMail(r, u)
784819
case mail.EmailChangedNotification:
785820
err = mr.EmailChangedNotificationMail(r, u, params.oldEmail)
821+
case mail.MFAFactorEnrolledNotification:
822+
err = mr.MFAFactorEnrolledNotificationMail(r, u, params.factorType)
823+
case mail.MFAFactorUnenrolledNotification:
824+
err = mr.MFAFactorUnenrolledNotificationMail(r, u, params.factorType)
786825
default:
787826
err = errors.New("invalid email action type")
788827
}

internal/api/mfa.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/gofrs/uuid"
1919
"github.com/pquerna/otp"
2020
"github.com/pquerna/otp/totp"
21+
"github.com/sirupsen/logrus"
2122
"github.com/supabase/auth/internal/api/apierrors"
2223
"github.com/supabase/auth/internal/api/sms_provider"
2324
"github.com/supabase/auth/internal/conf"
@@ -686,7 +687,7 @@ func (a *API) verifyTOTPFactor(w http.ResponseWriter, r *http.Request, params *V
686687
}
687688

688689
var token *AccessTokenResponse
689-
690+
verified := false
690691
err = db.Transaction(func(tx *storage.Connection) error {
691692
var terr error
692693
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
703704
if terr = factor.UpdateStatus(tx, models.FactorStateVerified); terr != nil {
704705
return terr
705706
}
707+
verified = true
706708
}
707709
if shouldReEncrypt && config.Security.DBEncryption.Encrypt {
708710
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
738740
return err
739741
}
740742

743+
// Send MFA factor enrolled notification email if enabled and the factor was just verified
744+
if verified && config.Mailer.Notifications.MFAFactorEnrolledEnabled && user.GetEmail() != "" {
745+
if err := a.sendMFAFactorEnrolledNotification(r, db, user, factor.FactorType); err != nil {
746+
// Log the error but don't fail the verification
747+
logrus.WithError(err).Warn("Unable to send MFA factor enrolled notification email")
748+
}
749+
}
750+
741751
metering.RecordLogin(metering.LoginTypeMFA, user.ID, &metering.LoginData{
742752
Provider: metering.ProviderMFATOTP,
743753
})
@@ -828,7 +838,7 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params *
828838
}
829839

830840
var token *AccessTokenResponse
831-
841+
verified := false
832842
err = db.Transaction(func(tx *storage.Connection) error {
833843
var terr error
834844
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 *
845855
if terr = factor.UpdateStatus(tx, models.FactorStateVerified); terr != nil {
846856
return terr
847857
}
858+
verified = true
848859
}
849860
user, terr = models.FindUserByID(tx, user.ID)
850861
if terr != nil {
@@ -869,6 +880,14 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params *
869880
return err
870881
}
871882

883+
// Send MFA factor enrolled notification email if enabled and the factor was just verified
884+
if verified && config.Mailer.Notifications.MFAFactorEnrolledEnabled && user.GetEmail() != "" {
885+
if err := a.sendMFAFactorEnrolledNotification(r, db, user, factor.FactorType); err != nil {
886+
// Log the error but don't fail the verification
887+
logrus.WithError(err).Warn("Unable to send MFA factor enrolled notification email")
888+
}
889+
}
890+
872891
metering.RecordLogin(metering.LoginTypeMFA, user.ID, &metering.LoginData{
873892
Provider: metering.ProviderMFAPhone,
874893
})
@@ -935,6 +954,7 @@ func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, param
935954
return apierrors.NewInternalServerError("Database error deleting challenge").WithInternalError(err)
936955
}
937956
var token *AccessTokenResponse
957+
verified := false
938958
err = db.Transaction(func(tx *storage.Connection) error {
939959
var terr error
940960
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
952972
if terr = factor.SaveWebAuthnCredential(tx, credential); terr != nil {
953973
return terr
954974
}
975+
verified = true
955976
}
956977

957978
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
9791000
return err
9801001
}
9811002

1003+
// Send MFA factor enrolled notification email if enabled and the factor was just verified
1004+
if verified && config.Mailer.Notifications.MFAFactorEnrolledEnabled && user.GetEmail() != "" {
1005+
if err := a.sendMFAFactorEnrolledNotification(r, db, user, factor.FactorType); err != nil {
1006+
// Log the error but don't fail the verification
1007+
logrus.WithError(err).Warn("Unable to send MFA factor enrolled notification email")
1008+
}
1009+
}
1010+
9821011
metering.RecordLogin(metering.LoginTypeMFA, user.ID, &metering.LoginData{
9831012
Provider: metering.ProviderMFAWebAuthn,
9841013
})
@@ -1039,6 +1068,8 @@ func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error {
10391068
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeInsufficientAAL, "AAL2 required to unenroll verified factor")
10401069
}
10411070

1071+
factorType := factor.FactorType
1072+
10421073
err = db.Transaction(func(tx *storage.Connection) error {
10431074
var terr error
10441075
if terr := tx.Destroy(factor); terr != nil {
@@ -1060,6 +1091,14 @@ func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error {
10601091
return err
10611092
}
10621093

1094+
// Send MFA factor unenrolled notification email if enabled
1095+
if config.Mailer.Notifications.MFAFactorUnenrolledEnabled && user.GetEmail() != "" {
1096+
if err := a.sendMFAFactorUnenrolledNotification(r, db, user, factorType); err != nil {
1097+
// Log the error but don't fail the unenrollment
1098+
logrus.WithError(err).Warn("Unable to send MFA factor unenrolled notification email")
1099+
}
1100+
}
1101+
10631102
return sendJSON(w, http.StatusOK, &UnenrollFactorResponse{
10641103
ID: factor.ID,
10651104
})

0 commit comments

Comments
 (0)