750 lines
23 KiB
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 == ""
|
|
}
|