package api import ( "context" "net/http" "time" "github.com/fatih/structs" "github.com/go-chi/chi/v5" "github.com/gofrs/uuid" "github.com/pkg/errors" "github.com/sethvargo/go-password/password" "github.com/supabase/auth/internal/api/provider" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/observability" "github.com/supabase/auth/internal/storage" "github.com/supabase/auth/internal/utilities" "golang.org/x/crypto/bcrypt" ) type AdminUserParams struct { Id string `json:"id"` Aud string `json:"aud"` Role string `json:"role"` Email string `json:"email"` Phone string `json:"phone"` Password *string `json:"password"` PasswordHash string `json:"password_hash"` EmailConfirm bool `json:"email_confirm"` PhoneConfirm bool `json:"phone_confirm"` UserMetaData map[string]interface{} `json:"user_metadata"` AppMetaData map[string]interface{} `json:"app_metadata"` BanDuration string `json:"ban_duration"` } type adminUserDeleteParams struct { ShouldSoftDelete bool `json:"should_soft_delete"` } type adminUserUpdateFactorParams struct { FriendlyName string `json:"friendly_name"` Phone string `json:"phone"` } type AdminListUsersResponse struct { Users []*models.User `json:"users"` Aud string `json:"aud"` } func (a *API) loadUser(w http.ResponseWriter, r *http.Request) (context.Context, error) { ctx := r.Context() db := a.db.WithContext(ctx) userID, err := uuid.FromString(chi.URLParam(r, "user_id")) if err != nil { return nil, notFoundError(ErrorCodeValidationFailed, "user_id must be an UUID") } observability.LogEntrySetField(r, "user_id", userID) u, err := models.FindUserByID(db, userID) if err != nil { if models.IsNotFoundError(err) { return nil, notFoundError(ErrorCodeUserNotFound, "User not found") } return nil, internalServerError("Database error loading user").WithInternalError(err) } return withUser(ctx, u), nil } // Use only after requireAuthentication, so that there is a valid user func (a *API) loadFactor(w http.ResponseWriter, r *http.Request) (context.Context, error) { ctx := r.Context() db := a.db.WithContext(ctx) user := getUser(ctx) factorID, err := uuid.FromString(chi.URLParam(r, "factor_id")) if err != nil { return nil, notFoundError(ErrorCodeValidationFailed, "factor_id must be an UUID") } observability.LogEntrySetField(r, "factor_id", factorID) factor, err := user.FindOwnedFactorByID(db, factorID) if err != nil { if models.IsNotFoundError(err) { return nil, notFoundError(ErrorCodeMFAFactorNotFound, "Factor not found") } return nil, internalServerError("Database error loading factor").WithInternalError(err) } return withFactor(ctx, factor), nil } func (a *API) getAdminParams(r *http.Request) (*AdminUserParams, error) { params := &AdminUserParams{} if err := retrieveRequestParams(r, params); err != nil { return nil, err } return params, nil } // adminUsers responds with a list of all users in a given audience func (a *API) adminUsers(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) aud := a.requestAud(ctx, r) pageParams, err := paginate(r) if err != nil { return badRequestError(ErrorCodeValidationFailed, "Bad Pagination Parameters: %v", err).WithInternalError(err) } sortParams, err := sort(r, map[string]bool{models.CreatedAt: true}, []models.SortField{{Name: models.CreatedAt, Dir: models.Descending}}) if err != nil { return badRequestError(ErrorCodeValidationFailed, "Bad Sort Parameters: %v", err) } filter := r.URL.Query().Get("filter") users, err := models.FindUsersInAudience(db, aud, pageParams, sortParams, filter) if err != nil { return internalServerError("Database error finding users").WithInternalError(err) } addPaginationHeaders(w, r, pageParams) return sendJSON(w, http.StatusOK, AdminListUsersResponse{ Users: users, Aud: aud, }) } // adminUserGet returns information about a single user func (a *API) adminUserGet(w http.ResponseWriter, r *http.Request) error { user := getUser(r.Context()) return sendJSON(w, http.StatusOK, user) } // adminUserUpdate updates a single user object func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) config := a.config user := getUser(ctx) adminUser := getAdminUser(ctx) params, err := a.getAdminParams(r) if err != nil { return err } if params.Email != "" { params.Email, err = a.validateEmail(params.Email) if err != nil { return err } } if params.Phone != "" { params.Phone, err = validatePhone(params.Phone) if err != nil { return err } } var banDuration *time.Duration if params.BanDuration != "" { duration := time.Duration(0) if params.BanDuration != "none" { duration, err = time.ParseDuration(params.BanDuration) if err != nil { return badRequestError(ErrorCodeValidationFailed, "invalid format for ban duration: %v", err) } } banDuration = &duration } if params.Password != nil { password := *params.Password if err := a.checkPasswordStrength(ctx, password); err != nil { return err } if err := user.SetPassword(ctx, password, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { return err } } err = db.Transaction(func(tx *storage.Connection) error { if params.Role != "" { if terr := user.SetRole(tx, params.Role); terr != nil { return terr } } if params.EmailConfirm { if terr := user.Confirm(tx); terr != nil { return terr } } if params.PhoneConfirm { if terr := user.ConfirmPhone(tx); terr != nil { return terr } } if params.Password != nil { if terr := user.UpdatePassword(tx, nil); terr != nil { return terr } } var identities []models.Identity if params.Email != "" { if identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "email"); terr != nil && !models.IsNotFoundError(terr) { return terr } else if identity == nil { // if the user doesn't have an existing email // then updating the user's email should create a new email identity i, terr := a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{ Subject: user.ID.String(), Email: params.Email, EmailVerified: params.EmailConfirm, })) if terr != nil { return terr } identities = append(identities, *i) } else { // update the existing email identity if terr := identity.UpdateIdentityData(tx, map[string]interface{}{ "email": params.Email, "email_verified": params.EmailConfirm, }); terr != nil { return terr } } if user.IsAnonymous && params.EmailConfirm { user.IsAnonymous = false if terr := tx.UpdateOnly(user, "is_anonymous"); terr != nil { return terr } } if terr := user.SetEmail(tx, params.Email); terr != nil { return terr } } if params.Phone != "" { if identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "phone"); terr != nil && !models.IsNotFoundError(terr) { return terr } else if identity == nil { // if the user doesn't have an existing phone // then updating the user's phone should create a new phone identity identity, terr := a.createNewIdentity(tx, user, "phone", structs.Map(provider.Claims{ Subject: user.ID.String(), Phone: params.Phone, PhoneVerified: params.PhoneConfirm, })) if terr != nil { return terr } identities = append(identities, *identity) } else { // update the existing phone identity if terr := identity.UpdateIdentityData(tx, map[string]interface{}{ "phone": params.Phone, "phone_verified": params.PhoneConfirm, }); terr != nil { return terr } } if user.IsAnonymous && params.PhoneConfirm { user.IsAnonymous = false if terr := tx.UpdateOnly(user, "is_anonymous"); terr != nil { return terr } } if terr := user.SetPhone(tx, params.Phone); terr != nil { return terr } } user.Identities = append(user.Identities, identities...) if params.AppMetaData != nil { if terr := user.UpdateAppMetaData(tx, params.AppMetaData); terr != nil { return terr } } if params.UserMetaData != nil { if terr := user.UpdateUserMetaData(tx, params.UserMetaData); terr != nil { return terr } } if banDuration != nil { if terr := user.Ban(tx, *banDuration); terr != nil { return terr } } if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UserModifiedAction, "", map[string]interface{}{ "user_id": user.ID, "user_email": user.Email, "user_phone": user.Phone, }); terr != nil { return terr } return nil }) if err != nil { return internalServerError("Error updating user").WithInternalError(err) } return sendJSON(w, http.StatusOK, user) } // adminUserCreate creates a new user based on the provided data func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) config := a.config adminUser := getAdminUser(ctx) params, err := a.getAdminParams(r) if err != nil { return err } aud := a.requestAud(ctx, r) if params.Aud != "" { aud = params.Aud } if params.Email == "" && params.Phone == "" { return badRequestError(ErrorCodeValidationFailed, "Cannot create a user without either an email or phone") } var providers []string if params.Email != "" { params.Email, err = a.validateEmail(params.Email) if err != nil { return err } if user, err := models.IsDuplicatedEmail(db, params.Email, aud, nil); err != nil { return internalServerError("Database error checking email").WithInternalError(err) } else if user != nil { return unprocessableEntityError(ErrorCodeEmailExists, DuplicateEmailMsg) } providers = append(providers, "email") } if params.Phone != "" { params.Phone, err = validatePhone(params.Phone) if err != nil { return err } if exists, err := models.IsDuplicatedPhone(db, params.Phone, aud); err != nil { return internalServerError("Database error checking phone").WithInternalError(err) } else if exists { return unprocessableEntityError(ErrorCodePhoneExists, "Phone number already registered by another user") } providers = append(providers, "phone") } if params.Password != nil && params.PasswordHash != "" { return badRequestError(ErrorCodeValidationFailed, "Only a password or a password hash should be provided") } if (params.Password == nil || *params.Password == "") && params.PasswordHash == "" { password, err := password.Generate(64, 10, 0, false, true) if err != nil { return internalServerError("Error generating password").WithInternalError(err) } params.Password = &password } var user *models.User if params.PasswordHash != "" { user, err = models.NewUserWithPasswordHash(params.Phone, params.Email, params.PasswordHash, aud, params.UserMetaData) } else { user, err = models.NewUser(params.Phone, params.Email, *params.Password, aud, params.UserMetaData) } if err != nil { if errors.Is(err, bcrypt.ErrPasswordTooLong) { return badRequestError(ErrorCodeValidationFailed, err.Error()) } return internalServerError("Error creating user").WithInternalError(err) } if params.Id != "" { customId, err := uuid.FromString(params.Id) if err != nil { return badRequestError(ErrorCodeValidationFailed, "ID must conform to the uuid v4 format") } if customId == uuid.Nil { return badRequestError(ErrorCodeValidationFailed, "ID cannot be a nil uuid") } user.ID = customId } user.AppMetaData = map[string]interface{}{ // TODO: Deprecate "provider" field // default to the first provider in the providers slice "provider": providers[0], "providers": providers, } var banDuration *time.Duration if params.BanDuration != "" { duration := time.Duration(0) if params.BanDuration != "none" { duration, err = time.ParseDuration(params.BanDuration) if err != nil { return badRequestError(ErrorCodeValidationFailed, "invalid format for ban duration: %v", err) } } banDuration = &duration } err = db.Transaction(func(tx *storage.Connection) error { if terr := tx.Create(user); terr != nil { return terr } var identities []models.Identity if user.GetEmail() != "" { identity, terr := a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{ Subject: user.ID.String(), Email: user.GetEmail(), })) if terr != nil { return terr } identities = append(identities, *identity) } if user.GetPhone() != "" { identity, terr := a.createNewIdentity(tx, user, "phone", structs.Map(provider.Claims{ Subject: user.ID.String(), Phone: user.GetPhone(), })) if terr != nil { return terr } identities = append(identities, *identity) } user.Identities = identities if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UserSignedUpAction, "", map[string]interface{}{ "user_id": user.ID, "user_email": user.Email, "user_phone": user.Phone, }); terr != nil { return terr } role := config.JWT.DefaultGroupName if params.Role != "" { role = params.Role } if terr := user.SetRole(tx, role); terr != nil { return terr } if params.AppMetaData != nil { if terr := user.UpdateAppMetaData(tx, params.AppMetaData); terr != nil { return terr } } if params.EmailConfirm { if terr := user.Confirm(tx); terr != nil { return terr } } if params.PhoneConfirm { if terr := user.ConfirmPhone(tx); terr != nil { return terr } } if banDuration != nil { if terr := user.Ban(tx, *banDuration); terr != nil { return terr } } return nil }) if err != nil { return internalServerError("Database error creating new user").WithInternalError(err) } return sendJSON(w, http.StatusOK, user) } // adminUserDelete deletes a user func (a *API) adminUserDelete(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() user := getUser(ctx) adminUser := getAdminUser(ctx) // ShouldSoftDelete defaults to false params := &adminUserDeleteParams{} if body, _ := utilities.GetBodyBytes(r); len(body) != 0 { // we only want to parse the body if it's not empty // retrieveRequestParams will handle any errors with stream if err := retrieveRequestParams(r, params); err != nil { return err } } err := a.db.Transaction(func(tx *storage.Connection) error { if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UserDeletedAction, "", map[string]interface{}{ "user_id": user.ID, "user_email": user.Email, "user_phone": user.Phone, }); terr != nil { return internalServerError("Error recording audit log entry").WithInternalError(terr) } if params.ShouldSoftDelete { if user.DeletedAt != nil { // user has been soft deleted already return nil } if terr := user.SoftDeleteUser(tx); terr != nil { return internalServerError("Error soft deleting user").WithInternalError(terr) } if terr := user.SoftDeleteUserIdentities(tx); terr != nil { return internalServerError("Error soft deleting user identities").WithInternalError(terr) } // hard delete all associated factors if terr := models.DeleteFactorsByUserId(tx, user.ID); terr != nil { return internalServerError("Error deleting user's factors").WithInternalError(terr) } // hard delete all associated sessions if terr := models.Logout(tx, user.ID); terr != nil { return internalServerError("Error deleting user's sessions").WithInternalError(terr) } } else { if terr := tx.Destroy(user); terr != nil { return internalServerError("Database error deleting user").WithInternalError(terr) } } return nil }) if err != nil { return err } return sendJSON(w, http.StatusOK, map[string]interface{}{}) } func (a *API) adminUserDeleteFactor(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() user := getUser(ctx) factor := getFactor(ctx) err := a.db.Transaction(func(tx *storage.Connection) error { if terr := models.NewAuditLogEntry(r, tx, user, models.DeleteFactorAction, r.RemoteAddr, map[string]interface{}{ "user_id": user.ID, "factor_id": factor.ID, }); terr != nil { return terr } if terr := tx.Destroy(factor); terr != nil { return internalServerError("Database error deleting factor").WithInternalError(terr) } return nil }) if err != nil { return err } return sendJSON(w, http.StatusOK, factor) } func (a *API) adminUserGetFactors(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() user := getUser(ctx) return sendJSON(w, http.StatusOK, user.Factors) } // adminUserUpdate updates a single factor object func (a *API) adminUserUpdateFactor(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() factor := getFactor(ctx) user := getUser(ctx) adminUser := getAdminUser(ctx) params := &adminUserUpdateFactorParams{} if err := retrieveRequestParams(r, params); err != nil { return err } err := a.db.Transaction(func(tx *storage.Connection) error { if params.FriendlyName != "" { if terr := factor.UpdateFriendlyName(tx, params.FriendlyName); terr != nil { return terr } } if params.Phone != "" && factor.IsPhoneFactor() { phone, err := validatePhone(params.Phone) if err != nil { return badRequestError(ErrorCodeValidationFailed, "Invalid phone number format (E.164 required)") } if terr := factor.UpdatePhone(tx, phone); terr != nil { return terr } } if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UpdateFactorAction, "", map[string]interface{}{ "user_id": user.ID, "factor_id": factor.ID, "factor_type": factor.FactorType, }); terr != nil { return terr } return nil }) if err != nil { return err } return sendJSON(w, http.StatusOK, factor) }