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
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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
<h2>MFA factor has been enrolled</h2>

<p>
A new factor ({{ .FactorType }}) has been enrolled for your account {{ .Email
}}.
</p>
<p>If you did not make this change, please contact support immediately.</p>
```

`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
<h2>MFA factor has been unenrolled</h2>

<p>
A factor ({{ .FactorType }}) has been unenrolled for your account {{ .Email
}}.
</p>
<p>If you did not make this change, please contact support immediately.</p>
```

`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`
Expand Down
6 changes: 6 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
39 changes: 39 additions & 0 deletions internal/api/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand Down
43 changes: 41 additions & 2 deletions internal/api/mfa.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{}{
Expand All @@ -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)
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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{}{
Expand All @@ -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 {
Expand All @@ -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,
})
Expand Down Expand Up @@ -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{}{
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
})
Expand Down
Loading