Skip to content

Commit df6436d

Browse files
authored
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.
1 parent 69e4d99 commit df6436d

File tree

5 files changed

+108
-3
lines changed

5 files changed

+108
-3
lines changed

README.md

+6-2
Original file line numberDiff line numberDiff line change
@@ -43,22 +43,26 @@ GOTRUE_API_HOST=localhost
4343
PORT=9999
4444
```
4545

46-
`API_HOST` - `string`
46+
`GOTRUE_API_HOST` - `string`
4747

4848
Hostname to listen on.
4949

5050
`PORT` (no prefix) / `API_PORT` - `number`
5151

5252
Port number to listen on. Defaults to `8081`.
5353

54-
`API_ENDPOINT` - `string` _Multi-instance mode only_
54+
`GOTRUE_API_ENDPOINT` - `string` _Multi-instance mode only_
5555

5656
Controls what endpoint Netlify can access this API on.
5757

5858
`REQUEST_ID_HEADER` - `string`
5959

6060
If you wish to inherit a request ID from the incoming request, specify the name in this value.
6161

62+
`GOTRUE_API_EXPORT_SECRET` - `string`
63+
64+
A secret that, if set, will allow exporting users for a migration to a different service.
65+
6266
### Database
6367

6468
```properties

api/admin.go

+37-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import (
66
"net/http"
77

88
"github.com/go-chi/chi"
9+
"github.com/gobuffalo/uuid"
910
"github.com/netlify/gotrue/models"
1011
"github.com/netlify/gotrue/storage"
11-
"github.com/gobuffalo/uuid"
1212
)
1313

1414
type adminUserParams struct {
@@ -80,6 +80,42 @@ func (a *API) adminUsers(w http.ResponseWriter, r *http.Request) error {
8080
})
8181
}
8282

83+
// adminUsers responds with a list of all users in a given audience
84+
func (a *API) adminExportUsers(exportSecret string) func(w http.ResponseWriter, r *http.Request) error {
85+
return func(w http.ResponseWriter, r *http.Request) error {
86+
if r.Header.Get("EXPORT_SECRET") != exportSecret {
87+
return unauthorizedError("Invalid export secret")
88+
}
89+
90+
ctx := r.Context()
91+
instanceID := getInstanceID(ctx)
92+
aud := a.requestAud(ctx, r)
93+
94+
pageParams, err := paginate(r)
95+
if err != nil {
96+
return badRequestError("Bad Pagination Parameters: %v", err)
97+
}
98+
99+
sortParams, err := sort(r, map[string]bool{models.CreatedAt: true}, []models.SortField{models.SortField{Name: models.CreatedAt, Dir: models.Descending}})
100+
if err != nil {
101+
return badRequestError("Bad Sort Parameters: %v", err)
102+
}
103+
104+
filter := r.URL.Query().Get("filter")
105+
106+
users, err := models.FindUsersForExportInAudience(a.db, instanceID, aud, pageParams, sortParams, filter)
107+
if err != nil {
108+
return internalServerError("Database error finding users").WithInternalError(err)
109+
}
110+
addPaginationHeaders(w, r, pageParams)
111+
112+
return sendJSON(w, http.StatusOK, map[string]interface{}{
113+
"users": users,
114+
"aud": aud,
115+
})
116+
}
117+
}
118+
83119
// adminUserGet returns information about a single user
84120
func (a *API) adminUserGet(w http.ResponseWriter, r *http.Request) error {
85121
user := getUser(r.Context())

api/api.go

+3
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
142142

143143
r.Route("/users", func(r *router) {
144144
r.Get("/", api.adminUsers)
145+
if globalConfig.API.ExportSecret != "" {
146+
r.Get("/export", api.adminExportUsers(globalConfig.API.ExportSecret))
147+
}
145148
r.With(api.requireEmailProvider).Post("/", api.adminUserCreate)
146149

147150
r.Route("/{user_id}", func(r *router) {

conf/configuration.go

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ type GlobalConfiguration struct {
5757
Port int `envconfig:"PORT" default:"8081"`
5858
Endpoint string
5959
RequestIDHeader string `envconfig:"REQUEST_ID_HEADER"`
60+
ExportSecret string `split_words:"true"`
6061
}
6162
DB DBConfiguration
6263
External ProviderConfiguration

models/user.go

+61
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,39 @@ type User struct {
5050
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
5151
}
5252

53+
// User respresents a registered user with email/password authentication
54+
type UserForExport struct {
55+
InstanceID uuid.UUID `json:"-" db:"instance_id"`
56+
ID uuid.UUID `json:"id" db:"id"`
57+
58+
Aud string `json:"aud" db:"aud"`
59+
Role string `json:"role" db:"role"`
60+
Email string `json:"email" db:"email"`
61+
EncryptedPassword string `json:"encrypted_password" db:"encrypted_password"` // Exposing the encrypted password for an export.
62+
ConfirmedAt *time.Time `json:"confirmed_at,omitempty" db:"confirmed_at"`
63+
InvitedAt *time.Time `json:"invited_at,omitempty" db:"invited_at"`
64+
65+
ConfirmationToken string `json:"-" db:"confirmation_token"`
66+
ConfirmationSentAt *time.Time `json:"confirmation_sent_at,omitempty" db:"confirmation_sent_at"`
67+
68+
RecoveryToken string `json:"-" db:"recovery_token"`
69+
RecoverySentAt *time.Time `json:"recovery_sent_at,omitempty" db:"recovery_sent_at"`
70+
71+
EmailChangeToken string `json:"-" db:"email_change_token"`
72+
EmailChange string `json:"new_email,omitempty" db:"email_change"`
73+
EmailChangeSentAt *time.Time `json:"email_change_sent_at,omitempty" db:"email_change_sent_at"`
74+
75+
LastSignInAt *time.Time `json:"last_sign_in_at,omitempty" db:"last_sign_in_at"`
76+
77+
AppMetaData JSONMap `json:"app_metadata" db:"raw_app_meta_data"`
78+
UserMetaData JSONMap `json:"user_metadata" db:"raw_user_meta_data"`
79+
80+
IsSuperAdmin bool `json:"-" db:"is_super_admin"`
81+
82+
CreatedAt time.Time `json:"created_at" db:"created_at"`
83+
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
84+
}
85+
5386
// NewUser initializes a new user from an email, password and user data.
5487
func NewUser(instanceID uuid.UUID, email, password, aud string, userData map[string]interface{}) (*User, error) {
5588
id, err := uuid.NewV4()
@@ -320,6 +353,34 @@ func FindUsersInAudience(tx *storage.Connection, instanceID uuid.UUID, aud strin
320353
return users, err
321354
}
322355

356+
// FindUsersInAudience finds users with the matching audience.
357+
func FindUsersForExportInAudience(tx *storage.Connection, instanceID uuid.UUID, aud string, pageParams *Pagination, sortParams *SortParams, filter string) ([]*UserForExport, error) {
358+
users := []*UserForExport{}
359+
q := tx.Q().Where("instance_id = ? and aud = ?", instanceID, aud)
360+
361+
if filter != "" {
362+
lf := "%" + filter + "%"
363+
// we must specify the collation in order to get case insensitive search for the JSON column
364+
q = q.Where("(email LIKE ? OR raw_user_meta_data->>'$.full_name' COLLATE utf8mb4_unicode_ci LIKE ?)", lf, lf)
365+
}
366+
367+
if sortParams != nil && len(sortParams.Fields) > 0 {
368+
for _, field := range sortParams.Fields {
369+
q = q.Order(field.Name + " " + string(field.Dir))
370+
}
371+
}
372+
373+
var err error
374+
if pageParams != nil {
375+
err = q.Paginate(int(pageParams.Page), int(pageParams.PerPage)).All(&users)
376+
pageParams.Count = uint64(q.Paginator.TotalEntriesSize)
377+
} else {
378+
err = q.All(&users)
379+
}
380+
381+
return users, err
382+
}
383+
323384
// IsDuplicatedEmail returns whether a user exists with a matching email and audience.
324385
func IsDuplicatedEmail(tx *storage.Connection, instanceID uuid.UUID, email, aud string) (bool, error) {
325386
_, err := FindUserByEmailAndAudience(tx, instanceID, email, aud)

0 commit comments

Comments
 (0)