From df6436d8537c839a4b061caae21a4ee1e19574c1 Mon Sep 17 00:00:00 2001 From: Ramon Snir Date: Wed, 18 Dec 2024 13:56:18 -0500 Subject: [PATCH] export users route (#367) Allow exporting users with hashed passwords for a migration to a different authn provider. It will not be exposed in existing installations, unless a new environment variable is supplied. --- README.md | 8 ++++-- api/admin.go | 38 ++++++++++++++++++++++++++- api/api.go | 3 +++ conf/configuration.go | 1 + models/user.go | 61 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 127231ee6..6417e7fb0 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ GOTRUE_API_HOST=localhost PORT=9999 ``` -`API_HOST` - `string` +`GOTRUE_API_HOST` - `string` Hostname to listen on. @@ -51,7 +51,7 @@ Hostname to listen on. Port number to listen on. Defaults to `8081`. -`API_ENDPOINT` - `string` _Multi-instance mode only_ +`GOTRUE_API_ENDPOINT` - `string` _Multi-instance mode only_ Controls what endpoint Netlify can access this API on. @@ -59,6 +59,10 @@ Controls what endpoint Netlify can access this API on. If you wish to inherit a request ID from the incoming request, specify the name in this value. +`GOTRUE_API_EXPORT_SECRET` - `string` + +A secret that, if set, will allow exporting users for a migration to a different service. + ### Database ```properties diff --git a/api/admin.go b/api/admin.go index e8d3c400d..f8a50387e 100644 --- a/api/admin.go +++ b/api/admin.go @@ -6,9 +6,9 @@ import ( "net/http" "github.com/go-chi/chi" + "github.com/gobuffalo/uuid" "github.com/netlify/gotrue/models" "github.com/netlify/gotrue/storage" - "github.com/gobuffalo/uuid" ) type adminUserParams struct { @@ -80,6 +80,42 @@ func (a *API) adminUsers(w http.ResponseWriter, r *http.Request) error { }) } +// adminUsers responds with a list of all users in a given audience +func (a *API) adminExportUsers(exportSecret string) func(w http.ResponseWriter, r *http.Request) error { + return func(w http.ResponseWriter, r *http.Request) error { + if r.Header.Get("EXPORT_SECRET") != exportSecret { + return unauthorizedError("Invalid export secret") + } + + ctx := r.Context() + instanceID := getInstanceID(ctx) + aud := a.requestAud(ctx, r) + + pageParams, err := paginate(r) + if err != nil { + return badRequestError("Bad Pagination Parameters: %v", err) + } + + sortParams, err := sort(r, map[string]bool{models.CreatedAt: true}, []models.SortField{models.SortField{Name: models.CreatedAt, Dir: models.Descending}}) + if err != nil { + return badRequestError("Bad Sort Parameters: %v", err) + } + + filter := r.URL.Query().Get("filter") + + users, err := models.FindUsersForExportInAudience(a.db, instanceID, aud, pageParams, sortParams, filter) + if err != nil { + return internalServerError("Database error finding users").WithInternalError(err) + } + addPaginationHeaders(w, r, pageParams) + + return sendJSON(w, http.StatusOK, map[string]interface{}{ + "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()) diff --git a/api/api.go b/api/api.go index 50bef8265..8428e0688 100644 --- a/api/api.go +++ b/api/api.go @@ -142,6 +142,9 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati r.Route("/users", func(r *router) { r.Get("/", api.adminUsers) + if globalConfig.API.ExportSecret != "" { + r.Get("/export", api.adminExportUsers(globalConfig.API.ExportSecret)) + } r.With(api.requireEmailProvider).Post("/", api.adminUserCreate) r.Route("/{user_id}", func(r *router) { diff --git a/conf/configuration.go b/conf/configuration.go index ac0b5fb9d..9c3c937e3 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -57,6 +57,7 @@ type GlobalConfiguration struct { Port int `envconfig:"PORT" default:"8081"` Endpoint string RequestIDHeader string `envconfig:"REQUEST_ID_HEADER"` + ExportSecret string `split_words:"true"` } DB DBConfiguration External ProviderConfiguration diff --git a/models/user.go b/models/user.go index 1fcd10734..2f1acd0e2 100644 --- a/models/user.go +++ b/models/user.go @@ -50,6 +50,39 @@ type User struct { UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } +// User respresents a registered user with email/password authentication +type UserForExport struct { + InstanceID uuid.UUID `json:"-" db:"instance_id"` + ID uuid.UUID `json:"id" db:"id"` + + Aud string `json:"aud" db:"aud"` + Role string `json:"role" db:"role"` + Email string `json:"email" db:"email"` + EncryptedPassword string `json:"encrypted_password" db:"encrypted_password"` // Exposing the encrypted password for an export. + ConfirmedAt *time.Time `json:"confirmed_at,omitempty" db:"confirmed_at"` + InvitedAt *time.Time `json:"invited_at,omitempty" db:"invited_at"` + + ConfirmationToken string `json:"-" db:"confirmation_token"` + ConfirmationSentAt *time.Time `json:"confirmation_sent_at,omitempty" db:"confirmation_sent_at"` + + RecoveryToken string `json:"-" db:"recovery_token"` + RecoverySentAt *time.Time `json:"recovery_sent_at,omitempty" db:"recovery_sent_at"` + + EmailChangeToken string `json:"-" db:"email_change_token"` + EmailChange string `json:"new_email,omitempty" db:"email_change"` + EmailChangeSentAt *time.Time `json:"email_change_sent_at,omitempty" db:"email_change_sent_at"` + + LastSignInAt *time.Time `json:"last_sign_in_at,omitempty" db:"last_sign_in_at"` + + AppMetaData JSONMap `json:"app_metadata" db:"raw_app_meta_data"` + UserMetaData JSONMap `json:"user_metadata" db:"raw_user_meta_data"` + + IsSuperAdmin bool `json:"-" db:"is_super_admin"` + + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + // NewUser initializes a new user from an email, password and user data. func NewUser(instanceID uuid.UUID, email, password, aud string, userData map[string]interface{}) (*User, error) { id, err := uuid.NewV4() @@ -320,6 +353,34 @@ func FindUsersInAudience(tx *storage.Connection, instanceID uuid.UUID, aud strin return users, err } +// FindUsersInAudience finds users with the matching audience. +func FindUsersForExportInAudience(tx *storage.Connection, instanceID uuid.UUID, aud string, pageParams *Pagination, sortParams *SortParams, filter string) ([]*UserForExport, error) { + users := []*UserForExport{} + q := tx.Q().Where("instance_id = ? and aud = ?", instanceID, aud) + + if filter != "" { + lf := "%" + filter + "%" + // we must specify the collation in order to get case insensitive search for the JSON column + q = q.Where("(email LIKE ? OR raw_user_meta_data->>'$.full_name' COLLATE utf8mb4_unicode_ci LIKE ?)", lf, lf) + } + + if sortParams != nil && len(sortParams.Fields) > 0 { + for _, field := range sortParams.Fields { + q = q.Order(field.Name + " " + string(field.Dir)) + } + } + + var err error + if pageParams != nil { + err = q.Paginate(int(pageParams.Page), int(pageParams.PerPage)).All(&users) + pageParams.Count = uint64(q.Paginator.TotalEntriesSize) + } else { + err = q.All(&users) + } + + return users, err +} + // IsDuplicatedEmail returns whether a user exists with a matching email and audience. func IsDuplicatedEmail(tx *storage.Connection, instanceID uuid.UUID, email, aud string) (bool, error) { _, err := FindUserByEmailAndAudience(tx, instanceID, email, aud)