package api import ( "net/http" "regexp" "strings" "time" "github.com/supabase/auth/internal/hooks" mail "github.com/supabase/auth/internal/mailer" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "github.com/badoux/checkmail" "github.com/fatih/structs" "github.com/pkg/errors" "github.com/sethvargo/go-password/password" "github.com/supabase/auth/internal/api/provider" "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" "github.com/supabase/auth/internal/utilities" ) var ( EmailRateLimitExceeded error = errors.New("email rate limit exceeded") ) type GenerateLinkParams struct { Type string `json:"type"` Email string `json:"email"` NewEmail string `json:"new_email"` Password string `json:"password"` Data map[string]interface{} `json:"data"` RedirectTo string `json:"redirect_to"` } type GenerateLinkResponse struct { models.User ActionLink string `json:"action_link"` EmailOtp string `json:"email_otp"` HashedToken string `json:"hashed_token"` VerificationType string `json:"verification_type"` RedirectTo string `json:"redirect_to"` } func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) config := a.config mailer := a.Mailer() adminUser := getAdminUser(ctx) params := &GenerateLinkParams{} if err := retrieveRequestParams(r, params); err != nil { return err } var err error params.Email, err = a.validateEmail(params.Email) if err != nil { return err } referrer := utilities.GetReferrer(r, config) if utilities.IsRedirectURLValid(config, params.RedirectTo) { referrer = params.RedirectTo } aud := a.requestAud(ctx, r) user, err := models.FindUserByEmailAndAudience(db, params.Email, aud) if err != nil { if models.IsNotFoundError(err) { switch params.Type { case mail.MagicLinkVerification: params.Type = mail.SignupVerification params.Password, err = password.Generate(64, 10, 1, false, true) if err != nil { // password generation must always succeed panic(err) } case mail.RecoveryVerification, mail.EmailChangeCurrentVerification, mail.EmailChangeNewVerification: return notFoundError(ErrorCodeUserNotFound, "User with this email not found") } } else { return internalServerError("Database error finding user").WithInternalError(err) } } var url string now := time.Now() otp := crypto.GenerateOtp(config.Mailer.OtpLength) hashedToken := crypto.GenerateTokenHash(params.Email, otp) var signupUser *models.User if params.Type == mail.SignupVerification && user == nil { signupParams := &SignupParams{ Email: params.Email, Password: params.Password, Data: params.Data, Provider: "email", Aud: aud, } if err := a.validateSignupParams(ctx, signupParams); err != nil { return err } signupUser, err = signupParams.ToUserModel(false /* <- isSSOUser */) if err != nil { return err } } err = db.Transaction(func(tx *storage.Connection) error { var terr error switch params.Type { case mail.MagicLinkVerification, mail.RecoveryVerification: if terr = models.NewAuditLogEntry(r, tx, user, models.UserRecoveryRequestedAction, "", nil); terr != nil { return terr } user.RecoveryToken = hashedToken user.RecoverySentAt = &now terr = tx.UpdateOnly(user, "recovery_token", "recovery_sent_at") if terr != nil { terr = errors.Wrap(terr, "Database error updating user for recovery") return terr } terr = models.CreateOneTimeToken(tx, user.ID, user.GetEmail(), user.RecoveryToken, models.RecoveryToken) if terr != nil { terr = errors.Wrap(terr, "Database error creating recovery token in admin") return terr } case mail.InviteVerification: if user != nil { if user.IsConfirmed() { return unprocessableEntityError(ErrorCodeEmailExists, DuplicateEmailMsg) } } else { signupParams := &SignupParams{ Email: params.Email, Data: params.Data, Provider: "email", Aud: aud, } // because params above sets no password, this // method is not computationally hard so it can // be used within a database transaction user, terr = signupParams.ToUserModel(false /* <- isSSOUser */) if terr != nil { return terr } user, terr = a.signupNewUser(tx, user) if terr != nil { return terr } identity, terr := a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{ Subject: user.ID.String(), Email: user.GetEmail(), })) if terr != nil { return terr } user.Identities = []models.Identity{*identity} } if terr = models.NewAuditLogEntry(r, tx, adminUser, models.UserInvitedAction, "", map[string]interface{}{ "user_id": user.ID, "user_email": user.Email, }); terr != nil { return terr } user.ConfirmationToken = hashedToken user.ConfirmationSentAt = &now user.InvitedAt = &now terr = tx.UpdateOnly(user, "confirmation_token", "confirmation_sent_at", "invited_at") if terr != nil { terr = errors.Wrap(terr, "Database error updating user for invite") return terr } terr = models.CreateOneTimeToken(tx, user.ID, user.GetEmail(), user.ConfirmationToken, models.ConfirmationToken) if terr != nil { terr = errors.Wrap(terr, "Database error creating confirmation token for invite in admin") return terr } case mail.SignupVerification: if user != nil { if user.IsConfirmed() { return unprocessableEntityError(ErrorCodeEmailExists, DuplicateEmailMsg) } if err := user.UpdateUserMetaData(tx, params.Data); err != nil { return internalServerError("Database error updating user").WithInternalError(err) } } else { // you should never use SignupParams with // password here to generate a new user, use // signupUser which is a model generated from // SignupParams above user, terr = a.signupNewUser(tx, signupUser) if terr != nil { return terr } identity, terr := a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{ Subject: user.ID.String(), Email: user.GetEmail(), })) if terr != nil { return terr } user.Identities = []models.Identity{*identity} } user.ConfirmationToken = hashedToken user.ConfirmationSentAt = &now terr = tx.UpdateOnly(user, "confirmation_token", "confirmation_sent_at") if terr != nil { terr = errors.Wrap(terr, "Database error updating user for confirmation") return terr } terr = models.CreateOneTimeToken(tx, user.ID, user.GetEmail(), user.ConfirmationToken, models.ConfirmationToken) if terr != nil { terr = errors.Wrap(terr, "Database error creating confirmation token for signup in admin") return terr } case mail.EmailChangeCurrentVerification, mail.EmailChangeNewVerification: if !config.Mailer.SecureEmailChangeEnabled && params.Type == "email_change_current" { return badRequestError(ErrorCodeValidationFailed, "Enable secure email change to generate link for current email") } params.NewEmail, terr = a.validateEmail(params.NewEmail) if terr != nil { return terr } if duplicateUser, terr := models.IsDuplicatedEmail(tx, params.NewEmail, user.Aud, user); terr != nil { return internalServerError("Database error checking email").WithInternalError(terr) } else if duplicateUser != nil { return unprocessableEntityError(ErrorCodeEmailExists, DuplicateEmailMsg) } now := time.Now() user.EmailChangeSentAt = &now user.EmailChange = params.NewEmail user.EmailChangeConfirmStatus = zeroConfirmation if params.Type == "email_change_current" { user.EmailChangeTokenCurrent = hashedToken } else if params.Type == "email_change_new" { user.EmailChangeTokenNew = crypto.GenerateTokenHash(params.NewEmail, otp) } terr = tx.UpdateOnly(user, "email_change_token_current", "email_change_token_new", "email_change", "email_change_sent_at", "email_change_confirm_status") if terr != nil { terr = errors.Wrap(terr, "Database error updating user for email change") return terr } if user.EmailChangeTokenCurrent != "" { terr = models.CreateOneTimeToken(tx, user.ID, user.GetEmail(), user.EmailChangeTokenCurrent, models.EmailChangeTokenCurrent) if terr != nil { terr = errors.Wrap(terr, "Database error creating email change token current in admin") return terr } } if user.EmailChangeTokenNew != "" { terr = models.CreateOneTimeToken(tx, user.ID, user.EmailChange, user.EmailChangeTokenNew, models.EmailChangeTokenNew) if terr != nil { terr = errors.Wrap(terr, "Database error creating email change token new in admin") return terr } } default: return badRequestError(ErrorCodeValidationFailed, "Invalid email action link type requested: %v", params.Type) } if terr != nil { return terr } externalURL := getExternalHost(ctx) url, terr = mailer.GetEmailActionLink(user, params.Type, referrer, externalURL) if terr != nil { return terr } return nil }) if err != nil { return err } resp := GenerateLinkResponse{ User: *user, ActionLink: url, EmailOtp: otp, HashedToken: hashedToken, VerificationType: params.Type, RedirectTo: referrer, } return sendJSON(w, http.StatusOK, resp) } func (a *API) sendConfirmation(r *http.Request, tx *storage.Connection, u *models.User, flowType models.FlowType) error { var err error config := a.config maxFrequency := config.SMTP.MaxFrequency otpLength := config.Mailer.OtpLength if err = validateSentWithinFrequencyLimit(u.ConfirmationSentAt, maxFrequency); err != nil { return err } oldToken := u.ConfirmationToken otp := crypto.GenerateOtp(otpLength) token := crypto.GenerateTokenHash(u.GetEmail(), otp) u.ConfirmationToken = addFlowPrefixToToken(token, flowType) now := time.Now() if err = a.sendEmail(r, tx, u, mail.SignupVerification, otp, "", u.ConfirmationToken); err != nil { u.ConfirmationToken = oldToken if errors.Is(err, EmailRateLimitExceeded) { return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) } else if herr, ok := err.(*HTTPError); ok { return herr } return internalServerError("Error sending confirmation email").WithInternalError(err) } u.ConfirmationSentAt = &now if err := tx.UpdateOnly(u, "confirmation_token", "confirmation_sent_at"); err != nil { return internalServerError("Error sending confirmation email").WithInternalError(errors.Wrap(err, "Database error updating user for confirmation")) } if err := models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken); err != nil { return internalServerError("Error sending confirmation email").WithInternalError(errors.Wrap(err, "Database error creating confirmation token")) } return nil } func (a *API) sendInvite(r *http.Request, tx *storage.Connection, u *models.User) error { config := a.config otpLength := config.Mailer.OtpLength var err error oldToken := u.ConfirmationToken otp := crypto.GenerateOtp(otpLength) u.ConfirmationToken = crypto.GenerateTokenHash(u.GetEmail(), otp) now := time.Now() if err = a.sendEmail(r, tx, u, mail.InviteVerification, otp, "", u.ConfirmationToken); err != nil { u.ConfirmationToken = oldToken if errors.Is(err, EmailRateLimitExceeded) { return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) } else if herr, ok := err.(*HTTPError); ok { return herr } return internalServerError("Error sending invite email").WithInternalError(err) } u.InvitedAt = &now u.ConfirmationSentAt = &now err = tx.UpdateOnly(u, "confirmation_token", "confirmation_sent_at", "invited_at") if err != nil { return internalServerError("Error inviting user").WithInternalError(errors.Wrap(err, "Database error updating user for invite")) } err = models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken) if err != nil { return internalServerError("Error inviting user").WithInternalError(errors.Wrap(err, "Database error creating confirmation token for invite")) } return nil } func (a *API) sendPasswordRecovery(r *http.Request, tx *storage.Connection, u *models.User, flowType models.FlowType) error { config := a.config otpLength := config.Mailer.OtpLength if err := validateSentWithinFrequencyLimit(u.RecoverySentAt, config.SMTP.MaxFrequency); err != nil { return err } oldToken := u.RecoveryToken otp := crypto.GenerateOtp(otpLength) token := crypto.GenerateTokenHash(u.GetEmail(), otp) u.RecoveryToken = addFlowPrefixToToken(token, flowType) now := time.Now() if err := a.sendEmail(r, tx, u, mail.RecoveryVerification, otp, "", u.RecoveryToken); err != nil { u.RecoveryToken = oldToken if errors.Is(err, EmailRateLimitExceeded) { return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) } else if herr, ok := err.(*HTTPError); ok { return herr } return internalServerError("Error sending recovery email").WithInternalError(err) } u.RecoverySentAt = &now if err := tx.UpdateOnly(u, "recovery_token", "recovery_sent_at"); err != nil { return internalServerError("Error sending recovery email").WithInternalError(errors.Wrap(err, "Database error updating user for recovery")) } if err := models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.RecoveryToken, models.RecoveryToken); err != nil { return internalServerError("Error sending recovery email").WithInternalError(errors.Wrap(err, "Database error creating recovery token")) } return nil } func (a *API) sendReauthenticationOtp(r *http.Request, tx *storage.Connection, u *models.User) error { config := a.config maxFrequency := config.SMTP.MaxFrequency otpLength := config.Mailer.OtpLength if err := validateSentWithinFrequencyLimit(u.ReauthenticationSentAt, maxFrequency); err != nil { return err } oldToken := u.ReauthenticationToken otp := crypto.GenerateOtp(otpLength) u.ReauthenticationToken = crypto.GenerateTokenHash(u.GetEmail(), otp) now := time.Now() if err := a.sendEmail(r, tx, u, mail.ReauthenticationVerification, otp, "", u.ReauthenticationToken); err != nil { u.ReauthenticationToken = oldToken if errors.Is(err, EmailRateLimitExceeded) { return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) } else if herr, ok := err.(*HTTPError); ok { return herr } return internalServerError("Error sending reauthentication email").WithInternalError(err) } u.ReauthenticationSentAt = &now if err := tx.UpdateOnly(u, "reauthentication_token", "reauthentication_sent_at"); err != nil { return internalServerError("Error sending reauthentication email").WithInternalError(errors.Wrap(err, "Database error updating user for reauthentication")) } if err := models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.ReauthenticationToken, models.ReauthenticationToken); err != nil { return internalServerError("Error sending reauthentication email").WithInternalError(errors.Wrap(err, "Database error creating reauthentication token")) } return nil } func (a *API) sendMagicLink(r *http.Request, tx *storage.Connection, u *models.User, flowType models.FlowType) error { var err error config := a.config otpLength := config.Mailer.OtpLength // since Magic Link is just a recovery with a different template and behaviour // around new users we will reuse the recovery db timer to prevent potential abuse if err := validateSentWithinFrequencyLimit(u.RecoverySentAt, config.SMTP.MaxFrequency); err != nil { return err } oldToken := u.RecoveryToken otp := crypto.GenerateOtp(otpLength) token := crypto.GenerateTokenHash(u.GetEmail(), otp) u.RecoveryToken = addFlowPrefixToToken(token, flowType) now := time.Now() if err = a.sendEmail(r, tx, u, mail.MagicLinkVerification, otp, "", u.RecoveryToken); err != nil { u.RecoveryToken = oldToken if errors.Is(err, EmailRateLimitExceeded) { return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) } else if herr, ok := err.(*HTTPError); ok { return herr } return internalServerError("Error sending magic link email").WithInternalError(err) } u.RecoverySentAt = &now if err := tx.UpdateOnly(u, "recovery_token", "recovery_sent_at"); err != nil { return internalServerError("Error sending magic link email").WithInternalError(errors.Wrap(err, "Database error updating user for recovery")) } if err := models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.RecoveryToken, models.RecoveryToken); err != nil { return internalServerError("Error sending magic link email").WithInternalError(errors.Wrap(err, "Database error creating recovery token")) } return nil } // sendEmailChange sends out an email change token to the new email. func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models.User, email string, flowType models.FlowType) error { config := a.config otpLength := config.Mailer.OtpLength if err := validateSentWithinFrequencyLimit(u.EmailChangeSentAt, config.SMTP.MaxFrequency); err != nil { return err } otpNew := crypto.GenerateOtp(otpLength) u.EmailChange = email token := crypto.GenerateTokenHash(u.EmailChange, otpNew) u.EmailChangeTokenNew = addFlowPrefixToToken(token, flowType) otpCurrent := "" if config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" { otpCurrent = crypto.GenerateOtp(otpLength) currentToken := crypto.GenerateTokenHash(u.GetEmail(), otpCurrent) u.EmailChangeTokenCurrent = addFlowPrefixToToken(currentToken, flowType) } u.EmailChangeConfirmStatus = zeroConfirmation now := time.Now() if err := a.sendEmail(r, tx, u, mail.EmailChangeVerification, otpCurrent, otpNew, u.EmailChangeTokenNew); err != nil { if errors.Is(err, EmailRateLimitExceeded) { return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) } else if herr, ok := err.(*HTTPError); ok { return herr } return internalServerError("Error sending email change email").WithInternalError(err) } u.EmailChangeSentAt = &now if err := tx.UpdateOnly( u, "email_change_token_current", "email_change_token_new", "email_change", "email_change_sent_at", "email_change_confirm_status", ); err != nil { return internalServerError("Error sending email change email").WithInternalError(errors.Wrap(err, "Database error updating user for email change")) } if u.EmailChangeTokenCurrent != "" { if err := models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.EmailChangeTokenCurrent, models.EmailChangeTokenCurrent); err != nil { return internalServerError("Error sending email change email").WithInternalError(errors.Wrap(err, "Database error creating email change token current")) } } if u.EmailChangeTokenNew != "" { if err := models.CreateOneTimeToken(tx, u.ID, u.EmailChange, u.EmailChangeTokenNew, models.EmailChangeTokenNew); err != nil { return internalServerError("Error sending email change email").WithInternalError(errors.Wrap(err, "Database error creating email change token new")) } } return nil } func (a *API) validateEmail(email string) (string, error) { if email == "" { return "", badRequestError(ErrorCodeValidationFailed, "An email address is required") } if len(email) > 255 { return "", badRequestError(ErrorCodeValidationFailed, "An email address is too long") } if err := checkmail.ValidateFormat(email); err != nil { return "", badRequestError(ErrorCodeValidationFailed, "Unable to validate email address: "+err.Error()) } return strings.ToLower(email), nil } func validateSentWithinFrequencyLimit(sentAt *time.Time, frequency time.Duration) error { if sentAt != nil && sentAt.Add(frequency).After(time.Now()) { return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, generateFrequencyLimitErrorMessage(sentAt, frequency)) } return nil } var emailLabelPattern = regexp.MustCompile("[+][^@]+@") func (a *API) checkEmailAddressAuthorization(email string) bool { if len(a.config.External.Email.AuthorizedAddresses) > 0 { // allow labelled emails when authorization rules are in place normalized := emailLabelPattern.ReplaceAllString(email, "@") for _, authorizedAddress := range a.config.External.Email.AuthorizedAddresses { if strings.EqualFold(normalized, authorizedAddress) { return true } } return false } return true } func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, emailActionType, otp, otpNew, tokenHashWithPrefix string) error { ctx := r.Context() config := a.config referrerURL := utilities.GetReferrer(r, config) externalURL := getExternalHost(ctx) if emailActionType != mail.EmailChangeVerification { if u.GetEmail() != "" && !a.checkEmailAddressAuthorization(u.GetEmail()) { return badRequestError(ErrorCodeEmailAddressNotAuthorized, "Email address %q cannot be used as it is not authorized", u.GetEmail()) } } else { // first check that the user can update their address to the // new one in u.EmailChange if u.EmailChange != "" && !a.checkEmailAddressAuthorization(u.EmailChange) { return badRequestError(ErrorCodeEmailAddressNotAuthorized, "Email address %q cannot be used as it is not authorized", u.EmailChange) } // if secure email change is enabled, check that the user // account (which could have been created before the authorized // address authorization restriction was enabled) can even // receive the confirmation message to the existing address if config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" && !a.checkEmailAddressAuthorization(u.GetEmail()) { return badRequestError(ErrorCodeEmailAddressNotAuthorized, "Email address %q cannot be used as it is not authorized", u.GetEmail()) } } // if the number of events is set to zero, we immediately apply rate limits. if config.RateLimitEmailSent.Events == 0 { emailRateLimitCounter.Add( ctx, 1, metric.WithAttributeSet(attribute.NewSet(attribute.String("path", r.URL.Path))), ) return EmailRateLimitExceeded } // TODO(km): Deprecate this behaviour - rate limits should still be applied to autoconfirm if !config.Mailer.Autoconfirm { // apply rate limiting before the email is sent out if ok := a.limiterOpts.Email.Allow(); !ok { emailRateLimitCounter.Add( ctx, 1, metric.WithAttributeSet(attribute.NewSet(attribute.String("path", r.URL.Path))), ) return EmailRateLimitExceeded } } if config.Hook.SendEmail.Enabled { // When secure email change is disabled, we place the token for the new email on emailData.Token if emailActionType == mail.EmailChangeVerification && !config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" { otp = otpNew } emailData := mail.EmailData{ Token: otp, EmailActionType: emailActionType, RedirectTo: referrerURL, SiteURL: externalURL.String(), TokenHash: tokenHashWithPrefix, } if emailActionType == mail.EmailChangeVerification && config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" { emailData.TokenNew = otpNew emailData.TokenHashNew = u.EmailChangeTokenCurrent } input := hooks.SendEmailInput{ User: u, EmailData: emailData, } output := hooks.SendEmailOutput{} return a.invokeHook(tx, r, &input, &output) } mr := a.Mailer() var err error switch emailActionType { case mail.SignupVerification: err = mr.ConfirmationMail(r, u, otp, referrerURL, externalURL) case mail.MagicLinkVerification: err = mr.MagicLinkMail(r, u, otp, referrerURL, externalURL) case mail.ReauthenticationVerification: err = mr.ReauthenticateMail(r, u, otp) case mail.RecoveryVerification: err = mr.RecoveryMail(r, u, otp, referrerURL, externalURL) case mail.InviteVerification: err = mr.InviteMail(r, u, otp, referrerURL, externalURL) case mail.EmailChangeVerification: err = mr.EmailChangeMail(r, u, otpNew, otp, referrerURL, externalURL) default: err = errors.New("invalid email action type") } switch { case errors.Is(err, mail.ErrInvalidEmailAddress), errors.Is(err, mail.ErrInvalidEmailFormat), errors.Is(err, mail.ErrInvalidEmailDNS): return badRequestError( ErrorCodeEmailAddressInvalid, "Email address %q is invalid", u.GetEmail()) default: return err } }