feat: own shared user API tokens
This commit is contained in:
19
README.md
19
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:<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
114
api_tokens_test.go
Normal 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
112
classes/api_tokens.go
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -20,3 +20,5 @@ throttle:
|
||||
password:
|
||||
bcrypt_cost: 10
|
||||
min_length: 8
|
||||
api_tokens:
|
||||
prefix: usr_
|
||||
|
||||
133
console/api_tokens.go
Normal file
133
console/api_tokens.go
Normal 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
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
161
controllers/api_tokens.go
Normal 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
129
middleware/api_token.go
Normal 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
50
models/api_token.go
Normal 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{}) }
|
||||
57
plugin.go
57
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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
36
updates/202610040001_create_api_tokens.go
Normal file
36
updates/202610040001_create_api_tokens.go
Normal 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...) }
|
||||
42
updates/api_tokens_test.go
Normal file
42
updates/api_tokens_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user