diff --git a/README.md b/README.md index ff0dfb5..b2dce7f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Module `git.golem15.com/golem15/sm-user-plugin`, package `user`, plugin ID `gole The plugin is a compiled SummerCMS plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`. -It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `golem15_user_organisations`, `user_groups` and `users_groups`, and the JSON API under `/_user/api/v1`. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs and config keys are a contract: change them only in a backwards-compatible way. +It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_api_tokens`, `golem15_user_organisations`, `user_groups` and `users_groups`, and the JSON API under `/_user/api/v1`. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs and config keys are a contract: change them only in a backwards-compatible way. ## Features @@ -25,6 +25,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `golem15 - **Profile.** `POST /update`, `POST /marketing-consent`, `POST /avatar` and `POST /avatar/remove` (the avatar is stored through the framework's attachment storage). - **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins. - **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No route of this plugin reads or writes groups, and the user payload still answers `"groups":[]`. +- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:` limits delegated access and `user.permission:` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created. - **Mail templates.** Activation, reset and reactivation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). - **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never). `GetApiArrayEvent` lets other plugins merge keys into the user payload. - **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload. @@ -67,7 +68,7 @@ plugins: The plugin's own `go.mod` replaces the framework with `../../../../summercms.go`, so it expects the framework checkout next to the application checkout (`/summercms.go` and `/acme/plugins/golem15/user`). -Another plugin that needs the signed-in user puts its routes behind the `jwt.auth` middleware and imports `git.golem15.com/golem15/sm-user-plugin/models` or `.../classes` for the user model and helpers. +Another plugin that needs the signed-in user puts its routes behind `jwt.auth`. A machine API uses `user.api_token`, optionally followed by `user.scope:read`, `user.scope:write`, or `user.permission:vendor.plugin.code`. Both guards resolve the same user and current group permissions. Import `git.golem15.com/golem15/sm-user-plugin/models`, `.../classes`, or `.../middleware` for the shared types and helpers. ## API reference @@ -90,6 +91,9 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle | `POST /marketing-consent` | `controllers.MarketingConsent` | | `POST /avatar` | `controllers.UploadAvatar` | | `POST /avatar/remove` | `controllers.RemoveAvatar` | +| `POST /tokens` | `controllers.APITokenStore` (`jwt.auth`) | +| `GET /tokens` | `controllers.APITokenIndex` (`jwt.auth`) | +| `DELETE /tokens/{id}` | `controllers.APITokenDestroy` (`jwt.auth`) | Packages: @@ -98,10 +102,11 @@ Packages: - `FireRegisterEvent(ctx, app, user, fields)` fires `RegisterEvent` with `Payload` set to a copy of `fields` without `password` and `password_confirmation`, and returns the listeners' error (`/register` ignores it). Fire it after the user row is committed. - `IssueToken(app, user, r)` mints the session JWT as `/register` and `/login` do. - `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners. -- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserGroupCodes` and `HasGroupCode`, the activation and reset code helpers, the throttle helpers, mail locale helpers and the two events. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. -- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`. +- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `HashAPIToken` and `RevokeAPIToken`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. +- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`. +- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `APIToken`. - `updates`: the gormigrate migrations, returned by `updates.All()`. -- `console`: `RequirePasswordChange`, the `user:require-password-change` command. +- `console`: `RequirePasswordChange` and `APITokenCommands`. ## Configuration @@ -126,12 +131,16 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any | `golem15.user.throttle.use_throttle` | `true` | Whether login throttling is on. | | `golem15.user.password.bcrypt_cost` | `10` | bcrypt cost for new password hashes. | | `golem15.user.password.min_length` | `8` | Minimum password length. | +| `golem15.user.api_tokens.prefix` | `usr_` | Prefix for newly minted and accepted shared API-token secrets. Override an existing application's legacy prefix without creating a plugin-specific token table. | ## CLI commands | Command | Purpose | |---------|---------| | `user:require-password-change ` | Flag an account so its next session must set a new password. | +| `user:token:create [--name api] [--scopes read,write] [--expires RFC3339]` | Create an application-wide API token and print its secret once. | +| `user:token:list` | List token metadata without secrets or hashes. | +| `user:token:revoke ` | Revoke a shared API token immediately. | ## Dependencies diff --git a/api_tokens_test.go b/api_tokens_test.go new file mode 100644 index 0000000..fe8bc75 --- /dev/null +++ b/api_tokens_test.go @@ -0,0 +1,114 @@ +package user + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/controllers" + usermiddleware "git.golem15.com/golem15/sm-user-plugin/middleware" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/bouncer" +) + +func TestSharedAPITokenAuthenticatesWithCurrentUserPermissions(t *testing.T) { + app, db := sessionApp(t, "0s") + if err := app.Config.Set("golem15.user.api_tokens.prefix", "tst_"); err != nil { + t.Fatal(err) + } + user := insertUser(t, db, "token-shared@example.test", "correct-horse") + var groupID uint + if err := db.Raw(`INSERT INTO user_groups (name, code, permissions, created_at, updated_at) + VALUES ('API operators', 'api-operators', '{"jz.bm.access_materials":1,"jz.bm.access_courses":0}', NOW(), NOW()) RETURNING id`).Scan(&groupID).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)", user.ID, groupID).Error; err != nil { + t.Fatal(err) + } + secret, token, err := classes.MintAPIToken("tst_", user.ID, "shared", []string{"read", "write"}, nil, nil) + if err != nil { + t.Fatal(err) + } + if strings.Contains(token.TokenHash, secret) || !strings.HasPrefix(secret, "tst_") { + t.Fatalf("secret/hash = %q/%q", secret, token.TokenHash) + } + if err := db.Create(token).Error; err != nil { + t.Fatal(err) + } + guard := &usermiddleware.APITokenGuard{App: app, Prefix: "tst_", ClientIP: func(*http.Request) string { return "203.0.113.7" }} + req := httptest.NewRequest(http.MethodGet, "/api/v1/example", nil) + req.Header.Set("Authorization", "Bearer "+secret) + principal, credential, err := guard.AuthenticateCredential(req) + if err != nil { + t.Fatal(err) + } + if principal.ID != user.ID || !principal.PermissionGrants["jz.bm.access_materials"] || principal.PermissionGrants["jz.bm.access_courses"] { + t.Fatalf("principal = %#v", principal) + } + got, ok := credential.(*models.APIToken) + if !ok || got.ID != token.ID || got.LastUsedAt == nil || got.LastUsedIP == nil || *got.LastUsedIP != "203.0.113.7" { + t.Fatalf("credential = %#v", credential) + } + + called := false + final := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }) + ctx := bouncer.WithCredential(bouncer.WithUser(context.Background(), principal), got) + scoped := usermiddleware.Scope("write")(usermiddleware.Permission("jz.bm.access_materials")(final)) + scoped.ServeHTTP(httptest.NewRecorder(), req.WithContext(ctx)) + if !called { + t.Fatal("shared token did not pass its scope and current user permission") + } + denied := httptest.NewRecorder() + usermiddleware.Permission("jz.bm.access_courses")(final).ServeHTTP(denied, req.WithContext(ctx)) + if denied.Code != http.StatusForbidden { + t.Fatalf("permission denial = %d %s", denied.Code, denied.Body.String()) + } +} + +func TestMintAPITokenPreservesScopeOrderWhileDeduplicating(t *testing.T) { + _, token, err := classes.MintAPIToken("usr_", 1, "ordered", []string{"write", "ai", "write", "read"}, nil, nil) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(token.Scopes.Get(), ","); got != "write,ai,read" { + t.Fatalf("scopes = %q, want write,ai,read", got) + } +} + +func TestAPITokenManagementShowsSecretOnlyOnCreate(t *testing.T) { + app, db := sessionApp(t, "0s") + if err := app.Config.Set("golem15.user.api_tokens.prefix", "usr_"); err != nil { + t.Fatal(err) + } + user := insertUser(t, db, "token-manage@example.test", "correct-horse") + principal := &bouncer.Principal{ID: user.ID} + req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/tokens", strings.NewReader(`{"name":"automation","scopes":["read"]}`)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(bouncer.WithUser(req.Context(), principal)) + created := httptest.NewRecorder() + controllers.APITokenStore(app).ServeHTTP(created, req) + if created.Code != http.StatusCreated { + t.Fatalf("create = %d %s", created.Code, created.Body.String()) + } + var body struct { + Token string `json:"token"` + Meta map[string]any `json:"meta"` + } + if err := json.Unmarshal(created.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(body.Token, "usr_") || strings.Contains(created.Body.String(), classes.HashAPIToken(body.Token)) { + t.Fatalf("create body = %s", created.Body.String()) + } + listReq := httptest.NewRequest(http.MethodGet, "/_user/api/v1/tokens", nil) + listReq = listReq.WithContext(bouncer.WithUser(listReq.Context(), principal)) + listed := httptest.NewRecorder() + controllers.APITokenIndex(app).ServeHTTP(listed, listReq) + if listed.Code != http.StatusOK || strings.Contains(listed.Body.String(), body.Token) || strings.Contains(listed.Body.String(), classes.HashAPIToken(body.Token)) { + t.Fatalf("list = %d %s", listed.Code, listed.Body.String()) + } +} diff --git a/classes/api_tokens.go b/classes/api_tokens.go new file mode 100644 index 0000000..a5f3d38 --- /dev/null +++ b/classes/api_tokens.go @@ -0,0 +1,112 @@ +package classes + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "slices" + "strings" + "time" + + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/lagoon" + "gorm.io/gorm" +) + +const DefaultAPITokenPrefix = "usr_" + +var ( + DefaultAPITokenScopes = []string{"read"} + AllowedAPITokenScopes = []string{"read", "write", "ai"} +) + +// APITokenPrefix returns the configured on-wire prefix. Applications may keep +// a legacy prefix while every plugin still consumes the same token table. +func APITokenPrefix(app *backpack.App) (string, error) { + prefix := DefaultAPITokenPrefix + if app != nil && app.Config != nil { + if configured := strings.TrimSpace(app.Config.String("golem15.user.api_tokens.prefix")); configured != "" { + prefix = configured + } + } + if len(prefix) < 2 || len(prefix) > 16 { + return "", fmt.Errorf("golem15.user: api_tokens.prefix must contain 2 to 16 safe characters") + } + for _, r := range prefix { + if !(r == '_' || r == '-' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') { + return "", fmt.Errorf("golem15.user: api_tokens.prefix contains an unsafe character") + } + } + return prefix, nil +} + +// MintAPIToken builds a token record and returns its one-time clear-text +// secret. A nil scopes slice receives DefaultAPITokenScopes. +func MintAPIToken(prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.APIToken, error) { + name = strings.TrimSpace(name) + if userID == 0 || name == "" || len(name) > 255 { + return "", nil, fmt.Errorf("a user and token name of 1 to 255 characters are required") + } + if prefix == "" { + prefix = DefaultAPITokenPrefix + } + if scopes == nil { + scopes = slices.Clone(DefaultAPITokenScopes) + } else { + scopes = slices.Clone(scopes) + } + for _, scope := range scopes { + if !slices.Contains(AllowedAPITokenScopes, scope) { + return "", nil, fmt.Errorf("scope %q is not allowed", scope) + } + } + seenScopes := make(map[string]bool, len(scopes)) + uniqueScopes := make([]string, 0, len(scopes)) + for _, scope := range scopes { + if !seenScopes[scope] { + seenScopes[scope] = true + uniqueScopes = append(uniqueScopes, scope) + } + } + scopes = uniqueScopes + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", nil, err + } + secret := prefix + base64.RawURLEncoding.EncodeToString(raw) + token := &models.APIToken{ + UserID: userID, + Name: &name, + TokenHash: HashAPIToken(secret), + Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true}, + ExpiresAt: expiresAt, + } + if len(collectionIDs) > 0 { + collectionIDs = slices.Compact(slices.Sorted(slices.Values(collectionIDs))) + token.CollectionIDs = lagoon.Jsonable[[]uint]{Data: collectionIDs, Valid: true} + } + return secret, token, nil +} + +// HashAPIToken returns the only representation of a token secret stored in +// the database. +func HashAPIToken(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} + +// RevokeAPIToken invalidates token immediately. +func RevokeAPIToken(db *gorm.DB, token *models.APIToken) error { + if db == nil || token == nil { + return fmt.Errorf("golem15.user: token is missing") + } + now := time.Now() + if err := db.Model(token).Update("revoked_at", now).Error; err != nil { + return err + } + token.RevokedAt = &now + return nil +} diff --git a/classes/user_lookup.go b/classes/user_lookup.go index 144fe51..0dd47ab 100644 --- a/classes/user_lookup.go +++ b/classes/user_lookup.go @@ -2,6 +2,7 @@ package classes import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -27,6 +28,14 @@ func (g GormUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, e if !ok || gdb == nil { return nil, fmt.Errorf("golem15.user: gorm handle is missing") } + return PrincipalByID(ctx, gdb, id) +} + +// PrincipalByID loads one front-end user and their current group permissions. +func PrincipalByID(ctx context.Context, gdb *gorm.DB, id uint) (*bouncer.Principal, error) { + if gdb == nil { + return nil, fmt.Errorf("golem15.user: gorm handle is missing") + } var row models.User err := gdb.WithContext(ctx).Where("id = ?", id).Take(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { @@ -42,9 +51,66 @@ func (g GormUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, e if row.TokensValidAfter != nil { principal.TokensValidAfter = *row.TokensValidAfter } + grants, err := UserPermissionGrants(ctx, gdb, row.ID) + if err != nil { + return nil, err + } + principal.PermissionGrants = grants return principal, nil } +// UserPermissionGrants returns the enabled permissions of all groups to which +// userID belongs. A false/zero value is not a grant; another group may still +// grant the same code. +func UserPermissionGrants(ctx context.Context, db *gorm.DB, userID uint) (map[string]bool, error) { + if db == nil { + return nil, fmt.Errorf("golem15.user: gorm handle is missing") + } + var rows []struct{ Permissions *string } + err := db.WithContext(ctx). + Table("user_groups"). + Select("user_groups.permissions"). + Joins("JOIN users_groups ON users_groups.user_group_id = user_groups.id"). + Where("users_groups.user_id = ? AND user_groups.permissions IS NOT NULL", userID). + Order("user_groups.id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + grants := map[string]bool{} + for _, row := range rows { + if row.Permissions == nil || strings.TrimSpace(*row.Permissions) == "" { + continue + } + var decoded map[string]any + if json.Unmarshal([]byte(*row.Permissions), &decoded) != nil { + continue + } + for code, value := range decoded { + if permissionEnabled(value) { + grants[code] = true + } + } + } + if len(grants) == 0 { + return nil, nil + } + return grants, nil +} + +func permissionEnabled(value any) bool { + switch v := value.(type) { + case bool: + return v + case float64: + return v == 1 + case string: + return v == "1" || strings.EqualFold(v, "true") + default: + return false + } +} + // LookupByEmail returns the user row, including a soft-deleted one. // A missing email is (nil, nil). func LookupByEmail(ctx context.Context, db *gorm.DB, email string) (*models.User, error) { diff --git a/config/config.yaml b/config/config.yaml index 772f07a..0254cd1 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -20,3 +20,5 @@ throttle: password: bcrypt_cost: 10 min_length: 8 +api_tokens: + prefix: usr_ diff --git a/console/api_tokens.go b/console/api_tokens.go new file mode 100644 index 0000000..9bed9bd --- /dev/null +++ b/console/api_tokens.go @@ -0,0 +1,133 @@ +package console + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bonfire" + "gorm.io/gorm" +) + +// APITokenCommands are the application-wide token create/list/revoke +// commands. Domain plugins deliberately do not mint their own credentials. +func APITokenCommands(app *backpack.App) []bonfire.Command { + withDB := func(ctx context.Context, fn func(*gorm.DB) error) error { + if app == nil { + return fmt.Errorf("golem15.user: app is nil") + } + db, ok := app.Lookup[*gorm.DB]() + if !ok || db == nil { + return fmt.Errorf("golem15.user: database is not configured") + } + return fn(db.WithContext(ctx)) + } + return []bonfire.Command{ + { + Name: "user:token:create", Description: "Create one API token usable by application plugins", + Args: []bonfire.Arg{{Name: "email", Description: "E-mail of the user the token acts as", Required: true}}, + Flags: []bonfire.Flag{ + {Name: "name", Description: "What the token is for", Default: "api"}, + {Name: "scopes", Description: "Comma-separated scopes: read, write, ai", Default: "read,write"}, + {Name: "expires", Description: "RFC3339 expiry; empty means no expiry"}, + }, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + email, _ := in.Argument("email") + name, _ := in.Flag("name") + rawScopes, _ := in.Flag("scopes") + scopes := []string{} + for _, scope := range strings.Split(rawScopes, ",") { + if scope = strings.TrimSpace(scope); scope != "" { + scopes = append(scopes, scope) + } + } + var expiresAt *time.Time + if raw, _ := in.Flag("expires"); strings.TrimSpace(raw) != "" { + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("user:token:create: --expires: %w", err) + } + expiresAt = &parsed + } + return withDB(ctx, func(db *gorm.DB) error { + user, err := classes.LookupByEmail(ctx, db, strings.TrimSpace(email)) + if err != nil { + return err + } + if user == nil { + return fmt.Errorf("user:token:create: no user with the e-mail %q", email) + } + prefix, err := classes.APITokenPrefix(app) + if err != nil { + return err + } + secret, token, err := classes.MintAPIToken(prefix, user.ID, name, scopes, expiresAt, nil) + if err != nil { + return fmt.Errorf("user:token:create: %w", err) + } + if err := db.Create(token).Error; err != nil { + return fmt.Errorf("user:token:create: %w", err) + } + out.Printf("token %d for %s, scopes %s\n", token.ID, email, strings.Join(token.Scopes.Get(), ",")) + out.Println(secret) + out.Success("user:token:create: store the secret now; it is not shown again") + return nil + }) + }, + }, + { + Name: "user:token:list", Description: "List API tokens", + Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error { + return withDB(ctx, func(db *gorm.DB) error { + var tokens []models.APIToken + if err := db.Order("id").Find(&tokens).Error; err != nil { + return fmt.Errorf("user:token:list: %w", err) + } + rows := make([][]string, 0, len(tokens)) + for _, token := range tokens { + state := "active" + if token.RevokedAt != nil { + state = "revoked" + } else if !token.IsUsableAt(time.Now()) { + state = "expired" + } + name := "" + if token.Name != nil { + name = *token.Name + } + rows = append(rows, []string{strconv.FormatUint(uint64(token.ID), 10), strconv.FormatUint(uint64(token.UserID), 10), name, strings.Join(token.Scopes.Get(), ","), state}) + } + out.Table([]string{"id", "user", "name", "scopes", "state"}, rows) + return nil + }) + }, + }, + { + Name: "user:token:revoke", Description: "Revoke an API token", + Args: []bonfire.Arg{{Name: "id", Description: "Token id", Required: true}}, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + raw, _ := in.Argument("id") + id, err := strconv.ParseUint(raw, 10, 64) + if err != nil || id == 0 { + return fmt.Errorf("user:token:revoke: %q is not a token id", raw) + } + return withDB(ctx, func(db *gorm.DB) error { + res := db.Model(&models.APIToken{}).Where("id = ? AND revoked_at IS NULL", id).UpdateColumn("revoked_at", time.Now()) + if res.Error != nil { + return fmt.Errorf("user:token:revoke: %w", res.Error) + } + if res.RowsAffected == 0 { + return fmt.Errorf("user:token:revoke: no active token with id %d", id) + } + out.Success(fmt.Sprintf("user:token:revoke: token %d revoked", id)) + return nil + }) + }, + }, + } +} diff --git a/console_test.go b/console_test.go index 8a1370b..d1bbaa4 100644 --- a/console_test.go +++ b/console_test.go @@ -17,12 +17,19 @@ func TestRequirePasswordChange(t *testing.T) { p := &Plugin{app: app} var _ pact.HasCommands = p cmds := p.Commands() - if len(cmds) != 1 || cmds[0].Name != "user:require-password-change" { + var cmd bonfire.Command + for _, candidate := range cmds { + if candidate.Name == "user:require-password-change" { + cmd = candidate + break + } + } + if cmd.Name == "" { t.Fatalf("commands = %#v", cmds) } var buf bytes.Buffer out := bonfire.NewOutput(nil, &buf, &buf) - if err := cmds[0].Run(context.Background(), emailArg{email: "alice@example.com"}, out); err != nil { + if err := cmd.Run(context.Background(), emailArg{email: "alice@example.com"}, out); err != nil { t.Fatal(err) } if !strings.Contains(buf.String(), "alice@example.com") { @@ -35,7 +42,7 @@ func TestRequirePasswordChange(t *testing.T) { if !fresh.MustChangePassword { t.Fatal("must_change_password stayed false") } - if err := cmds[0].Run(context.Background(), emailArg{email: "missing@example.com"}, out); err == nil { + if err := cmd.Run(context.Background(), emailArg{email: "missing@example.com"}, out); err == nil { t.Fatal("unknown email returned nil error") } } diff --git a/controllers/api_tokens.go b/controllers/api_tokens.go new file mode 100644 index 0000000..5f93c5a --- /dev/null +++ b/controllers/api_tokens.go @@ -0,0 +1,161 @@ +package controllers + +import ( + "errors" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/wire" + "gorm.io/gorm" +) + +// APITokenStore mints one shared personal API token for the authenticated +// user. The clear-text token appears only in this response. +func APITokenStore(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + db, user, ok := apiTokenDeps(w, r, app) + if !ok { + return + } + fields, err := readFields(r) + if err != nil { + writeOpaque500(w) + return + } + name := strings.TrimSpace(strField(fields, "name")) + if name == "" || len(name) > 255 { + writeValidation(w, map[string][]string{"name": {"The name field is required and may not be longer than 255 characters."}}) + return + } + scopes, err := apiTokenScopes(fields["scopes"]) + if err != nil { + writeValidation(w, map[string][]string{"scopes": {err.Error()}}) + return + } + var expiresAt *time.Time + if raw := strings.TrimSpace(strField(fields, "expires_at")); raw != "" { + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil { + writeValidation(w, map[string][]string{"expires_at": {"The expires_at field must be a valid RFC3339 date."}}) + return + } + expiresAt = &parsed + } + prefix, err := classes.APITokenPrefix(app) + if err != nil { + writeOpaque500(w) + return + } + secret, token, err := classes.MintAPIToken(prefix, user.ID, name, scopes, expiresAt, nil) + if err != nil || db.WithContext(r.Context()).Create(token).Error != nil { + writeOpaque500(w) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"token": secret, "meta": apiTokenJSON(token)}) + } +} + +// APITokenIndex lists the caller's live personal tokens without hashes. +func APITokenIndex(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + db, user, ok := apiTokenDeps(w, r, app) + if !ok { + return + } + var rows []models.APIToken + if err := db.WithContext(r.Context()). + Where("user_id = ? AND oauth_client_id IS NULL AND revoked_at IS NULL", user.ID). + Order("created_at DESC").Find(&rows).Error; err != nil { + writeOpaque500(w) + return + } + data := make([]map[string]any, 0, len(rows)) + for i := range rows { + data = append(data, apiTokenJSON(&rows[i])) + } + writeJSON(w, http.StatusOK, map[string]any{"data": wire.Slice(data)}) + } +} + +// APITokenDestroy revokes one live personal token owned by the caller. +func APITokenDestroy(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + db, user, ok := apiTokenDeps(w, r, app) + if !ok { + return + } + id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) + if err != nil || id == 0 { + writeJSON(w, http.StatusNotFound, map[string]any{"error": "Token not found"}) + return + } + var token models.APIToken + err = db.WithContext(r.Context()).Where("user_id = ? AND oauth_client_id IS NULL", user.ID).First(&token, id).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + writeJSON(w, http.StatusNotFound, map[string]any{"error": "Token not found"}) + return + } + if err != nil || classes.RevokeAPIToken(db.WithContext(r.Context()), &token) != nil { + writeOpaque500(w) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"revoked": true}}) + } +} + +func apiTokenDeps(w http.ResponseWriter, r *http.Request, app *backpack.App) (*gorm.DB, *bouncer.Principal, bool) { + if app == nil { + writeOpaque500(w) + return nil, nil, false + } + db, ok := app.Lookup[*gorm.DB]() + if !ok || db == nil { + writeOpaque500(w) + return nil, nil, false + } + user, ok := bouncer.User(r.Context()) + if !ok || user == nil || user.ID == 0 { + writeUnauthorized(w) + return nil, nil, false + } + return db, user, true +} + +func apiTokenScopes(raw any) ([]string, error) { + if raw == nil { + return nil, nil + } + values, ok := raw.([]any) + if !ok { + return nil, errors.New("The scopes field must be an array.") + } + out := make([]string, 0, len(values)) + for _, rawScope := range values { + scope, ok := rawScope.(string) + if !ok || !slices.Contains(classes.AllowedAPITokenScopes, scope) { + return nil, errors.New("The selected scope is invalid.") + } + out = append(out, scope) + } + return out, nil +} + +func apiTokenJSON(token *models.APIToken) map[string]any { + name := "" + if token.Name != nil { + name = *token.Name + } + return map[string]any{ + "id": token.ID, "name": name, "scopes": wire.Slice(token.Scopes.Get()), + "last_used_at": token.LastUsedAt, "last_used_ip": token.LastUsedIP, + "expires_at": token.ExpiresAt, "revoked_at": token.RevokedAt, + "created_at": token.CreatedAt, + } +} diff --git a/middleware/api_token.go b/middleware/api_token.go new file mode 100644 index 0000000..83aedbc --- /dev/null +++ b/middleware/api_token.go @@ -0,0 +1,129 @@ +package middleware + +import ( + "errors" + "net/http" + "strings" + "time" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/wire" + "gorm.io/gorm" +) + +var errInvalidAPIToken = errors.New("invalid API token") + +// APITokenGuard authenticates the shared golem15.user API token and stores +// both its user principal and *models.APIToken credential on the request. +type APITokenGuard struct { + App *backpack.App + DB func() *gorm.DB + Prefix string + ClientIP func(*http.Request) string +} + +var ( + _ bouncer.CredentialGuard = (*APITokenGuard)(nil) + _ bouncer.UnauthorizedWriter = (*APITokenGuard)(nil) +) + +func (g *APITokenGuard) AuthenticateCredential(r *http.Request) (*bouncer.Principal, any, error) { + if g == nil || r == nil { + return nil, nil, errInvalidAPIToken + } + prefix := g.Prefix + if prefix == "" { + var err error + prefix, err = classes.APITokenPrefix(g.App) + if err != nil { + return nil, nil, errInvalidAPIToken + } + } + secret := BearerToken(r) + if secret == "" || !strings.HasPrefix(secret, prefix) { + return nil, nil, errInvalidAPIToken + } + var db *gorm.DB + if g.DB != nil { + db = g.DB() + } else if g.App != nil { + db, _ = g.App.Lookup[*gorm.DB]() + } + if db == nil { + return nil, nil, errInvalidAPIToken + } + db = db.WithContext(r.Context()) + var token models.APIToken + result := db.Where("token_hash = ?", classes.HashAPIToken(secret)).Limit(1).Find(&token) + now := time.Now() + if result.Error != nil || result.RowsAffected == 0 || !token.IsUsableAt(now) { + return nil, nil, errInvalidAPIToken + } + principal, err := classes.PrincipalByID(r.Context(), db, token.UserID) + if err != nil || principal == nil { + return nil, nil, errInvalidAPIToken + } + used := map[string]any{"last_used_at": now} + if g.ClientIP != nil { + if ip := strings.TrimSpace(g.ClientIP(r)); ip != "" { + used["last_used_ip"] = ip + token.LastUsedIP = &ip + } + } + if err := db.Model(&models.APIToken{}).Where("id = ?", token.ID).UpdateColumns(used).Error; err != nil { + return nil, nil, errInvalidAPIToken + } + token.LastUsedAt = &now + return principal, &token, nil +} + +func (g *APITokenGuard) WriteUnauthorized(w http.ResponseWriter, _ error) { + wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Invalid token."}) +} + +// Scope requires one delegated scope from the shared token credential. +func Scope(scope string) pact.Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + credential, _ := bouncer.Credential(r.Context()) + token, ok := credential.(*models.APIToken) + if !ok || token == nil || !token.HasScope(scope) { + wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Missing required scope: " + scope}) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// Permission requires one current user-group permission. Tokens never carry +// a permission snapshot: revoking the user's grant takes effect immediately. +func Permission(code string) pact.Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, _ := bouncer.User(r.Context()) + if !cabana.Allows(principal, []string{code}) { + wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Forbidden."}) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// BearerToken returns the Authorization bearer value or an empty string. +func BearerToken(r *http.Request) string { + if r == nil { + return "" + } + token, ok := strings.CutPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ") + if !ok { + return "" + } + return strings.TrimSpace(token) +} diff --git a/models/api_token.go b/models/api_token.go new file mode 100644 index 0000000..e016b86 --- /dev/null +++ b/models/api_token.go @@ -0,0 +1,50 @@ +package models + +import ( + "slices" + "time" + + "git.golem15.com/golem15/summercms/modules/lagoon" +) + +// APIToken is a personal or OAuth access token owned by golem15.user. The +// clear-text secret is returned once and never persisted. +type APIToken struct { + ID uint `gorm:"column:id;primaryKey"` + UserID uint `gorm:"column:user_id"` + Name *string `gorm:"column:name"` + TokenHash string `gorm:"column:token_hash" json:"-"` + Scopes lagoon.Jsonable[[]string] `gorm:"column:scopes"` + CollectionIDs lagoon.Jsonable[[]uint] `gorm:"column:collection_ids"` + ExpiresAt *time.Time `gorm:"column:expires_at"` + RevokedAt *time.Time `gorm:"column:revoked_at"` + LastUsedAt *time.Time `gorm:"column:last_used_at"` + LastUsedIP *string `gorm:"column:last_used_ip"` + OAuthClientID *string `gorm:"column:oauth_client_id"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (APIToken) TableName() string { return "user_api_tokens" } + +func (APIToken) Fillable() []string { + return []string{ + "user_id", "name", "token_hash", "scopes", "collection_ids", + "expires_at", "revoked_at", "last_used_at", "last_used_ip", "oauth_client_id", + } +} + +func (APIToken) Hidden() []string { return []string{"token_hash"} } + +// HasScope reports whether this token explicitly carries scope. +func (t APIToken) HasScope(scope string) bool { return slices.Contains(t.Scopes.Get(), scope) } + +// IsUsableAt is false after revocation or expiry. +func (t APIToken) IsUsableAt(now time.Time) bool { + return t.RevokedAt == nil && (t.ExpiresAt == nil || t.ExpiresAt.After(now)) +} + +// IsUsable checks usability at the current time. +func (t APIToken) IsUsable() bool { return t.IsUsableAt(time.Now()) } + +func init() { Register(APIToken{}) } diff --git a/plugin.go b/plugin.go index 40bec01..703e6f6 100644 --- a/plugin.go +++ b/plugin.go @@ -12,6 +12,7 @@ import ( "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/console" + usermiddleware "git.golem15.com/golem15/sm-user-plugin/middleware" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/sm-user-plugin/updates" "git.golem15.com/golem15/summercms/modules/backpack" @@ -25,17 +26,18 @@ import ( ) var ( - _ party.Plugin = (*Plugin)(nil) - _ pact.HasConfig = (*Plugin)(nil) - _ pact.HasMigrations = (*Plugin)(nil) - _ pact.HasMiddleware = (*Plugin)(nil) - _ pact.HasModels = (*Plugin)(nil) - _ pact.HasRoutes = (*Plugin)(nil) - _ pact.HasMailTemplates = (*Plugin)(nil) - _ pact.HasLang = (*Plugin)(nil) - _ pact.HasCommands = (*Plugin)(nil) - _ surf.BucketProvider = (*Plugin)(nil) - _ bouncer.UserProvider = classes.GormUsers{} + _ party.Plugin = (*Plugin)(nil) + _ pact.HasConfig = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasMiddleware = (*Plugin)(nil) + _ pact.HasMiddlewareFactories = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) + _ pact.HasRoutes = (*Plugin)(nil) + _ pact.HasMailTemplates = (*Plugin)(nil) + _ pact.HasLang = (*Plugin)(nil) + _ pact.HasCommands = (*Plugin)(nil) + _ surf.BucketProvider = (*Plugin)(nil) + _ bouncer.UserProvider = classes.GormUsers{} ) //go:embed config @@ -93,7 +95,18 @@ func (p *Plugin) Boot(app *backpack.App) error { } p.startBlacklistSweep(bl) } - return reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, "token", "auth_token")) + if err := reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, "token", "auth_token")); err != nil { + return err + } + prefix, err := classes.APITokenPrefix(app) + if err != nil { + return err + } + trusted := surf.TrustedProxies(app.Config) + return reg.Register(p.ID(), "api_token", &usermiddleware.APITokenGuard{ + App: app, Prefix: prefix, + ClientIP: func(r *http.Request) string { return surf.ClientIP(r, trusted) }, + }) } func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) { @@ -186,7 +199,7 @@ func (p *Plugin) MailLayouts() map[string]string { } func (p *Plugin) Commands() []bonfire.Command { - return []bonfire.Command{console.RequirePasswordChange(p.app)} + return append([]bonfire.Command{console.RequirePasswordChange(p.app)}, console.APITokenCommands(p.app)...) } func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() } @@ -201,11 +214,21 @@ func (p *Plugin) Middlewares() map[string]pact.Middleware { if !ok || reg == nil { return nil } - mw, err := reg.Middleware("jwt") - if err != nil { - return nil + out := map[string]pact.Middleware{} + if mw, err := reg.Middleware("jwt"); err == nil { + out["jwt.auth"] = mw + } + if mw, err := reg.Middleware("api_token"); err == nil { + out["user.api_token"] = mw + } + return out +} + +func (p *Plugin) MiddlewareFactories() map[string]func(string) pact.Middleware { + return map[string]func(string) pact.Middleware{ + "user.scope": usermiddleware.Scope, + "user.permission": usermiddleware.Permission, } - return map[string]pact.Middleware{"jwt.auth": mw} } func init() { diff --git a/routes.go b/routes.go index 719306d..b41310b 100644 --- a/routes.go +++ b/routes.go @@ -23,6 +23,10 @@ func (p *Plugin) Routes(r pact.Router) error { g.Post("/marketing-consent", controllers.MarketingConsent(p.app)) g.Post("/avatar", controllers.UploadAvatar(p.app)) g.Post("/avatar/remove", controllers.RemoveAvatar(p.app)) + g.Post("/tokens", controllers.APITokenStore(p.app), "jwt.auth") + g.Get("/tokens", controllers.APITokenIndex(p.app), "jwt.auth") + g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth") + g.Where("id", "[0-9]+") }) return nil } diff --git a/updates/202610040001_create_api_tokens.go b/updates/202610040001_create_api_tokens.go new file mode 100644 index 0000000..3ad86ac --- /dev/null +++ b/updates/202610040001_create_api_tokens.go @@ -0,0 +1,36 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var apiTokenMigrations = []*gormigrate.Migration{ + { + ID: "202610040001_create_user_api_tokens", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE user_api_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NULL, + token_hash VARCHAR(64) NOT NULL UNIQUE, + scopes TEXT NULL, + collection_ids TEXT NULL, + expires_at TIMESTAMPTZ NULL, + revoked_at TIMESTAMPTZ NULL, + last_used_at TIMESTAMPTZ NULL, + last_used_ip VARCHAR(64) NULL, + oauth_client_id TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX user_api_tokens_user_id_index ON user_api_tokens (user_id); +CREATE INDEX user_api_tokens_oauth_client_id_index ON user_api_tokens (oauth_client_id)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("DROP TABLE IF EXISTS user_api_tokens").Error + }, + }, +} + +func init() { Register(apiTokenMigrations...) } diff --git a/updates/api_tokens_test.go b/updates/api_tokens_test.go new file mode 100644 index 0000000..29376c4 --- /dev/null +++ b/updates/api_tokens_test.go @@ -0,0 +1,42 @@ +package updates + +import ( + "testing" + + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/lagoon" + "github.com/go-gormigrate/gormigrate/v2" +) + +func TestAPITokenMigration(t *testing.T) { + sqlDB := dedicatedDB(t, "user_api_tokens") + db, err := lagoon.Use(t.Context(), sqlDB) + if err != nil { + t.Fatal(err) + } + m := gormigrate.New(db, &gormigrate.Options{TableName: "summer_migrations_golem15_user", UseTransaction: true}, All()) + if err := m.Migrate(); err != nil { + t.Fatal(err) + } + if !db.Migrator().HasTable((&models.APIToken{}).TableName()) { + t.Fatal("user_api_tokens missing") + } + for _, column := range []string{"id", "user_id", "name", "token_hash", "scopes", "collection_ids", "expires_at", "revoked_at", "last_used_at", "last_used_ip", "oauth_client_id", "created_at", "updated_at"} { + if !db.Migrator().HasColumn(&models.APIToken{}, column) { + t.Fatalf("user_api_tokens.%s missing", column) + } + } + var oauthClientType string + if err := db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = 'user_api_tokens' AND column_name = 'oauth_client_id'`).Scan(&oauthClientType).Error; err != nil { + t.Fatal(err) + } + if oauthClientType != "text" { + t.Fatalf("oauth_client_id type = %q, want text for legacy OAuth client IDs", oauthClientType) + } + if err := m.RollbackLast(); err != nil { + t.Fatal(err) + } + if db.Migrator().HasTable(&models.APIToken{}) { + t.Fatal("user_api_tokens survived rollback") + } +} diff --git a/updates/organisations_test.go b/updates/organisations_test.go index 6c855fd..dcd24ce 100644 --- a/updates/organisations_test.go +++ b/updates/organisations_test.go @@ -45,6 +45,7 @@ func TestCreateOrganisationsMigration(t *testing.T) { "202609220006_create_user_throttle", "202609220007_create_jwt_blacklist", "202610020001_create_user_groups", + "202610040001_create_user_api_tokens", } if len(ids) != len(want) { t.Fatalf("history = %v", ids) @@ -55,8 +56,8 @@ func TestCreateOrganisationsMigration(t *testing.T) { } } - // user groups, jwt blacklist, throttle, extend-users, then organisations. - for range 5 { + // API tokens, user groups, jwt blacklist, throttle, extend-users, then organisations. + for range 6 { if err := m.RollbackLast(); err != nil { t.Fatal(err) } diff --git a/updates/user_groups_test.go b/updates/user_groups_test.go index 0de3db3..b2c7971 100644 --- a/updates/user_groups_test.go +++ b/updates/user_groups_test.go @@ -54,8 +54,10 @@ func TestUserGroupsMigration(t *testing.T) { t.Fatalf("users_groups primary key = %q", pk) } - if err := m.RollbackLast(); err != nil { - t.Fatal(err) + for range 2 { // API tokens, then user groups. + if err := m.RollbackLast(); err != nil { + t.Fatal(err) + } } if gdb.Migrator().HasTable("user_groups") || gdb.Migrator().HasTable("users_groups") { t.Fatal("user groups tables survived rollback") diff --git a/updates/user_session_test.go b/updates/user_session_test.go index 68ba0a8..d23cbbc 100644 --- a/updates/user_session_test.go +++ b/updates/user_session_test.go @@ -47,8 +47,8 @@ func TestExtendUsersMigration(t *testing.T) { if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded { t.Fatalf("defaults = %+v", row) } - // user groups, jwt blacklist, throttle, then extend-users. - for range 4 { + // API tokens, user groups, jwt blacklist, throttle, then extend-users. + for range 5 { if err := m.RollbackLast(); err != nil { t.Fatal(err) } @@ -87,8 +87,8 @@ func TestCreateUserThrottleMigration(t *testing.T) { t.Fatalf("missing column %s", col) } } - // user groups, jwt blacklist, then throttle. - for range 3 { + // API tokens, user groups, jwt blacklist, then throttle. + for range 4 { if err := m.RollbackLast(); err != nil { t.Fatal(err) } @@ -119,8 +119,8 @@ func TestCreateJwtBlacklistMigration(t *testing.T) { t.Fatalf("missing column %s", col) } } - // user groups, then jwt blacklist. - for range 2 { + // API tokens, user groups, then jwt blacklist. + for range 3 { if err := m.RollbackLast(); err != nil { t.Fatal(err) }