391 lines
12 KiB
Go
391 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/fatih/structs"
|
|
"github.com/gofrs/uuid"
|
|
"github.com/pkg/errors"
|
|
"github.com/supabase/auth/internal/api/provider"
|
|
"github.com/supabase/auth/internal/api/sms_provider"
|
|
"github.com/supabase/auth/internal/metering"
|
|
"github.com/supabase/auth/internal/models"
|
|
"github.com/supabase/auth/internal/storage"
|
|
)
|
|
|
|
// SignupParams are the parameters the Signup endpoint accepts
|
|
type SignupParams struct {
|
|
Email string `json:"email"`
|
|
Phone string `json:"phone"`
|
|
Password string `json:"password"`
|
|
Data map[string]interface{} `json:"data"`
|
|
Provider string `json:"-"`
|
|
Aud string `json:"-"`
|
|
Channel string `json:"channel"`
|
|
CodeChallengeMethod string `json:"code_challenge_method"`
|
|
CodeChallenge string `json:"code_challenge"`
|
|
}
|
|
|
|
func (a *API) validateSignupParams(ctx context.Context, p *SignupParams) error {
|
|
config := a.config
|
|
|
|
if p.Password == "" {
|
|
return badRequestError(ErrorCodeValidationFailed, "Signup requires a valid password")
|
|
}
|
|
|
|
if err := a.checkPasswordStrength(ctx, p.Password); err != nil {
|
|
return err
|
|
}
|
|
if p.Email != "" && p.Phone != "" {
|
|
return badRequestError(ErrorCodeValidationFailed, "Only an email address or phone number should be provided on signup.")
|
|
}
|
|
if p.Provider == "phone" && !sms_provider.IsValidMessageChannel(p.Channel, config) {
|
|
return badRequestError(ErrorCodeValidationFailed, InvalidChannelError)
|
|
}
|
|
// PKCE not needed as phone signups already return access token in body
|
|
if p.Phone != "" && p.CodeChallenge != "" {
|
|
return badRequestError(ErrorCodeValidationFailed, "PKCE not supported for phone signups")
|
|
}
|
|
if err := validatePKCEParams(p.CodeChallengeMethod, p.CodeChallenge); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *SignupParams) ConfigureDefaults() {
|
|
if p.Email != "" {
|
|
p.Provider = "email"
|
|
} else if p.Phone != "" {
|
|
p.Provider = "phone"
|
|
}
|
|
if p.Data == nil {
|
|
p.Data = make(map[string]interface{})
|
|
}
|
|
|
|
// For backwards compatibility, we default to SMS if params Channel is not specified
|
|
if p.Phone != "" && p.Channel == "" {
|
|
p.Channel = sms_provider.SMSProvider
|
|
}
|
|
}
|
|
|
|
func (params *SignupParams) ToUserModel(isSSOUser bool) (user *models.User, err error) {
|
|
switch params.Provider {
|
|
case "email":
|
|
user, err = models.NewUser("", params.Email, params.Password, params.Aud, params.Data)
|
|
case "phone":
|
|
user, err = models.NewUser(params.Phone, "", params.Password, params.Aud, params.Data)
|
|
case "anonymous":
|
|
user, err = models.NewUser("", "", "", params.Aud, params.Data)
|
|
user.IsAnonymous = true
|
|
default:
|
|
// handles external provider case
|
|
user, err = models.NewUser("", params.Email, params.Password, params.Aud, params.Data)
|
|
}
|
|
if err != nil {
|
|
err = internalServerError("Database error creating user").WithInternalError(err)
|
|
return
|
|
}
|
|
user.IsSSOUser = isSSOUser
|
|
if user.AppMetaData == nil {
|
|
user.AppMetaData = make(map[string]interface{})
|
|
}
|
|
|
|
user.Identities = make([]models.Identity, 0)
|
|
|
|
if params.Provider != "anonymous" {
|
|
// TODO: Deprecate "provider" field
|
|
user.AppMetaData["provider"] = params.Provider
|
|
|
|
user.AppMetaData["providers"] = []string{params.Provider}
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// Signup is the endpoint for registering a new user
|
|
func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
|
|
ctx := r.Context()
|
|
config := a.config
|
|
db := a.db.WithContext(ctx)
|
|
|
|
if config.DisableSignup {
|
|
return unprocessableEntityError(ErrorCodeSignupDisabled, "Signups not allowed for this instance")
|
|
}
|
|
|
|
params := &SignupParams{}
|
|
if err := retrieveRequestParams(r, params); err != nil {
|
|
return err
|
|
}
|
|
|
|
params.ConfigureDefaults()
|
|
|
|
if err := a.validateSignupParams(ctx, params); err != nil {
|
|
return err
|
|
}
|
|
|
|
var err error
|
|
flowType := getFlowFromChallenge(params.CodeChallenge)
|
|
|
|
var user *models.User
|
|
var grantParams models.GrantParams
|
|
|
|
grantParams.FillGrantParams(r)
|
|
|
|
params.Aud = a.requestAud(ctx, r)
|
|
|
|
switch params.Provider {
|
|
case "email":
|
|
if !config.External.Email.Enabled {
|
|
return badRequestError(ErrorCodeEmailProviderDisabled, "Email signups are disabled")
|
|
}
|
|
params.Email, err = a.validateEmail(params.Email)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user, err = models.IsDuplicatedEmail(db, params.Email, params.Aud, nil)
|
|
case "phone":
|
|
if !config.External.Phone.Enabled {
|
|
return badRequestError(ErrorCodePhoneProviderDisabled, "Phone signups are disabled")
|
|
}
|
|
params.Phone, err = validatePhone(params.Phone)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user, err = models.FindUserByPhoneAndAudience(db, params.Phone, params.Aud)
|
|
default:
|
|
msg := ""
|
|
if config.External.Email.Enabled && config.External.Phone.Enabled {
|
|
msg = "Sign up only available with email or phone provider"
|
|
} else if config.External.Email.Enabled {
|
|
msg = "Sign up only available with email provider"
|
|
} else if config.External.Phone.Enabled {
|
|
msg = "Sign up only available with phone provider"
|
|
} else {
|
|
msg = "Sign up with this provider not possible"
|
|
}
|
|
|
|
return badRequestError(ErrorCodeValidationFailed, msg)
|
|
}
|
|
|
|
if err != nil && !models.IsNotFoundError(err) {
|
|
return internalServerError("Database error finding user").WithInternalError(err)
|
|
}
|
|
|
|
var signupUser *models.User
|
|
if user == nil {
|
|
// always call this outside of a database transaction as this method
|
|
// can be computationally hard and block due to password hashing
|
|
signupUser, err = params.ToUserModel(false /* <- isSSOUser */)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
err = db.Transaction(func(tx *storage.Connection) error {
|
|
var terr error
|
|
if user != nil {
|
|
if (params.Provider == "email" && user.IsConfirmed()) || (params.Provider == "phone" && user.IsPhoneConfirmed()) {
|
|
return UserExistsError
|
|
}
|
|
// do not update the user because we can't be sure of their claimed identity
|
|
} else {
|
|
user, terr = a.signupNewUser(tx, signupUser)
|
|
if terr != nil {
|
|
return terr
|
|
}
|
|
}
|
|
identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), params.Provider)
|
|
if terr != nil {
|
|
if !models.IsNotFoundError(terr) {
|
|
return terr
|
|
}
|
|
identityData := structs.Map(provider.Claims{
|
|
Subject: user.ID.String(),
|
|
Email: user.GetEmail(),
|
|
})
|
|
for k, v := range params.Data {
|
|
if _, ok := identityData[k]; !ok {
|
|
identityData[k] = v
|
|
}
|
|
}
|
|
identity, terr = a.createNewIdentity(tx, user, params.Provider, identityData)
|
|
if terr != nil {
|
|
return terr
|
|
}
|
|
if terr := user.RemoveUnconfirmedIdentities(tx, identity); terr != nil {
|
|
return terr
|
|
}
|
|
}
|
|
user.Identities = []models.Identity{*identity}
|
|
|
|
if params.Provider == "email" && !user.IsConfirmed() {
|
|
if config.Mailer.Autoconfirm {
|
|
if terr = models.NewAuditLogEntry(r, tx, user, models.UserSignedUpAction, "", map[string]interface{}{
|
|
"provider": params.Provider,
|
|
}); terr != nil {
|
|
return terr
|
|
}
|
|
if terr = user.Confirm(tx); terr != nil {
|
|
return internalServerError("Database error updating user").WithInternalError(terr)
|
|
}
|
|
} else {
|
|
if terr = models.NewAuditLogEntry(r, tx, user, models.UserConfirmationRequestedAction, "", map[string]interface{}{
|
|
"provider": params.Provider,
|
|
}); terr != nil {
|
|
return terr
|
|
}
|
|
if isPKCEFlow(flowType) {
|
|
_, terr := generateFlowState(tx, params.Provider, models.EmailSignup, params.CodeChallengeMethod, params.CodeChallenge, &user.ID)
|
|
if terr != nil {
|
|
return terr
|
|
}
|
|
}
|
|
if terr = a.sendConfirmation(r, tx, user, flowType); terr != nil {
|
|
return terr
|
|
}
|
|
}
|
|
} else if params.Provider == "phone" && !user.IsPhoneConfirmed() {
|
|
if config.Sms.Autoconfirm {
|
|
if terr = models.NewAuditLogEntry(r, tx, user, models.UserSignedUpAction, "", map[string]interface{}{
|
|
"provider": params.Provider,
|
|
"channel": params.Channel,
|
|
}); terr != nil {
|
|
return terr
|
|
}
|
|
if terr = user.ConfirmPhone(tx); terr != nil {
|
|
return internalServerError("Database error updating user").WithInternalError(terr)
|
|
}
|
|
} else {
|
|
if terr = models.NewAuditLogEntry(r, tx, user, models.UserConfirmationRequestedAction, "", map[string]interface{}{
|
|
"provider": params.Provider,
|
|
}); terr != nil {
|
|
return terr
|
|
}
|
|
if _, terr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneConfirmationOtp, params.Channel); terr != nil {
|
|
return terr
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
if errors.Is(err, UserExistsError) {
|
|
err = db.Transaction(func(tx *storage.Connection) error {
|
|
if terr := models.NewAuditLogEntry(r, tx, user, models.UserRepeatedSignUpAction, "", map[string]interface{}{
|
|
"provider": params.Provider,
|
|
}); terr != nil {
|
|
return terr
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if config.Mailer.Autoconfirm || config.Sms.Autoconfirm {
|
|
return unprocessableEntityError(ErrorCodeUserAlreadyExists, "User already registered")
|
|
}
|
|
sanitizedUser, err := sanitizeUser(user, params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return sendJSON(w, http.StatusOK, sanitizedUser)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// handles case where Mailer.Autoconfirm is true or Phone.Autoconfirm is true
|
|
if user.IsConfirmed() || user.IsPhoneConfirmed() {
|
|
var token *AccessTokenResponse
|
|
err = db.Transaction(func(tx *storage.Connection) error {
|
|
var terr error
|
|
if terr = models.NewAuditLogEntry(r, tx, user, models.LoginAction, "", map[string]interface{}{
|
|
"provider": params.Provider,
|
|
}); terr != nil {
|
|
return terr
|
|
}
|
|
token, terr = a.issueRefreshToken(r, tx, user, models.PasswordGrant, grantParams)
|
|
|
|
if terr != nil {
|
|
return terr
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
metering.RecordLogin("password", user.ID)
|
|
return sendJSON(w, http.StatusOK, token)
|
|
}
|
|
if user.HasBeenInvited() {
|
|
// Remove sensitive fields
|
|
user.UserMetaData = map[string]interface{}{}
|
|
user.Identities = []models.Identity{}
|
|
}
|
|
return sendJSON(w, http.StatusOK, user)
|
|
}
|
|
|
|
// sanitizeUser removes all user sensitive information from the user object
|
|
// Should be used whenever we want to prevent information about whether a user is registered or not from leaking
|
|
func sanitizeUser(u *models.User, params *SignupParams) (*models.User, error) {
|
|
now := time.Now()
|
|
|
|
u.ID = uuid.Must(uuid.NewV4())
|
|
|
|
u.Role, u.EmailChange = "", ""
|
|
u.CreatedAt, u.UpdatedAt, u.ConfirmationSentAt = now, now, &now
|
|
u.LastSignInAt, u.ConfirmedAt, u.EmailChangeSentAt, u.EmailConfirmedAt, u.PhoneConfirmedAt = nil, nil, nil, nil, nil
|
|
u.Identities = make([]models.Identity, 0)
|
|
u.UserMetaData = params.Data
|
|
u.Aud = params.Aud
|
|
|
|
// sanitize app_metadata
|
|
u.AppMetaData = map[string]interface{}{
|
|
"provider": params.Provider,
|
|
"providers": []string{params.Provider},
|
|
}
|
|
|
|
// sanitize param fields
|
|
switch params.Provider {
|
|
case "email":
|
|
u.Phone = ""
|
|
case "phone":
|
|
u.Email = ""
|
|
default:
|
|
u.Phone, u.Email = "", ""
|
|
}
|
|
|
|
return u, nil
|
|
}
|
|
|
|
func (a *API) signupNewUser(conn *storage.Connection, user *models.User) (*models.User, error) {
|
|
config := a.config
|
|
|
|
err := conn.Transaction(func(tx *storage.Connection) error {
|
|
var terr error
|
|
if terr = tx.Create(user); terr != nil {
|
|
return internalServerError("Database error saving new user").WithInternalError(terr)
|
|
}
|
|
if terr = user.SetRole(tx, config.JWT.DefaultGroupName); terr != nil {
|
|
return internalServerError("Database error updating user").WithInternalError(terr)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// there may be triggers or generated column values in the database that will modify the
|
|
// user data as it is being inserted. thus we load the user object
|
|
// again to fetch those changes.
|
|
if err := conn.Reload(user); err != nil {
|
|
return nil, internalServerError("Database error loading user after sign-up").WithInternalError(err)
|
|
}
|
|
|
|
return user, nil
|
|
}
|