chatdesk-ui/auth_v2.169.0/internal/api/verify.go

750 lines
23 KiB
Go

package api
import (
"context"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/fatih/structs"
"github.com/sethvargo/go-password/password"
"github.com/supabase/auth/internal/api/provider"
"github.com/supabase/auth/internal/api/sms_provider"
"github.com/supabase/auth/internal/crypto"
mail "github.com/supabase/auth/internal/mailer"
"github.com/supabase/auth/internal/models"
"github.com/supabase/auth/internal/observability"
"github.com/supabase/auth/internal/storage"
"github.com/supabase/auth/internal/utilities"
)
const (
smsVerification = "sms"
phoneChangeVerification = "phone_change"
// includes signupVerification and magicLinkVerification
)
const (
zeroConfirmation int = iota
singleConfirmation
)
// Only applicable when SECURE_EMAIL_CHANGE_ENABLED
const singleConfirmationAccepted = "Confirmation link accepted. Please proceed to confirm link sent to the other email"
// VerifyParams are the parameters the Verify endpoint accepts
type VerifyParams struct {
Type string `json:"type"`
Token string `json:"token"`
TokenHash string `json:"token_hash"`
Email string `json:"email"`
Phone string `json:"phone"`
RedirectTo string `json:"redirect_to"`
}
func (p *VerifyParams) Validate(r *http.Request, a *API) error {
var err error
if p.Type == "" {
return badRequestError(ErrorCodeValidationFailed, "Verify requires a verification type")
}
switch r.Method {
case http.MethodGet:
if p.Token == "" {
return badRequestError(ErrorCodeValidationFailed, "Verify requires a token or a token hash")
}
// TODO: deprecate the token query param from GET /verify and use token_hash instead (breaking change)
p.TokenHash = p.Token
case http.MethodPost:
if (p.Token == "" && p.TokenHash == "") || (p.Token != "" && p.TokenHash != "") {
return badRequestError(ErrorCodeValidationFailed, "Verify requires either a token or a token hash")
}
if p.Token != "" {
if isPhoneOtpVerification(p) {
p.Phone, err = validatePhone(p.Phone)
if err != nil {
return err
}
p.TokenHash = crypto.GenerateTokenHash(p.Phone, p.Token)
} else if isEmailOtpVerification(p) {
p.Email, err = a.validateEmail(p.Email)
if err != nil {
return unprocessableEntityError(ErrorCodeValidationFailed, "Invalid email format").WithInternalError(err)
}
p.TokenHash = crypto.GenerateTokenHash(p.Email, p.Token)
} else {
return badRequestError(ErrorCodeValidationFailed, "Only an email address or phone number should be provided on verify")
}
} else if p.TokenHash != "" {
if p.Email != "" || p.Phone != "" || p.RedirectTo != "" {
return badRequestError(ErrorCodeValidationFailed, "Only the token_hash and type should be provided")
}
}
default:
return nil
}
return nil
}
// Verify exchanges a confirmation or recovery token to a refresh token
func (a *API) Verify(w http.ResponseWriter, r *http.Request) error {
params := &VerifyParams{}
switch r.Method {
case http.MethodGet:
params.Token = r.FormValue("token")
params.Type = r.FormValue("type")
params.RedirectTo = utilities.GetReferrer(r, a.config)
if err := params.Validate(r, a); err != nil {
return err
}
return a.verifyGet(w, r, params)
case http.MethodPost:
if err := retrieveRequestParams(r, params); err != nil {
return err
}
if err := params.Validate(r, a); err != nil {
return err
}
return a.verifyPost(w, r, params)
default:
// this should have been handled by Chi
panic("Only GET and POST methods allowed")
}
}
func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyParams) error {
ctx := r.Context()
db := a.db.WithContext(ctx)
var (
user *models.User
grantParams models.GrantParams
err error
token *AccessTokenResponse
authCode string
rurl string
)
grantParams.FillGrantParams(r)
flowType := models.ImplicitFlow
var authenticationMethod models.AuthenticationMethod
if strings.HasPrefix(params.Token, PKCEPrefix) {
flowType = models.PKCEFlow
authenticationMethod, err = models.ParseAuthenticationMethod(params.Type)
if err != nil {
return err
}
}
err = db.Transaction(func(tx *storage.Connection) error {
var terr error
user, terr = a.verifyTokenHash(tx, params)
if terr != nil {
return terr
}
switch params.Type {
case mail.SignupVerification, mail.InviteVerification:
user, terr = a.signupVerify(r, ctx, tx, user)
case mail.RecoveryVerification, mail.MagicLinkVerification:
user, terr = a.recoverVerify(r, tx, user)
case mail.EmailChangeVerification:
user, terr = a.emailChangeVerify(r, tx, params, user)
if user == nil && terr == nil {
// only one OTP is confirmed at this point, so we return early and ask the user to confirm the second OTP
rurl, terr = a.prepRedirectURL(singleConfirmationAccepted, params.RedirectTo, flowType)
if terr != nil {
return terr
}
return nil
}
default:
return badRequestError(ErrorCodeValidationFailed, "Unsupported verification type")
}
if terr != nil {
return terr
}
if terr := user.UpdateAppMetaDataProviders(tx); terr != nil {
return terr
}
// Reload user model from db.
// This is important for refreshing the data in any generated columns like IsAnonymous.
if terr := tx.Reload(user); err != nil {
return terr
}
if isImplicitFlow(flowType) {
token, terr = a.issueRefreshToken(r, tx, user, models.OTP, grantParams)
if terr != nil {
return terr
}
} else if isPKCEFlow(flowType) {
if authCode, terr = issueAuthCode(tx, user, authenticationMethod); terr != nil {
return badRequestError(ErrorCodeFlowStateNotFound, "No associated flow state found. %s", terr)
}
}
return nil
})
if err != nil {
var herr *HTTPError
if errors.As(err, &herr) {
rurl, err = a.prepErrorRedirectURL(herr, r, params.RedirectTo, flowType)
if err != nil {
return err
}
}
}
if rurl != "" {
http.Redirect(w, r, rurl, http.StatusSeeOther)
return nil
}
rurl = params.RedirectTo
if isImplicitFlow(flowType) && token != nil {
q := url.Values{}
q.Set("type", params.Type)
rurl = token.AsRedirectURL(rurl, q)
} else if isPKCEFlow(flowType) {
rurl, err = a.prepPKCERedirectURL(rurl, authCode)
if err != nil {
return err
}
}
http.Redirect(w, r, rurl, http.StatusSeeOther)
return nil
}
func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyParams) error {
ctx := r.Context()
db := a.db.WithContext(ctx)
var (
user *models.User
grantParams models.GrantParams
token *AccessTokenResponse
)
var isSingleConfirmationResponse = false
grantParams.FillGrantParams(r)
err := db.Transaction(func(tx *storage.Connection) error {
var terr error
aud := a.requestAud(ctx, r)
if isUsingTokenHash(params) {
user, terr = a.verifyTokenHash(tx, params)
} else {
user, terr = a.verifyUserAndToken(tx, params, aud)
}
if terr != nil {
return terr
}
switch params.Type {
case mail.SignupVerification, mail.InviteVerification:
user, terr = a.signupVerify(r, ctx, tx, user)
case mail.RecoveryVerification, mail.MagicLinkVerification:
user, terr = a.recoverVerify(r, tx, user)
case mail.EmailChangeVerification:
user, terr = a.emailChangeVerify(r, tx, params, user)
if user == nil && terr == nil {
isSingleConfirmationResponse = true
return nil
}
case smsVerification, phoneChangeVerification:
user, terr = a.smsVerify(r, tx, user, params)
default:
return badRequestError(ErrorCodeValidationFailed, "Unsupported verification type")
}
if terr != nil {
return terr
}
if terr := user.UpdateAppMetaDataProviders(tx); terr != nil {
return terr
}
// Reload user model from db.
// This is important for refreshing the data in any generated columns like IsAnonymous.
if terr := tx.Reload(user); terr != nil {
return terr
}
token, terr = a.issueRefreshToken(r, tx, user, models.OTP, grantParams)
if terr != nil {
return terr
}
return nil
})
if err != nil {
return err
}
if isSingleConfirmationResponse {
return sendJSON(w, http.StatusOK, map[string]string{
"msg": singleConfirmationAccepted,
"code": strconv.Itoa(http.StatusOK),
})
}
return sendJSON(w, http.StatusOK, token)
}
func (a *API) signupVerify(r *http.Request, ctx context.Context, conn *storage.Connection, user *models.User) (*models.User, error) {
config := a.config
shouldUpdatePassword := false
if !user.HasPassword() && user.InvitedAt != nil {
// sign them up with temporary password, and require application
// to present the user with a password set form
password, err := password.Generate(64, 10, 0, false, true)
if err != nil {
// password generation must succeed
panic(err)
}
if err := user.SetPassword(ctx, password, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil {
return nil, err
}
shouldUpdatePassword = true
}
err := conn.Transaction(func(tx *storage.Connection) error {
var terr error
if shouldUpdatePassword {
if terr = user.UpdatePassword(tx, nil); terr != nil {
return internalServerError("Error storing password").WithInternalError(terr)
}
}
if terr = models.NewAuditLogEntry(r, tx, user, models.UserSignedUpAction, "", nil); terr != nil {
return terr
}
if terr = user.Confirm(tx); terr != nil {
return internalServerError("Error confirming user").WithInternalError(terr)
}
for _, identity := range user.Identities {
if identity.Email == "" || user.Email == "" || identity.Email != user.Email {
continue
}
if terr = identity.UpdateIdentityData(tx, map[string]interface{}{
"email_verified": true,
}); terr != nil {
return internalServerError("Error setting email_verified to true on identity").WithInternalError(terr)
}
}
return nil
})
if err != nil {
return nil, err
}
return user, nil
}
func (a *API) recoverVerify(r *http.Request, conn *storage.Connection, user *models.User) (*models.User, error) {
err := conn.Transaction(func(tx *storage.Connection) error {
var terr error
if terr = user.Recover(tx); terr != nil {
return terr
}
if !user.IsConfirmed() {
if terr = models.NewAuditLogEntry(r, tx, user, models.UserSignedUpAction, "", nil); terr != nil {
return terr
}
if terr = user.Confirm(tx); terr != nil {
return terr
}
} else {
if terr = models.NewAuditLogEntry(r, tx, user, models.LoginAction, "", nil); terr != nil {
return terr
}
}
return nil
})
if err != nil {
return nil, internalServerError("Database error updating user").WithInternalError(err)
}
return user, nil
}
func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models.User, params *VerifyParams) (*models.User, error) {
err := conn.Transaction(func(tx *storage.Connection) error {
if params.Type == smsVerification {
if terr := models.NewAuditLogEntry(r, tx, user, models.UserSignedUpAction, "", nil); terr != nil {
return terr
}
if terr := user.ConfirmPhone(tx); terr != nil {
return internalServerError("Error confirming user").WithInternalError(terr)
}
} else if params.Type == phoneChangeVerification {
if terr := models.NewAuditLogEntry(r, tx, user, models.UserModifiedAction, "", nil); terr != nil {
return terr
}
if identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "phone"); terr != nil {
if !models.IsNotFoundError(terr) {
return terr
}
// confirming the phone change should create a new phone identity if the user doesn't have one
if _, terr = a.createNewIdentity(tx, user, "phone", structs.Map(provider.Claims{
Subject: user.ID.String(),
Phone: params.Phone,
PhoneVerified: true,
})); terr != nil {
return terr
}
} else {
if terr := identity.UpdateIdentityData(tx, map[string]interface{}{
"phone": params.Phone,
"phone_verified": true,
}); terr != nil {
return terr
}
}
if terr := user.ConfirmPhoneChange(tx); terr != nil {
return internalServerError("Error confirming user").WithInternalError(terr)
}
}
if user.IsAnonymous {
user.IsAnonymous = false
if terr := tx.UpdateOnly(user, "is_anonymous"); terr != nil {
return terr
}
}
if terr := tx.Load(user, "Identities"); terr != nil {
return internalServerError("Error refetching identities").WithInternalError(terr)
}
return nil
})
if err != nil {
return nil, err
}
return user, nil
}
func (a *API) prepErrorRedirectURL(err *HTTPError, r *http.Request, rurl string, flowType models.FlowType) (string, error) {
u, perr := url.Parse(rurl)
if perr != nil {
return "", err
}
q := u.Query()
// Maintain separate query params for hash and query
hq := url.Values{}
log := observability.GetLogEntry(r).Entry
errorID := utilities.GetRequestID(r.Context())
err.ErrorID = errorID
log.WithError(err.Cause()).Info(err.Error())
if str, ok := oauthErrorMap[err.HTTPStatus]; ok {
hq.Set("error", str)
q.Set("error", str)
}
hq.Set("error_code", err.ErrorCode)
hq.Set("error_description", err.Message)
q.Set("error_code", err.ErrorCode)
q.Set("error_description", err.Message)
if flowType == models.PKCEFlow {
// Additionally, may override existing error query param if set to PKCE.
u.RawQuery = q.Encode()
}
// Left as hash fragment to comply with spec.
u.Fragment = hq.Encode()
return u.String(), nil
}
func (a *API) prepRedirectURL(message string, rurl string, flowType models.FlowType) (string, error) {
u, perr := url.Parse(rurl)
if perr != nil {
return "", perr
}
hq := url.Values{}
q := u.Query()
hq.Set("message", message)
if flowType == models.PKCEFlow {
q.Set("message", message)
}
u.RawQuery = q.Encode()
u.Fragment = hq.Encode()
return u.String(), nil
}
func (a *API) prepPKCERedirectURL(rurl, code string) (string, error) {
u, err := url.Parse(rurl)
if err != nil {
return "", err
}
q := u.Query()
q.Set("code", code)
u.RawQuery = q.Encode()
return u.String(), nil
}
func (a *API) emailChangeVerify(r *http.Request, conn *storage.Connection, params *VerifyParams, user *models.User) (*models.User, error) {
config := a.config
if !config.Mailer.Autoconfirm &&
config.Mailer.SecureEmailChangeEnabled &&
user.EmailChangeConfirmStatus == zeroConfirmation &&
user.GetEmail() != "" {
err := conn.Transaction(func(tx *storage.Connection) error {
currentOTT, terr := models.FindOneTimeToken(tx, params.TokenHash, models.EmailChangeTokenCurrent)
if terr != nil && !models.IsNotFoundError(terr) {
return terr
}
newOTT, terr := models.FindOneTimeToken(tx, params.TokenHash, models.EmailChangeTokenNew)
if terr != nil && !models.IsNotFoundError(terr) {
return terr
}
user.EmailChangeConfirmStatus = singleConfirmation
if params.Token == user.EmailChangeTokenCurrent || params.TokenHash == user.EmailChangeTokenCurrent || (currentOTT != nil && params.TokenHash == currentOTT.TokenHash) {
user.EmailChangeTokenCurrent = ""
if terr := models.ClearOneTimeTokenForUser(tx, user.ID, models.EmailChangeTokenCurrent); terr != nil {
return terr
}
} else if params.Token == user.EmailChangeTokenNew || params.TokenHash == user.EmailChangeTokenNew || (newOTT != nil && params.TokenHash == newOTT.TokenHash) {
user.EmailChangeTokenNew = ""
if terr := models.ClearOneTimeTokenForUser(tx, user.ID, models.EmailChangeTokenNew); terr != nil {
return terr
}
}
if terr := tx.UpdateOnly(user, "email_change_confirm_status", "email_change_token_current", "email_change_token_new"); terr != nil {
return terr
}
return nil
})
if err != nil {
return nil, err
}
return nil, nil
}
// one email is confirmed at this point if GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED is enabled
err := conn.Transaction(func(tx *storage.Connection) error {
if terr := models.NewAuditLogEntry(r, tx, user, models.UserModifiedAction, "", nil); terr != nil {
return terr
}
if identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "email"); terr != nil {
if !models.IsNotFoundError(terr) {
return terr
}
// confirming the email change should create a new email identity if the user doesn't have one
if _, terr = a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{
Subject: user.ID.String(),
Email: user.EmailChange,
EmailVerified: true,
})); terr != nil {
return terr
}
} else {
if terr := identity.UpdateIdentityData(tx, map[string]interface{}{
"email": user.EmailChange,
"email_verified": true,
}); terr != nil {
return terr
}
}
if user.IsAnonymous {
user.IsAnonymous = false
if terr := tx.UpdateOnly(user, "is_anonymous"); terr != nil {
return terr
}
}
if terr := tx.Load(user, "Identities"); terr != nil {
return internalServerError("Error refetching identities").WithInternalError(terr)
}
if terr := user.ConfirmEmailChange(tx, zeroConfirmation); terr != nil {
return internalServerError("Error confirm email").WithInternalError(terr)
}
return nil
})
if err != nil {
return nil, err
}
return user, nil
}
func (a *API) verifyTokenHash(conn *storage.Connection, params *VerifyParams) (*models.User, error) {
config := a.config
var user *models.User
var err error
switch params.Type {
case mail.EmailOTPVerification:
// need to find user by confirmation token or recovery token with the token hash
user, err = models.FindUserByConfirmationOrRecoveryToken(conn, params.TokenHash)
case mail.SignupVerification, mail.InviteVerification:
user, err = models.FindUserByConfirmationToken(conn, params.TokenHash)
case mail.RecoveryVerification, mail.MagicLinkVerification:
user, err = models.FindUserByRecoveryToken(conn, params.TokenHash)
case mail.EmailChangeVerification:
user, err = models.FindUserByEmailChangeToken(conn, params.TokenHash)
default:
return nil, badRequestError(ErrorCodeValidationFailed, "Invalid email verification type")
}
if err != nil {
if models.IsNotFoundError(err) {
return nil, forbiddenError(ErrorCodeOTPExpired, "Email link is invalid or has expired").WithInternalError(err)
}
return nil, internalServerError("Database error finding user from email link").WithInternalError(err)
}
if user.IsBanned() {
return nil, forbiddenError(ErrorCodeUserBanned, "User is banned")
}
var isExpired bool
switch params.Type {
case mail.EmailOTPVerification:
sentAt := user.ConfirmationSentAt
params.Type = "signup"
if user.RecoveryToken == params.TokenHash {
sentAt = user.RecoverySentAt
params.Type = "magiclink"
}
isExpired = isOtpExpired(sentAt, config.Mailer.OtpExp)
case mail.SignupVerification, mail.InviteVerification:
isExpired = isOtpExpired(user.ConfirmationSentAt, config.Mailer.OtpExp)
case mail.RecoveryVerification, mail.MagicLinkVerification:
isExpired = isOtpExpired(user.RecoverySentAt, config.Mailer.OtpExp)
case mail.EmailChangeVerification:
isExpired = isOtpExpired(user.EmailChangeSentAt, config.Mailer.OtpExp)
}
if isExpired {
return nil, forbiddenError(ErrorCodeOTPExpired, "Email link is invalid or has expired").WithInternalMessage("email link has expired")
}
return user, nil
}
// verifyUserAndToken verifies the token associated to the user based on the verify type
func (a *API) verifyUserAndToken(conn *storage.Connection, params *VerifyParams, aud string) (*models.User, error) {
config := a.config
var user *models.User
var err error
tokenHash := params.TokenHash
switch params.Type {
case phoneChangeVerification:
user, err = models.FindUserByPhoneChangeAndAudience(conn, params.Phone, aud)
case smsVerification:
user, err = models.FindUserByPhoneAndAudience(conn, params.Phone, aud)
case mail.EmailChangeVerification:
// Since the email change could be trigger via the implicit or PKCE flow,
// the query used has to also check if the token saved in the db contains the pkce_ prefix
user, err = models.FindUserForEmailChange(conn, params.Email, tokenHash, aud, config.Mailer.SecureEmailChangeEnabled)
default:
user, err = models.FindUserByEmailAndAudience(conn, params.Email, aud)
}
if err != nil {
if models.IsNotFoundError(err) {
return nil, forbiddenError(ErrorCodeOTPExpired, "Token has expired or is invalid").WithInternalError(err)
}
return nil, internalServerError("Database error finding user").WithInternalError(err)
}
if user.IsBanned() {
return nil, forbiddenError(ErrorCodeUserBanned, "User is banned")
}
var isValid bool
smsProvider, _ := sms_provider.GetSmsProvider(*config)
switch params.Type {
case mail.EmailOTPVerification:
// if the type is emailOTPVerification, we'll check both the confirmation_token and recovery_token columns
if isOtpValid(tokenHash, user.ConfirmationToken, user.ConfirmationSentAt, config.Mailer.OtpExp) {
isValid = true
params.Type = mail.SignupVerification
} else if isOtpValid(tokenHash, user.RecoveryToken, user.RecoverySentAt, config.Mailer.OtpExp) {
isValid = true
params.Type = mail.MagicLinkVerification
} else {
isValid = false
}
case mail.SignupVerification, mail.InviteVerification:
isValid = isOtpValid(tokenHash, user.ConfirmationToken, user.ConfirmationSentAt, config.Mailer.OtpExp)
case mail.RecoveryVerification, mail.MagicLinkVerification:
isValid = isOtpValid(tokenHash, user.RecoveryToken, user.RecoverySentAt, config.Mailer.OtpExp)
case mail.EmailChangeVerification:
isValid = isOtpValid(tokenHash, user.EmailChangeTokenCurrent, user.EmailChangeSentAt, config.Mailer.OtpExp) ||
isOtpValid(tokenHash, user.EmailChangeTokenNew, user.EmailChangeSentAt, config.Mailer.OtpExp)
case phoneChangeVerification, smsVerification:
if testOTP, ok := config.Sms.GetTestOTP(params.Phone, time.Now()); ok {
if params.Token == testOTP {
return user, nil
}
}
phone := params.Phone
sentAt := user.ConfirmationSentAt
expectedToken := user.ConfirmationToken
if params.Type == phoneChangeVerification {
phone = user.PhoneChange
sentAt = user.PhoneChangeSentAt
expectedToken = user.PhoneChangeToken
}
if !config.Hook.SendSMS.Enabled && config.Sms.IsTwilioVerifyProvider() {
if err := smsProvider.(*sms_provider.TwilioVerifyProvider).VerifyOTP(phone, params.Token); err != nil {
return nil, forbiddenError(ErrorCodeOTPExpired, "Token has expired or is invalid").WithInternalError(err)
}
return user, nil
}
isValid = isOtpValid(tokenHash, expectedToken, sentAt, config.Sms.OtpExp)
}
if !isValid {
return nil, forbiddenError(ErrorCodeOTPExpired, "Token has expired or is invalid").WithInternalMessage("token has expired or is invalid")
}
return user, nil
}
// isOtpValid checks the actual otp sent against the expected otp and ensures that it's within the valid window
func isOtpValid(actual, expected string, sentAt *time.Time, otpExp uint) bool {
if expected == "" || sentAt == nil {
return false
}
return !isOtpExpired(sentAt, otpExp) && ((actual == expected) || ("pkce_"+actual == expected))
}
func isOtpExpired(sentAt *time.Time, otpExp uint) bool {
return time.Now().After(sentAt.Add(time.Second * time.Duration(otpExp))) // #nosec G115
}
// isPhoneOtpVerification checks if the verification came from a phone otp
func isPhoneOtpVerification(params *VerifyParams) bool {
return params.Phone != "" && params.Email == ""
}
// isEmailOtpVerification checks if the verification came from an email otp
func isEmailOtpVerification(params *VerifyParams) bool {
return params.Phone == "" && params.Email != ""
}
func isUsingTokenHash(params *VerifyParams) bool {
return params.TokenHash != "" && params.Token == "" && params.Phone == "" && params.Email == ""
}