Skip to content

Commit

Permalink
export users route (#367)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ramonsnir authored Dec 18, 2024
1 parent 69e4d99 commit df6436d
Show file tree
Hide file tree
Showing 5 changed files with 108 additions and 3 deletions.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,26 @@ GOTRUE_API_HOST=localhost
PORT=9999
```

`API_HOST` - `string`
`GOTRUE_API_HOST` - `string`

Hostname to listen on.

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

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.

`REQUEST_ID_HEADER` - `string`

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
Expand Down
38 changes: 37 additions & 1 deletion api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
Expand Down
3 changes: 3 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit df6436d

Please sign in to comment.