686 lines
24 KiB
Go
686 lines
24 KiB
Go
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
|
|
}
|
|
}
|