feat: own shared user API tokens

This commit is contained in:
Jakub Zych
2026-10-04 22:15:57 +02:00
parent 8a658903ed
commit 0fe5b91b63
17 changed files with 926 additions and 35 deletions

View File

@@ -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:<scope>` limits delegated access and `user.permission:<code>` 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 (`<dir>/summercms.go` and `<dir>/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 <email>` | Flag an account so its next session must set a new password. |
| `user:token:create <email> [--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 <id>` | Revoke a shared API token immediately. |
## Dependencies

114
api_tokens_test.go Normal file
View File

@@ -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())
}
}

112
classes/api_tokens.go Normal file
View File

@@ -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
}

View File

@@ -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) {

View File

@@ -20,3 +20,5 @@ throttle:
password:
bcrypt_cost: 10
min_length: 8
api_tokens:
prefix: usr_

133
console/api_tokens.go Normal file
View File

@@ -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
})
},
},
}
}

View File

@@ -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")
}
}

161
controllers/api_tokens.go Normal file
View File

@@ -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,
}
}

129
middleware/api_token.go Normal file
View File

@@ -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)
}

50
models/api_token.go Normal file
View File

@@ -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{}) }

View File

@@ -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() {

View File

@@ -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
}

View File

@@ -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...) }

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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)
}