Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c5d3b8935 | |||
| 9335e80342 | |||
| 80b436d3d9 | |||
| ee474401cb | |||
| b82d85f440 | |||
|
|
c0770294a5 | ||
|
|
15b19c7021 | ||
| 569768e657 | |||
|
|
24b16b1f10 |
19
README.md
19
README.md
@@ -31,9 +31,11 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
|||||||
- **Privileged groups.** The groups whose code is listed in `golem15.user.privileged_groups` (default `admin`) are privileged: an application may treat their members as site administrators. Three things therefore need the backend permission `golem15.users.manage_privileged_groups` on top of the screen's own permission, and answer 403 without it, saving nothing. First, membership: adding a privileged group to a user or removing it, on create and on update of the user form. An administrator without the permission sees those groups locked in the field and can still change the user's other groups. Second, the code: creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group. Its name, description and frontend permissions need only `golem15.users.access_groups`. Codes compare case-sensitively, and a group without a code is never privileged. Third, the member's account, which is protected against takeover: changing that user's email or password, and deleting them for good, needs the backend permission `golem15.users.manage_privileged_groups` on top of `golem15.users.access_users`. Without it the request answers 403 and nothing is saved; a bulk delete that contains such a user deletes none of the selected users. Everything else on such a user (name, organisation, avatar, frontend permissions, activate, deactivate, restore, ban, unban, unsuspend) needs only `golem15.users.access_users`. `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs` and `IsPrivilegedMember` expose the rule to other code.
|
- **Privileged groups.** The groups whose code is listed in `golem15.user.privileged_groups` (default `admin`) are privileged: an application may treat their members as site administrators. Three things therefore need the backend permission `golem15.users.manage_privileged_groups` on top of the screen's own permission, and answer 403 without it, saving nothing. First, membership: adding a privileged group to a user or removing it, on create and on update of the user form. An administrator without the permission sees those groups locked in the field and can still change the user's other groups. Second, the code: creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group. Its name, description and frontend permissions need only `golem15.users.access_groups`. Codes compare case-sensitively, and a group without a code is never privileged. Third, the member's account, which is protected against takeover: changing that user's email or password, and deleting them for good, needs the backend permission `golem15.users.manage_privileged_groups` on top of `golem15.users.access_users`. Without it the request answers 403 and nothing is saved; a bulk delete that contains such a user deletes none of the selected users. Everything else on such a user (name, organisation, avatar, frontend permissions, activate, deactivate, restore, ban, unban, unsuspend) needs only `golem15.users.access_users`. `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs` and `IsPrivilegedMember` expose the rule to other code.
|
||||||
- **Frontend permissions.** `golem15_user_frontend_permissions` is the catalogue of permissions a site defines for its users (rows come from the application's migrations or seeds). A user's own choices are stored in `users.permissions` and a group's in `user_groups.permissions`, as a JSON object of code to `1` (allow) or `-1` (deny). `MergedPermissions` combines them as PHP `getMergedPermissions` does: among a user's groups the most positive value wins, then the user's own value replaces the groups'. `HasPermission` and `UserHasPermission` answer whether a code is held: only at exactly `1`, with Winter's `*` wildcards. Neither value appears in the user payload, whose `permissions` key stays `null`.
|
- **Frontend permissions.** `golem15_user_frontend_permissions` is the catalogue of permissions a site defines for its users (rows come from the application's migrations or seeds). A user's own choices are stored in `users.permissions` and a group's in `user_groups.permissions`, as a JSON object of code to `1` (allow) or `-1` (deny). `MergedPermissions` combines them as PHP `getMergedPermissions` does: among a user's groups the most positive value wins, then the user's own value replaces the groups'. `HasPermission` and `UserHasPermission` answer whether a code is held: only at exactly `1`, with Winter's `*` wildcards. Neither value appears in the user payload, whose `permissions` key stays `null`.
|
||||||
- **Last seen.** `users.last_seen` is stamped by `POST /login` and `POST /refresh` through `TouchLastSeen`, at most once per five minutes per user. A failed write is logged and never fails authentication. It is shown in the admin list and is not part of any user payload.
|
- **Last seen.** `users.last_seen` is stamped by `POST /login` and `POST /refresh` through `TouchLastSeen`, at most once per five minutes per user. A failed write is logged and never fails authentication. It is shown in the admin list and is not part of any user payload.
|
||||||
|
- **Handle.** `users.handle` is a unique nullable `VARCHAR(255)` generated on create (`classes.HandleGenerator`, charset `[a-z0-9._-]`, length 2–255). It is omitted from the user JSON payload (`json:"-"`). `user:backfill-handles` fills existing rows that still have none.
|
||||||
|
- **User lighthouse authorizer.** Boot calls `classes.RegisterUserAuthorizer`: `lighthouse.From` then `Registry.Register("user", …)` if the namespace is free. `classes.AuthorizeUser` allows only `user:{id}` for that user (`lighthouse.ChannelID` on the two-segment name).
|
||||||
- **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.
|
- **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, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
|
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
|
||||||
- **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.
|
- **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). `CreatedEvent` (`classes.CreatedEventName` `user.Created`) fires from API register and admin create. `DeletingEvent` (`classes.DeletingEventName` `user.Deleting`) fires from `FormAfterDelete` before `ForceDeleteCleanup` / unscoped delete, inside the same transaction. `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.
|
- **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.
|
||||||
- **OAuth providers.** `GET /oauth-providers` lists configured social login providers (none yet: it returns an empty list).
|
- **OAuth providers.** `GET /oauth-providers` lists configured social login providers (none yet: it returns an empty list).
|
||||||
|
|
||||||
@@ -149,11 +151,20 @@ Migrations added for linked OAuth identities, all additive:
|
|||||||
|----|------|
|
|----|------|
|
||||||
| `202610050001_create_oauth_identities` | the table `golem15_user_oauth_identities` (`user_id` cascade FK to `users`, unique `(user_id, provider)` and `(provider, provider_id)`, jsonb `profile_data`) |
|
| `202610050001_create_oauth_identities` | the table `golem15_user_oauth_identities` (`user_id` cascade FK to `users`, unique `(user_id, provider)` and `(provider, provider_id)`, jsonb `profile_data`) |
|
||||||
|
|
||||||
|
Migrations added for @mention handles, all additive:
|
||||||
|
|
||||||
|
| ID | Adds |
|
||||||
|
|----|------|
|
||||||
|
| `202610160002_add_handle_to_users` | `users.handle` (`VARCHAR(255)` NULL, unique index `users_handle_unique`, `ADD COLUMN IF NOT EXISTS`; rollback is a no-op) |
|
||||||
|
|
||||||
Packages:
|
Packages:
|
||||||
|
|
||||||
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins:
|
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins:
|
||||||
- `RegisterUser(ctx, app, db, fields, RegisterOptions)` validates `fields` against the user model's rules, hashes the password with `golem15.user.password.bcrypt_cost` and creates the user on `db` (which may be a transaction). It returns the validation errors instead of writing a response and never modifies `fields`. `RegisterOptions.Activate` creates the account activated; `RegisterOptions.IP` records the client IP.
|
- `RegisterUser(ctx, app, db, fields, RegisterOptions)` validates `fields` against the user model's rules, hashes the password with `golem15.user.password.bcrypt_cost` and creates the user on `db` (which may be a transaction). It returns the validation errors instead of writing a response and never modifies `fields`. `RegisterOptions.Activate` creates the account activated; `RegisterOptions.IP` records the client IP.
|
||||||
- `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.
|
- `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.
|
||||||
|
- `FireCreatedEvent(ctx, app, user)` fires `CreatedEvent` (`user.Created`). `/register` ignores that error.
|
||||||
|
- `FireDeletingEvent(ctx, app, user, tx)` fires `DeletingEvent` (`user.Deleting`) inside the delete transaction.
|
||||||
|
- `FireDeactivatingEvent(ctx, app, user, tx)` fires `DeactivatingEvent` (`user.Deactivating`) inside the deactivation transaction. The admin deactivate bulk action fires it after `DeactivateUsers` for every selected user that was not deactivated yet; a listener error rolls the action back.
|
||||||
- `IssueToken(app, user, r)` mints the session JWT as `/register` and `/login` do.
|
- `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.
|
- `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners.
|
||||||
- `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. For the admin and for other plugins it also has:
|
- `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. For the admin and for other plugins it also has:
|
||||||
@@ -161,10 +172,13 @@ Packages:
|
|||||||
- `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule.
|
- `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule.
|
||||||
- `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction.
|
- `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction.
|
||||||
- `TouchLastSeen(ctx, db, userID)`.
|
- `TouchLastSeen(ctx, db, userID)`.
|
||||||
|
- `RequestUser(ctx, app, r)` and `EndRequestSession(ctx, app, w, r)`, for handlers that serve signed-in and anonymous browsers on one route (an OAuth provider's authorize page). `RequestUser` reads and checks the session JWT exactly as the `jwt` guard does (bearer header, then the `token` and `auth_token` cookies, the blacklist, `tokens_valid_after`) and returns the user, or `nil` for an anonymous, invalid, expired or blacklisted request and for a soft-deleted user; only an infrastructure failure is an error. `EndRequestSession` blacklists the request's JWT until it expires (as `POST /logout` does) and writes the forget cookie of `bouncer.LoadCookieConfig`, best effort: a blacklist failure is logged, never returned.
|
||||||
|
- `AvatarURL(ctx, app, user, size)`, the display picture rule of the user payload at any size: the avatar thumbnail (size by size, mode auto) when the user has an avatar and a file bucket is published, the file path when the thumbnail fails, otherwise the gravatar URL. The URL may be protocol-relative or relative. The payload's `avatar_url` comes from it at 128.
|
||||||
|
- `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
|
||||||
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
|
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
|
||||||
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`).
|
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`).
|
||||||
- `updates`: the gormigrate migrations, returned by `updates.All()`.
|
- `updates`: the gormigrate migrations, returned by `updates.All()`.
|
||||||
- `console`: `RequirePasswordChange` and `APITokenCommands`.
|
- `console`: `RequirePasswordChange`, `BackfillHandles` and `APITokenCommands`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -197,6 +211,7 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
|
|||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `user:require-password-change <email>` | Flag an account so its next session must set a new password. |
|
| `user:require-password-change <email>` | Flag an account so its next session must set a new password. |
|
||||||
|
| `user:backfill-handles` | Assign a unique handle to every user row that still has none. |
|
||||||
| `user:token:create <email> [--name api] [--scopes read,write] [--expires RFC3339]` | Create an application-wide API token and print its secret once. |
|
| `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:list` | List token metadata without secrets or hashes. |
|
||||||
| `user:token:revoke <id>` | Revoke a shared API token immediately. |
|
| `user:token:revoke <id>` | Revoke a shared API token immediately. |
|
||||||
|
|||||||
136
avatar_url_test.go
Normal file
136
avatar_url_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"gocloud.dev/blob/memblob"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jpegAvatar stores a real JPEG avatar for user (a system_files row and its
|
||||||
|
// blob) and returns the row.
|
||||||
|
func (e *adminEnv) jpegAvatar(t *testing.T, user *models.User) attach.File {
|
||||||
|
t.Helper()
|
||||||
|
disk := "face" + adminStamp() + ".jpg"
|
||||||
|
data := jpegBytes(t)
|
||||||
|
if err := e.bucket.WriteAll(context.Background(), attach.BlobKey(disk), data, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
row := attach.File{
|
||||||
|
DiskName: disk, FileName: "face.jpg", FileSize: int64(len(data)), ContentType: "image/jpeg",
|
||||||
|
Field: models.AvatarField, AttachmentID: strconv.FormatUint(uint64(user.ID), 10), AttachmentType: user.MorphName(),
|
||||||
|
}
|
||||||
|
if err := e.gdb.Create(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func gravatarFor(email string, size int) string {
|
||||||
|
return fmt.Sprintf("//www.gravatar.com/avatar/%x?s=%d&d=mm", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email)))), size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAvatarURL pins classes.AvatarURL (D-05): the avatar thumbnail at the
|
||||||
|
// asked size, the file path when the thumbnail fails, the gravatar
|
||||||
|
// fallback with the size, errors only for a failing lookup, and the user
|
||||||
|
// API payload (applyAvatar) unchanged: its avatar_url is AvatarURL at 128.
|
||||||
|
func TestAvatarURL(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
if _, err := classes.AvatarURL(ctx, env.app, nil, 128); err == nil {
|
||||||
|
t.Fatal("a nil user must be refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gravatar: no app, an unsaved user, no attachment; the email is
|
||||||
|
// trimmed and lower-cased, the size is the one asked for.
|
||||||
|
plain := env.seedUser(t, "plain", func(u *models.User) { u.Email = " Mixed.Case-" + adminStamp() + "@Example.TEST " })
|
||||||
|
for _, size := range []int{32, 128, 256} {
|
||||||
|
for name, call := range map[string]func() (string, error){
|
||||||
|
"no app": func() (string, error) { return classes.AvatarURL(ctx, nil, plain, size) },
|
||||||
|
"unsaved user": func() (string, error) { return classes.AvatarURL(ctx, env.app, &models.User{Email: plain.Email}, size) },
|
||||||
|
"no attachment": func() (string, error) { return classes.AvatarURL(ctx, env.app, plain, size) },
|
||||||
|
} {
|
||||||
|
got, err := call()
|
||||||
|
if err != nil || got != gravatarFor(plain.Email, size) {
|
||||||
|
t.Fatalf("%s at %d = %q %v, want %q", name, size, got, err, gravatarFor(plain.Email, size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gravatarFor(plain.Email, 1) != gravatarFor(strings.ToLower(strings.TrimSpace(plain.Email)), 1) {
|
||||||
|
t.Fatal("the gravatar hash is not of the normalised email")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file avatar: the thumbnail at size by size, mode auto.
|
||||||
|
withFile := env.seedUser(t, "withfile", nil)
|
||||||
|
row := env.jpegAvatar(t, withFile)
|
||||||
|
thumb, err := classes.AvatarURL(ctx, env.app, withFile, 64)
|
||||||
|
want := attach.ThumbFilename(row.ID, 64, 64, 0, 0, "auto", "jpg")
|
||||||
|
if err != nil || !strings.HasSuffix(thumb, want) || strings.HasPrefix(thumb, "//") {
|
||||||
|
t.Fatalf("thumbnail = %q %v, want a path ending in %s", thumb, err, want)
|
||||||
|
}
|
||||||
|
// Without a published bucket the gravatar is used even with a file.
|
||||||
|
noBucket := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
||||||
|
if err := noBucket.Publish(env.gdb); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := classes.AvatarURL(ctx, noBucket, withFile, 64); err != nil || got != gravatarFor(withFile.Email, 64) {
|
||||||
|
t.Fatalf("no bucket = %q %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An original that does not decode gets WinterCMS's broken-image
|
||||||
|
// thumbnail, still a thumbnail path.
|
||||||
|
notImage := env.seedUser(t, "notimage", nil)
|
||||||
|
env.seedAvatar(t, notImage) // its blob is not an image
|
||||||
|
if got, err := classes.AvatarURL(ctx, env.app, notImage, 128); err != nil || !strings.Contains(got, "thumb_") {
|
||||||
|
t.Fatalf("non-image avatar = %q %v", got, err)
|
||||||
|
}
|
||||||
|
// A thumbnail that cannot be made (here: the storage fails) falls back
|
||||||
|
// to the file path.
|
||||||
|
closed := memblob.OpenBucket(nil)
|
||||||
|
_ = closed.Close()
|
||||||
|
failing := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
||||||
|
if err := failing.Publish(env.gdb); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := attach.Publish(failing, closed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := classes.AvatarURL(ctx, failing, withFile, 128); err != nil || got != "/"+attach.BlobKey(row.DiskName) {
|
||||||
|
t.Fatalf("failing thumbnail = %q %v, want /%s", got, err, attach.BlobKey(row.DiskName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failing lookup is the only error.
|
||||||
|
canceled, cancel := context.WithCancel(ctx)
|
||||||
|
cancel()
|
||||||
|
if got, err := classes.AvatarURL(canceled, env.app, withFile, 128); err == nil {
|
||||||
|
t.Fatalf("failing lookup = %q, want an error", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user API payload is unchanged: avatar_url is AvatarURL at 128,
|
||||||
|
// next to has_avatar and the disk name.
|
||||||
|
payload, err := controllers.APIArray(ctx, env.app, withFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
at128, _ := classes.AvatarURL(ctx, env.app, withFile, 128)
|
||||||
|
if payload["has_avatar"] != true || payload["avatar"] != row.DiskName || payload["avatar_url"] != at128 {
|
||||||
|
t.Fatalf("payload avatar fields = %v %v %v, want true %s %s", payload["has_avatar"], payload["avatar"], payload["avatar_url"], row.DiskName, at128)
|
||||||
|
}
|
||||||
|
without, err := controllers.APIArray(ctx, env.app, plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Without one the payload keeps its gravatar default (size 25).
|
||||||
|
if without["has_avatar"] != false || without["avatar"] != gravatarFor(plain.Email, 25) {
|
||||||
|
t.Fatalf("payload without an avatar = %v %v", without["has_avatar"], without["avatar"])
|
||||||
|
}
|
||||||
|
}
|
||||||
49
classes/authorizer.go
Normal file
49
classes/authorizer.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lighthouse"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthorizeUser is the lighthouse authorizer for the user namespace
|
||||||
|
// (D-17, T-19-10). It allows only user:{id} for that user. ChannelID is
|
||||||
|
// the two-segment reader (segment 1 is the id). A second presence: prefix
|
||||||
|
// or extra segments are denied.
|
||||||
|
func AuthorizeUser() lighthouse.AuthorizerFunc {
|
||||||
|
return func(_ context.Context, userID uint, channel string) lighthouse.Result {
|
||||||
|
name := strings.TrimPrefix(channel, "presence:")
|
||||||
|
if strings.HasPrefix(name, "presence:") {
|
||||||
|
return lighthouse.Denied("invalid user channel")
|
||||||
|
}
|
||||||
|
parts := strings.Split(name, ":")
|
||||||
|
if len(parts) != 2 || parts[0] != "user" {
|
||||||
|
return lighthouse.Denied("invalid user channel")
|
||||||
|
}
|
||||||
|
if userID == 0 || lighthouse.ChannelID(name) != int64(userID) {
|
||||||
|
return lighthouse.Denied("not that user")
|
||||||
|
}
|
||||||
|
return lighthouse.Allowed(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterUserAuthorizer registers the user namespace if it is free. A
|
||||||
|
// missing lighthouse service is skipped so Boot stays additive on hosts
|
||||||
|
// that never mount realtime.
|
||||||
|
func RegisterUserAuthorizer(app *backpack.App) error {
|
||||||
|
svc, err := lighthouse.From(app)
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().Warn("golem15.user: lighthouse unavailable; user authorizer not registered", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if svc == nil || svc.Registry() == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := svc.Registry().Get("user"); ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return svc.Registry().Register("user", AuthorizeUser())
|
||||||
|
}
|
||||||
36
classes/authorizer_test.go
Normal file
36
classes/authorizer_test.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lighthouse"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUserAuthorizer: user:9 is allowed only for user 9 (T-19-10).
|
||||||
|
func TestUserAuthorizer(t *testing.T) {
|
||||||
|
auth := AuthorizeUser()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if got := auth.Authorize(ctx, 9, "user:9"); !got.Allowed {
|
||||||
|
t.Fatalf("user:9 for 9 denied: %s", got.Reason())
|
||||||
|
}
|
||||||
|
if got := auth.Authorize(ctx, 9, "presence:user:9"); !got.Allowed {
|
||||||
|
t.Fatalf("presence:user:9 for 9 denied: %s", got.Reason())
|
||||||
|
}
|
||||||
|
if lighthouse.ChannelID("user:9") != 9 {
|
||||||
|
t.Fatalf("ChannelID(user:9) = %d, want 9", lighthouse.ChannelID("user:9"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, channel := range []string{"user:8", "user:9:extra", "chat:9", "user:", "user", "presence:presence:user:9"} {
|
||||||
|
if got := auth.Authorize(ctx, 9, channel); got.Allowed {
|
||||||
|
t.Errorf("%q for 9 allowed, want deny", channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := auth.Authorize(ctx, 8, "user:9"); got.Allowed {
|
||||||
|
t.Fatal("user:9 for 8 allowed, want deny")
|
||||||
|
}
|
||||||
|
if got := auth.Authorize(ctx, 0, "user:0"); got.Allowed {
|
||||||
|
t.Fatal("user:0 for 0 allowed, want deny")
|
||||||
|
}
|
||||||
|
}
|
||||||
65
classes/avatar.go
Normal file
65
classes/avatar.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AvatarURL returns the display picture URL of user at size pixels, the
|
||||||
|
// rule the user API payload follows: the thumbnail (size by size, mode
|
||||||
|
// auto) of the user's avatar attachment when there is one and a file
|
||||||
|
// bucket is published, the attachment's file path when the thumbnail
|
||||||
|
// fails (logged), and otherwise the gravatar URL
|
||||||
|
// //www.gravatar.com/avatar/{md5 of the email}?s={size}&d=mm. The URL may
|
||||||
|
// be protocol-relative or relative; a caller that needs an absolute URL
|
||||||
|
// resolves it. Only a failing database lookup is an error.
|
||||||
|
func AvatarURL(ctx context.Context, app *backpack.App, user *models.User, size int) (string, error) {
|
||||||
|
if user == nil {
|
||||||
|
return "", errors.New("golem15.user: avatar needs a user")
|
||||||
|
}
|
||||||
|
fallback := gravatar(user.Email, size)
|
||||||
|
if app == nil || user.ID == 0 {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
gdb, ok := app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || gdb == nil {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
var f attach.File
|
||||||
|
err := gdb.WithContext(ctx).
|
||||||
|
Where("attachment_type = ? AND attachment_id = ? AND field = ?", user.MorphName(), strconv.FormatUint(uint64(user.ID), 10), models.AvatarField).
|
||||||
|
Take(&f).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
bucket, ok := app.Lookup[*blob.Bucket]()
|
||||||
|
if !ok || bucket == nil {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
url, err := f.Thumb(ctx, bucket, size, size, "auto")
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().Error("golem15.user: avatar thumb", "err", err)
|
||||||
|
return "/" + attach.BlobKey(f.DiskName), nil
|
||||||
|
}
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gravatar is the gravatar URL of an email at size pixels.
|
||||||
|
func gravatar(email string, size int) string {
|
||||||
|
sum := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email))))
|
||||||
|
return fmt.Sprintf("//www.gravatar.com/avatar/%x?s=%d&d=mm", sum, size)
|
||||||
|
}
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
package classes
|
package classes
|
||||||
|
|
||||||
import "git.golem15.com/golem15/sm-user-plugin/models"
|
import (
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreatedEventName is the festival name of CreatedEvent (D-13 / RESEARCH A6).
|
||||||
|
const CreatedEventName = "user.Created"
|
||||||
|
|
||||||
|
// DeletingEventName is the festival name of DeletingEvent (D-13 / RESEARCH A6).
|
||||||
|
const DeletingEventName = "user.Deleting"
|
||||||
|
|
||||||
|
// DeactivatingEventName is the festival name of DeactivatingEvent.
|
||||||
|
const DeactivatingEventName = "user.Deactivating"
|
||||||
|
|
||||||
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
|
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
|
||||||
type GetApiArrayEvent struct {
|
type GetApiArrayEvent struct {
|
||||||
@@ -27,3 +39,29 @@ type RegisterEvent struct {
|
|||||||
// or hashed. Listeners must treat it as read-only input.
|
// or hashed. Listeners must treat it as read-only input.
|
||||||
Payload map[string]any
|
Payload map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreatedEvent fires from every user create path (API register and admin
|
||||||
|
// create) so listeners can run inside the same write, including handle
|
||||||
|
// generation (D-13, D-15). Tx is the create transaction when there is one.
|
||||||
|
type CreatedEvent struct {
|
||||||
|
User *models.User
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletingEvent fires from FormAfterDelete before ForceDeleteCleanup /
|
||||||
|
// unscoped delete, inside the same transaction, so listeners (chatchannels
|
||||||
|
// GDPR) can sever rows before the user row is gone (D-13, T-19-07).
|
||||||
|
type DeletingEvent struct {
|
||||||
|
User *models.User
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivatingEvent fires after a user is soft-deleted (deactivated, the
|
||||||
|
// PHP $user->delete()) by the admin deactivate action, inside the same
|
||||||
|
// transaction, so listeners can revoke what a deactivated account must not
|
||||||
|
// keep (OAuth grants, for example) while leaving what a restore needs. A
|
||||||
|
// listener error rolls the deactivation back.
|
||||||
|
type DeactivatingEvent struct {
|
||||||
|
User *models.User
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,3 +46,41 @@ func TestRegisterEventFires(t *testing.T) {
|
|||||||
t.Fatalf("event user = %#v", seen)
|
t.Fatalf("event user = %#v", seen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreatedEventFires(t *testing.T) {
|
||||||
|
if CreatedEventName != "user.Created" {
|
||||||
|
t.Fatalf("CreatedEventName = %q, want user.Created", CreatedEventName)
|
||||||
|
}
|
||||||
|
bus := festival.New()
|
||||||
|
var seen *models.User
|
||||||
|
bus.Listen[*CreatedEvent]("golem15.user", func(_ context.Context, e *CreatedEvent) error {
|
||||||
|
seen = e.User
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
user := &models.User{ID: 9, Email: "created@example.test"}
|
||||||
|
if err := bus.Fire(context.Background(), &CreatedEvent{User: user}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seen == nil || seen.Email != user.Email {
|
||||||
|
t.Fatalf("created event user = %#v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletingEventFires(t *testing.T) {
|
||||||
|
if DeletingEventName != "user.Deleting" {
|
||||||
|
t.Fatalf("DeletingEventName = %q, want user.Deleting", DeletingEventName)
|
||||||
|
}
|
||||||
|
bus := festival.New()
|
||||||
|
var seen *models.User
|
||||||
|
bus.Listen[*DeletingEvent]("golem15.user", func(_ context.Context, e *DeletingEvent) error {
|
||||||
|
seen = e.User
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
user := &models.User{ID: 9, Email: "gone@example.test"}
|
||||||
|
if err := bus.Fire(context.Background(), &DeletingEvent{User: user}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seen == nil || seen.Email != user.Email {
|
||||||
|
t.Fatalf("deleting event user = %#v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
160
classes/handle_generator.go
Normal file
160
classes/handle_generator.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
handleMinLen = 2
|
||||||
|
handleMaxLen = 255
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleGenerator ports the PHP ChatChannels HandleGenerator. The emitted
|
||||||
|
// charset [a-z0-9._-] is a strict subset of MentionParser [A-Za-z0-9._-],
|
||||||
|
// length 2–255, so every generated handle is matchable as an @-token.
|
||||||
|
type HandleGenerator struct{}
|
||||||
|
|
||||||
|
// Slug transliterates name to ASCII, lowercases, drops every rune outside
|
||||||
|
// [a-z0-9._-], then trims leading/trailing `._-`. A result shorter than 2
|
||||||
|
// is not a valid base (Generate falls back to user{id}).
|
||||||
|
func (HandleGenerator) Slug(name string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(name))
|
||||||
|
for _, r := range name {
|
||||||
|
b.WriteString(foldHandleRune(r))
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), "._-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate returns a unique handle for name. The base is Slug(name); if
|
||||||
|
// that is shorter than 2 it falls back to "user{fallbackID}" (or "user"
|
||||||
|
// when no id is known). The smallest free integer suffix is then appended
|
||||||
|
// until isTaken returns false: first "James" → "james", second → "james1".
|
||||||
|
func (g HandleGenerator) Generate(name string, isTaken func(string) bool, fallbackID *uint) string {
|
||||||
|
base := g.Slug(name)
|
||||||
|
if len(base) < handleMinLen {
|
||||||
|
if fallbackID != nil {
|
||||||
|
base = "user" + strconv.FormatUint(uint64(*fallbackID), 10)
|
||||||
|
} else {
|
||||||
|
base = "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base = clipHandle(base)
|
||||||
|
candidate := base
|
||||||
|
i := 0
|
||||||
|
for isTaken != nil && isTaken(candidate) {
|
||||||
|
i++
|
||||||
|
candidate = clipHandle(base + strconv.Itoa(i))
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignHandle writes a unique handle onto user when it has none, using
|
||||||
|
// the user's display name and id. It is idempotent when handle is already
|
||||||
|
// set. The write uses db (the create transaction when there is one).
|
||||||
|
func AssignHandle(ctx context.Context, db *gorm.DB, user *models.User) error {
|
||||||
|
if user == nil {
|
||||||
|
return errors.New("golem15.user: a handle needs a user")
|
||||||
|
}
|
||||||
|
if user.Handle != nil && strings.TrimSpace(*user.Handle) != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if db == nil {
|
||||||
|
return errors.New("golem15.user: a handle needs a database")
|
||||||
|
}
|
||||||
|
name := ""
|
||||||
|
if user.Name != nil {
|
||||||
|
name = *user.Name
|
||||||
|
}
|
||||||
|
var fallback *uint
|
||||||
|
if user.ID != 0 {
|
||||||
|
id := user.ID
|
||||||
|
fallback = &id
|
||||||
|
}
|
||||||
|
gen := HandleGenerator{}
|
||||||
|
for attempt := 0; attempt < 8; attempt++ {
|
||||||
|
handle := gen.Generate(name, func(candidate string) bool {
|
||||||
|
var n int64
|
||||||
|
q := db.WithContext(ctx).Model(&models.User{}).Where("handle = ?", candidate)
|
||||||
|
if user.ID != 0 {
|
||||||
|
q = q.Where("id <> ?", user.ID)
|
||||||
|
}
|
||||||
|
if err := q.Count(&n).Error; err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return n > 0
|
||||||
|
}, fallback)
|
||||||
|
user.Handle = &handle
|
||||||
|
res := db.WithContext(ctx).Model(user).Where("id = ?", user.ID).Update("handle", handle)
|
||||||
|
if res.Error == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isUniqueHandleConflict(res.Error) {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("golem15.user: could not allocate a unique handle")
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipHandle(s string) string {
|
||||||
|
if len(s) <= handleMaxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:handleMaxLen]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUniqueHandleConflict(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "users_handle_unique") || strings.Contains(msg, "duplicate key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// foldHandleRune maps r into the handle charset [a-z0-9._-] or "".
|
||||||
|
func foldHandleRune(r rune) string {
|
||||||
|
r = unicode.ToLower(r)
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
if mapped, ok := latinHandleFold[r]; ok {
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// latinHandleFold covers Latin-1 / Latin Extended-A letters PHP Str::ascii
|
||||||
|
// would emit for the names we generate handles from (Polish, French, German).
|
||||||
|
var latinHandleFold = map[rune]string{
|
||||||
|
'à': "a", 'á': "a", 'â': "a", 'ã': "a", 'ä': "a", 'å': "a", 'ą': "a", 'ā': "a",
|
||||||
|
'æ': "ae",
|
||||||
|
'ç': "c", 'ć': "c", 'č': "c",
|
||||||
|
'ď': "d", 'đ': "d", 'ð': "d",
|
||||||
|
'è': "e", 'é': "e", 'ê': "e", 'ë': "e", 'ę': "e", 'ē': "e", 'ě': "e",
|
||||||
|
'ì': "i", 'í': "i", 'î': "i", 'ï': "i", 'ī': "i",
|
||||||
|
'ł': "l",
|
||||||
|
'ñ': "n", 'ń': "n", 'ň': "n",
|
||||||
|
'ò': "o", 'ó': "o", 'ô': "o", 'õ': "o", 'ö': "o", 'ø': "o", 'ō': "o",
|
||||||
|
'œ': "oe",
|
||||||
|
'ř': "r",
|
||||||
|
'ś': "s", 'š': "s", 'ş': "s", 'ß': "ss",
|
||||||
|
'ť': "t", 'þ': "th",
|
||||||
|
'ù': "u", 'ú': "u", 'û': "u", 'ü': "u", 'ū': "u",
|
||||||
|
'ý': "y", 'ÿ': "y",
|
||||||
|
'ź': "z", 'ż': "z", 'ž': "z",
|
||||||
|
}
|
||||||
78
classes/handle_generator_test.go
Normal file
78
classes/handle_generator_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestHandle: generator charset is a subset of MentionParser, uniqueness
|
||||||
|
// uses the smallest free integer suffix, and short names fall back to user{id}.
|
||||||
|
func TestHandle(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
g := HandleGenerator{}
|
||||||
|
|
||||||
|
if got := g.Slug("Paweł"); got != "pawel" {
|
||||||
|
t.Fatalf("slug Paweł = %q, want pawel", got)
|
||||||
|
}
|
||||||
|
if got := g.Slug("John Doe"); got != "johndoe" {
|
||||||
|
t.Fatalf("slug John Doe = %q, want johndoe", got)
|
||||||
|
}
|
||||||
|
if got := g.Slug("Zoë"); got != "zoe" {
|
||||||
|
t.Fatalf("slug Zoë = %q, want zoe", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
handle := g.Generate("James", func(string) bool { return false }, nil)
|
||||||
|
if handle != "james" {
|
||||||
|
t.Fatalf("generate James = %q, want james", handle)
|
||||||
|
}
|
||||||
|
if !validHandle(handle) {
|
||||||
|
t.Fatalf("generated %q is outside [a-z0-9._-]{2,255}", handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
taken := map[string]struct{}{}
|
||||||
|
isTaken := func(c string) bool {
|
||||||
|
_, ok := taken[c]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
first := g.Generate("James", isTaken, nil)
|
||||||
|
taken[first] = struct{}{}
|
||||||
|
second := g.Generate("James", isTaken, nil)
|
||||||
|
taken[second] = struct{}{}
|
||||||
|
third := g.Generate("James", isTaken, nil)
|
||||||
|
if first != "james" || second != "james1" || third != "james2" {
|
||||||
|
t.Fatalf("suffixes = %q %q %q, want james james1 james2", first, second, third)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uint(42)
|
||||||
|
if got := g.Generate("", func(string) bool { return false }, &id); got != "user42" {
|
||||||
|
t.Fatalf("empty name = %q, want user42", got)
|
||||||
|
}
|
||||||
|
if got := g.Generate("x", func(string) bool { return false }, &id); got != "user42" {
|
||||||
|
t.Fatalf("short name = %q, want user42", got)
|
||||||
|
}
|
||||||
|
if got := g.Generate("???", func(string) bool { return false }, &id); got != "user42" {
|
||||||
|
t.Fatalf("punctuation name = %q, want user42", got)
|
||||||
|
}
|
||||||
|
if got := g.Generate("", func(string) bool { return false }, nil); got != "user" {
|
||||||
|
t.Fatalf("no id = %q, want user", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := []string{"James", "Paweł", "John Doe", "???", "a", "Zoë", " ", "....", "O'Brien"}
|
||||||
|
for _, name := range names {
|
||||||
|
got := g.Generate(name, func(string) bool { return false }, &id)
|
||||||
|
if !validHandle(got) {
|
||||||
|
t.Errorf("generated %q for %q is outside the MentionParser subset", got, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validHandle(s string) bool {
|
||||||
|
if len(s) < 2 || len(s) > 255 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
152
classes/session_user.go
Normal file
152
classes/session_user.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionCookieNames are the cookies the plugin's "jwt" guard reads after
|
||||||
|
// the Authorization header, in the same order (plugin.go Boot).
|
||||||
|
var sessionCookieNames = []string{"token", "auth_token"}
|
||||||
|
|
||||||
|
// RequestUser returns the signed-in user of r for handlers that run without
|
||||||
|
// the jwt.auth middleware and must also serve anonymous requests (an OAuth
|
||||||
|
// provider's authorize page, for example). The token is read and checked
|
||||||
|
// exactly as the plugin's "jwt" guard does: the Authorization bearer header
|
||||||
|
// first, then the token and auth_token cookies; the plugin's JWT secret;
|
||||||
|
// the published bouncer.BlacklistStore when there is one; and the user's
|
||||||
|
// tokens_valid_after cutoff. A soft-deleted user is not returned.
|
||||||
|
//
|
||||||
|
// An anonymous request, or a token that is invalid, expired, blacklisted or
|
||||||
|
// names no user, returns (nil, nil). Only an infrastructure failure (no
|
||||||
|
// database handle, a failing query or blacklist lookup) returns an error.
|
||||||
|
func RequestUser(ctx context.Context, app *backpack.App, r *http.Request) (*models.User, error) {
|
||||||
|
if r == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
raw := sessionToken(r)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
secret, err := JWTSecret(app)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sub, iat, _, jti, err := bouncer.VerifyClaims(raw, secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(sub, 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
gdb, ok := app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || gdb == nil {
|
||||||
|
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||||
|
}
|
||||||
|
var user models.User
|
||||||
|
err = gdb.WithContext(ctx).Where("id = ?", uint(id)).Take(&user).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if bl, ok := app.Lookup[bouncer.BlacklistStore](); ok && bl != nil {
|
||||||
|
blocked, err := bl.IsBlacklisted(ctx, jti)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if blocked {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user.TokensValidAfter != nil && iat.Before(*user.TokensValidAfter) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndRequestSession signs the request's user out the way Logout does and
|
||||||
|
// forgets the token cookie. When r carries a valid session JWT its jti is
|
||||||
|
// added to the published bouncer.BlacklistStore until the token expires;
|
||||||
|
// the response always gets the forget cookie of
|
||||||
|
// bouncer.LoadCookieConfig(app.Config), so its Domain and Path match the
|
||||||
|
// cookie the login wrote. It is best effort, as in the PHP provider: a
|
||||||
|
// blacklist failure is logged and never returned, and the function writes
|
||||||
|
// no status or body.
|
||||||
|
func EndRequestSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if r != nil {
|
||||||
|
if raw := sessionToken(r); raw != "" {
|
||||||
|
revokeSessionToken(ctx, app, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg := bouncer.CookieConfig{Name: bouncer.DefaultCookieName, Path: "/", SameSite: http.SameSiteLaxMode}
|
||||||
|
if app != nil && app.Config != nil {
|
||||||
|
loaded, err := bouncer.LoadCookieConfig(app.Config)
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().Warn("golem15.user: end session: cookie config", "err", err)
|
||||||
|
} else {
|
||||||
|
cfg = loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.SetCookie(w, cfg.ForgetTokenCookie())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeSessionToken blacklists raw's jti until its expiry (best effort).
|
||||||
|
func revokeSessionToken(ctx context.Context, app *backpack.App, raw string) {
|
||||||
|
if app == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secret, err := JWTSecret(app)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _, exp, jti, err := bouncer.VerifyClaims(raw, secret)
|
||||||
|
if err != nil || jti == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bl, ok := app.Lookup[bouncer.BlacklistStore]()
|
||||||
|
if !ok || bl == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := bl.Add(ctx, jti, exp, time.Now()); err != nil {
|
||||||
|
slog.Default().Warn("golem15.user: end session: blacklist token", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionToken extracts the session JWT as the "jwt" guard does: the
|
||||||
|
// Authorization bearer header, then each session cookie in order.
|
||||||
|
func sessionToken(r *http.Request) string {
|
||||||
|
h := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||||
|
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||||
|
if token = strings.TrimSpace(token); token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range sessionCookieNames {
|
||||||
|
c, err := r.Cookie(name)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Value); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -16,16 +16,14 @@ import (
|
|||||||
|
|
||||||
// APITokenCommands are the application-wide token create/list/revoke
|
// APITokenCommands are the application-wide token create/list/revoke
|
||||||
// commands. Domain plugins deliberately do not mint their own credentials.
|
// commands. Domain plugins deliberately do not mint their own credentials.
|
||||||
func APITokenCommands(app *backpack.App) []bonfire.Command {
|
// withDB is the plugin's database accessor so the commands work both during
|
||||||
withDB := func(ctx context.Context, fn func(*gorm.DB) error) error {
|
// serve and as one-shot CLI runs that open Postgres from config.
|
||||||
if app == nil {
|
func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm.DB) error) error) []bonfire.Command {
|
||||||
return fmt.Errorf("golem15.user: app is nil")
|
open := func(ctx context.Context, fn func(*gorm.DB) error) error {
|
||||||
}
|
if withDB == nil {
|
||||||
db, ok := app.Lookup[*gorm.DB]()
|
|
||||||
if !ok || db == nil {
|
|
||||||
return fmt.Errorf("golem15.user: database is not configured")
|
return fmt.Errorf("golem15.user: database is not configured")
|
||||||
}
|
}
|
||||||
return fn(db.WithContext(ctx))
|
return withDB(ctx, fn)
|
||||||
}
|
}
|
||||||
return []bonfire.Command{
|
return []bonfire.Command{
|
||||||
{
|
{
|
||||||
@@ -54,7 +52,7 @@ func APITokenCommands(app *backpack.App) []bonfire.Command {
|
|||||||
}
|
}
|
||||||
expiresAt = &parsed
|
expiresAt = &parsed
|
||||||
}
|
}
|
||||||
return withDB(ctx, func(db *gorm.DB) error {
|
return open(ctx, func(db *gorm.DB) error {
|
||||||
user, err := classes.LookupByEmail(ctx, db, strings.TrimSpace(email))
|
user, err := classes.LookupByEmail(ctx, db, strings.TrimSpace(email))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -83,7 +81,7 @@ func APITokenCommands(app *backpack.App) []bonfire.Command {
|
|||||||
{
|
{
|
||||||
Name: "user:token:list", Description: "List API tokens",
|
Name: "user:token:list", Description: "List API tokens",
|
||||||
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
|
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
|
||||||
return withDB(ctx, func(db *gorm.DB) error {
|
return open(ctx, func(db *gorm.DB) error {
|
||||||
var tokens []models.APIToken
|
var tokens []models.APIToken
|
||||||
if err := db.Order("id").Find(&tokens).Error; err != nil {
|
if err := db.Order("id").Find(&tokens).Error; err != nil {
|
||||||
return fmt.Errorf("user:token:list: %w", err)
|
return fmt.Errorf("user:token:list: %w", err)
|
||||||
@@ -116,7 +114,7 @@ func APITokenCommands(app *backpack.App) []bonfire.Command {
|
|||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
return fmt.Errorf("user:token:revoke: %q is not a token id", raw)
|
return fmt.Errorf("user:token:revoke: %q is not a token id", raw)
|
||||||
}
|
}
|
||||||
return withDB(ctx, func(db *gorm.DB) error {
|
return open(ctx, func(db *gorm.DB) error {
|
||||||
res := db.Model(&models.APIToken{}).Where("id = ? AND revoked_at IS NULL", id).UpdateColumn("revoked_at", time.Now())
|
res := db.Model(&models.APIToken{}).Where("id = ? AND revoked_at IS NULL", id).UpdateColumn("revoked_at", time.Now())
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
return fmt.Errorf("user:token:revoke: %w", res.Error)
|
return fmt.Errorf("user:token:revoke: %w", res.Error)
|
||||||
|
|||||||
42
console/backfill_handles.go
Normal file
42
console/backfill_handles.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackfillHandles fills a unique handle on every user row that still has none.
|
||||||
|
func BackfillHandles(app *backpack.App) bonfire.Command {
|
||||||
|
return bonfire.Command{
|
||||||
|
Name: "user:backfill-handles",
|
||||||
|
Description: "Assign unique handles to users that do not have one (D-15)",
|
||||||
|
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
|
||||||
|
if app == nil {
|
||||||
|
return fmt.Errorf("user:backfill-handles: app is nil")
|
||||||
|
}
|
||||||
|
gdb, ok := app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || gdb == nil {
|
||||||
|
return fmt.Errorf("user:backfill-handles: database is not configured")
|
||||||
|
}
|
||||||
|
var users []models.User
|
||||||
|
if err := gdb.WithContext(ctx).Where("handle IS NULL OR handle = ''").Find(&users).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for i := range users {
|
||||||
|
if err := classes.AssignHandle(ctx, gdb, &users[i]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
out.Success(fmt.Sprintf("Assigned handles to %d user(s)", n))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,13 +7,12 @@ import (
|
|||||||
|
|
||||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RequirePasswordChange forces the named account to set a new password on next login.
|
// RequirePasswordChange forces the named account to set a new password on next login.
|
||||||
func RequirePasswordChange(app *backpack.App) bonfire.Command {
|
func RequirePasswordChange(withDB func(context.Context, func(*gorm.DB) error) error) bonfire.Command {
|
||||||
return bonfire.Command{
|
return bonfire.Command{
|
||||||
Name: "user:require-password-change",
|
Name: "user:require-password-change",
|
||||||
Description: "Force a user to change their password on next login (D-21)",
|
Description: "Force a user to change their password on next login (D-21)",
|
||||||
@@ -28,29 +27,27 @@ func RequirePasswordChange(app *backpack.App) bonfire.Command {
|
|||||||
if !ok || email == "" {
|
if !ok || email == "" {
|
||||||
return fmt.Errorf("email is required")
|
return fmt.Errorf("email is required")
|
||||||
}
|
}
|
||||||
if app == nil {
|
if withDB == nil {
|
||||||
return fmt.Errorf("user:require-password-change: app is nil")
|
|
||||||
}
|
|
||||||
gdb, ok := app.Lookup[*gorm.DB]()
|
|
||||||
if !ok || gdb == nil {
|
|
||||||
return fmt.Errorf("user:require-password-change: database is not configured")
|
return fmt.Errorf("user:require-password-change: database is not configured")
|
||||||
}
|
}
|
||||||
user, err := classes.LookupByEmail(ctx, gdb, email)
|
return withDB(ctx, func(gdb *gorm.DB) error {
|
||||||
if err != nil {
|
user, err := classes.LookupByEmail(ctx, gdb, email)
|
||||||
return err
|
if err != nil {
|
||||||
}
|
return err
|
||||||
if user == nil {
|
}
|
||||||
return fmt.Errorf("user %s not found", email)
|
if user == nil {
|
||||||
}
|
return fmt.Errorf("user %s not found", email)
|
||||||
res := gdb.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).Update("must_change_password", true)
|
}
|
||||||
if res.Error != nil {
|
res := gdb.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).Update("must_change_password", true)
|
||||||
return res.Error
|
if res.Error != nil {
|
||||||
}
|
return res.Error
|
||||||
if res.RowsAffected != 1 {
|
}
|
||||||
return fmt.Errorf("user %s was not updated", email)
|
if res.RowsAffected != 1 {
|
||||||
}
|
return fmt.Errorf("user %s was not updated", email)
|
||||||
out.Success("Password change required for " + user.Email)
|
}
|
||||||
return nil
|
out.Success("Password change required for " + user.Email)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRequirePasswordChange(t *testing.T) {
|
func TestRequirePasswordChange(t *testing.T) {
|
||||||
@@ -16,17 +18,7 @@ func TestRequirePasswordChange(t *testing.T) {
|
|||||||
user := insertUser(t, gdb, "alice@example.com", "correct-horse")
|
user := insertUser(t, gdb, "alice@example.com", "correct-horse")
|
||||||
p := &Plugin{app: app}
|
p := &Plugin{app: app}
|
||||||
var _ pact.HasCommands = p
|
var _ pact.HasCommands = p
|
||||||
cmds := p.Commands()
|
cmd := commandNamed(t, p.Commands(), "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
|
var buf bytes.Buffer
|
||||||
out := bonfire.NewOutput(nil, &buf, &buf)
|
out := bonfire.NewOutput(nil, &buf, &buf)
|
||||||
if err := cmd.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 {
|
||||||
@@ -47,6 +39,55 @@ func TestRequirePasswordChange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPITokenCreateCommand(t *testing.T) {
|
||||||
|
app, gdb := sessionApp(t, "0s")
|
||||||
|
user := insertUser(t, gdb, "pat@example.com", "correct-horse")
|
||||||
|
cmd := commandNamed(t, (&Plugin{app: app}).Commands(), "user:token:create")
|
||||||
|
var buf bytes.Buffer
|
||||||
|
out := bonfire.NewOutput(nil, &buf, &buf)
|
||||||
|
if err := cmd.Run(context.Background(), tokenCreateArg{email: user.Email, name: "rome scripts", scopes: "read,write"}, out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "usr_") {
|
||||||
|
t.Fatalf("secret missing from output %q", buf.String())
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
if err := gdb.Model(&models.APIToken{}).Where("user_id = ? AND revoked_at IS NULL", user.ID).Count(&n).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("tokens = %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleWithDBOpensFromConfigWhenUnpublished(t *testing.T) {
|
||||||
|
p := &Plugin{app: backpack.New(sessionConfig(t))}
|
||||||
|
err := p.withDB(context.Background(), func(*gorm.DB) error {
|
||||||
|
t.Fatal("fn should not run without a DSN")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected OpenFromApp to fail")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "database is not configured") {
|
||||||
|
t.Fatalf("still the Lookup-only error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "database.dsn is empty") {
|
||||||
|
t.Fatalf("got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandNamed(t *testing.T, cmds []bonfire.Command, name string) bonfire.Command {
|
||||||
|
t.Helper()
|
||||||
|
for _, candidate := range cmds {
|
||||||
|
if candidate.Name == name {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("commands = %#v", cmds)
|
||||||
|
return bonfire.Command{}
|
||||||
|
}
|
||||||
|
|
||||||
type emailArg struct{ email string }
|
type emailArg struct{ email string }
|
||||||
|
|
||||||
func (e emailArg) Args() []string { return []string{e.email} }
|
func (e emailArg) Args() []string { return []string{e.email} }
|
||||||
@@ -61,3 +102,27 @@ func (e emailArg) Argument(name string) (string, bool) {
|
|||||||
func (e emailArg) Flag(string) (string, bool) { return "", false }
|
func (e emailArg) Flag(string) (string, bool) { return "", false }
|
||||||
|
|
||||||
func (e emailArg) Flags(string) []string { return nil }
|
func (e emailArg) Flags(string) []string { return nil }
|
||||||
|
|
||||||
|
type tokenCreateArg struct{ email, name, scopes string }
|
||||||
|
|
||||||
|
func (a tokenCreateArg) Args() []string { return []string{a.email} }
|
||||||
|
|
||||||
|
func (a tokenCreateArg) Argument(name string) (string, bool) {
|
||||||
|
if name == "email" {
|
||||||
|
return a.email, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a tokenCreateArg) Flag(name string) (string, bool) {
|
||||||
|
switch name {
|
||||||
|
case "name":
|
||||||
|
return a.name, true
|
||||||
|
case "scopes":
|
||||||
|
return a.scopes, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a tokenCreateArg) Flags(string) []string { return nil }
|
||||||
|
|||||||
@@ -649,6 +649,7 @@ func Register(app *backpack.App) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = FireRegisterEvent(r.Context(), app, user, fields)
|
_ = FireRegisterEvent(r.Context(), app, user, fields)
|
||||||
|
_ = FireCreatedEvent(r.Context(), app, user)
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
if mode == "user" {
|
if mode == "user" {
|
||||||
vars := mailVars(displayName(user), "", "")
|
vars := mailVars(displayName(user), "", "")
|
||||||
@@ -1282,17 +1283,9 @@ func applyAvatar(ctx context.Context, app *backpack.App, user *models.User, payl
|
|||||||
}
|
}
|
||||||
payload["has_avatar"] = true
|
payload["has_avatar"] = true
|
||||||
payload["avatar"] = f.DiskName
|
payload["avatar"] = f.DiskName
|
||||||
bucket, ok := app.Lookup[*blob.Bucket]()
|
if url, err := classes.AvatarURL(ctx, app, user, 128); err == nil && url != "" {
|
||||||
if !ok || bucket == nil {
|
payload["avatar_url"] = url
|
||||||
return
|
|
||||||
}
|
}
|
||||||
url, err := f.Thumb(ctx, bucket, 128, 128, "auto")
|
|
||||||
if err != nil {
|
|
||||||
slog.Default().Error("golem15.user: avatar thumb", "err", err)
|
|
||||||
payload["avatar_url"] = "/" + attach.BlobKey(f.DiskName)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payload["avatar_url"] = url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func avatarExt(mime string) (string, bool) {
|
func avatarExt(mime string) (string, bool) {
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
|
|||||||
if err := db.WithContext(ctx).Create(user).Error; err != nil {
|
if err := db.WithContext(ctx).Create(user).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
if err := classes.AssignHandle(ctx, db, user); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
return user, nil, nil
|
return user, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +100,36 @@ func FireRegisterEvent(ctx context.Context, app *backpack.App, user *models.User
|
|||||||
return app.Events.Fire(ctx, &classes.RegisterEvent{User: user, Payload: payload})
|
return app.Events.Fire(ctx, &classes.RegisterEvent{User: user, Payload: payload})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FireCreatedEvent fires classes.CreatedEvent (user.Created) for user and
|
||||||
|
// returns the listeners' error. /register ignores that error, as it does
|
||||||
|
// for FireRegisterEvent.
|
||||||
|
func FireCreatedEvent(ctx context.Context, app *backpack.App, user *models.User) error {
|
||||||
|
if app == nil || app.Events == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return app.Events.Fire(ctx, &classes.CreatedEvent{User: user})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FireDeletingEvent fires classes.DeletingEvent (user.Deleting) for user
|
||||||
|
// inside the delete transaction. FormAfterDelete returns the listeners'
|
||||||
|
// error so a failed cascade rolls the delete back.
|
||||||
|
func FireDeletingEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB) error {
|
||||||
|
if app == nil || app.Events == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return app.Events.Fire(ctx, &classes.DeletingEvent{User: user, Tx: tx})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FireDeactivatingEvent fires classes.DeactivatingEvent (user.Deactivating)
|
||||||
|
// for user inside the deactivation transaction and returns the listeners'
|
||||||
|
// error, so a failed listener rolls the deactivation back.
|
||||||
|
func FireDeactivatingEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB) error {
|
||||||
|
if app == nil || app.Events == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return app.Events.Fire(ctx, &classes.DeactivatingEvent{User: user, Tx: tx})
|
||||||
|
}
|
||||||
|
|
||||||
// IssueToken mints the session JWT for user exactly as /register and
|
// IssueToken mints the session JWT for user exactly as /register and
|
||||||
// /login do: the plugin's JWT secret, golem15.user.jwt.ttl and the request
|
// /login do: the plugin's JWT secret, golem15.user.jwt.ttl and the request
|
||||||
// URL as the issuer.
|
// URL as the issuer.
|
||||||
|
|||||||
@@ -302,23 +302,33 @@ func (c usersAdminController) storePassword(ctx context.Context, user *models.Us
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormAfterCreate sends the invitation when the send_invite checkbox was
|
// FormAfterCreate assigns a unique handle, fires user.Created
|
||||||
// set: an activation code is issued inside the create's transaction, and
|
// (CreatedEventName) inside the create transaction, and sends the
|
||||||
// exactly one golem15.user::mail.invite message with the activation link
|
// invitation when the send_invite checkbox was set: an activation code is
|
||||||
// leaves after the commit. A create that rolls back sends nothing.
|
// issued inside the same transaction, and exactly one
|
||||||
|
// golem15.user::mail.invite message with the activation link leaves after
|
||||||
|
// the commit. A create that rolls back sends nothing.
|
||||||
func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) error {
|
func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) error {
|
||||||
user, ok := model.(*models.User)
|
user, ok := model.(*models.User)
|
||||||
if !ok || user == nil {
|
if !ok || user == nil {
|
||||||
return errors.New("users admin: form model is not a user")
|
return errors.New("users admin: form model is not a user")
|
||||||
}
|
}
|
||||||
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
|
||||||
if invite, _ := values["send_invite"].(bool); !invite {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
tx, ok := cabana.TxFromContext(ctx)
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("users admin: a create needs its transaction")
|
return errors.New("users admin: a create needs its transaction")
|
||||||
}
|
}
|
||||||
|
if err := classes.AssignHandle(ctx, tx.Session(&gorm.Session{NewDB: true}), user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if app := c.application(); app != nil && app.Events != nil {
|
||||||
|
if err := app.Events.Fire(ctx, &classes.CreatedEvent{User: user, Tx: tx}); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", classes.CreatedEventName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
||||||
|
if invite, _ := values["send_invite"].(bool); !invite {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
code, err := classes.IssueActivationCode(ctx, tx.Session(&gorm.Session{NewDB: true}), user)
|
code, err := classes.IssueActivationCode(ctx, tx.Session(&gorm.Session{NewDB: true}), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -512,6 +522,9 @@ func (c usersAdminController) FormAfterDelete(ctx context.Context, model any) er
|
|||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("users admin: a delete needs its transaction")
|
return errors.New("users admin: a delete needs its transaction")
|
||||||
}
|
}
|
||||||
|
if err := FireDeletingEvent(ctx, c.application(), user, tx); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", classes.DeletingEventName, err)
|
||||||
|
}
|
||||||
if err := classes.ForceDeleteCleanup(ctx, tx, c.bucket(), user); err != nil {
|
if err := classes.ForceDeleteCleanup(ctx, tx, c.bucket(), user); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -581,13 +594,35 @@ func (c usersAdminController) AdminBulkActions() []pact.AdminBulkAction {
|
|||||||
}
|
}
|
||||||
return []pact.AdminBulkAction{
|
return []pact.AdminBulkAction{
|
||||||
bulk("activate", classes.ActivateUsers),
|
bulk("activate", classes.ActivateUsers),
|
||||||
bulk("deactivate", classes.DeactivateUsers),
|
bulk("deactivate", c.deactivateUsers),
|
||||||
bulk("restore", classes.RestoreUsers),
|
bulk("restore", classes.RestoreUsers),
|
||||||
bulk("ban", classes.BanUsers),
|
bulk("ban", classes.BanUsers),
|
||||||
bulk("unban", classes.UnbanUsers),
|
bulk("unban", classes.UnbanUsers),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deactivateUsers runs classes.DeactivateUsers unchanged and then fires
|
||||||
|
// user.Deactivating, in the action's transaction, for every selected user
|
||||||
|
// that was not deactivated before. A listener error rolls the action back.
|
||||||
|
func (c usersAdminController) deactivateUsers(ctx context.Context, tx *gorm.DB, users []*models.User) (int, error) {
|
||||||
|
pending := make([]*models.User, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
if user != nil && user.ID != 0 && !user.DeletedAt.Valid {
|
||||||
|
pending = append(pending, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed, err := classes.DeactivateUsers(ctx, tx, users)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, user := range pending {
|
||||||
|
if err := FireDeactivatingEvent(ctx, c.application(), user, tx); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
// throttleState reads whether one user is banned and whether they are
|
// throttleState reads whether one user is banned and whether they are
|
||||||
// suspended, through the write's transaction when there is one.
|
// suspended, through the write's transaction when there is one.
|
||||||
func (c usersAdminController) throttleState(ctx context.Context, userID uint) (banned, suspended bool, err error) {
|
func (c usersAdminController) throttleState(ctx context.Context, userID uint) (banned, suspended bool, err error) {
|
||||||
|
|||||||
110
deactivating_event_test.go
Normal file
110
deactivating_event_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeactivatingEvent pins user.Deactivating (D-05): the admin deactivate
|
||||||
|
// bulk action fires one event per newly deactivated user inside the
|
||||||
|
// action's transaction, already deactivated users fire none, a listener
|
||||||
|
// error rolls the whole action back, and the event name is the constant.
|
||||||
|
func TestDeactivatingEvent(t *testing.T) {
|
||||||
|
if classes.DeactivatingEventName != "user.Deactivating" {
|
||||||
|
t.Fatalf("DeactivatingEventName = %q", classes.DeactivatingEventName)
|
||||||
|
}
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
token := env.admin(t, permAccessUsers)
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var fired []uint
|
||||||
|
var failFor uint
|
||||||
|
var txSawDeactivation = map[uint]bool{}
|
||||||
|
env.app.Events.Listen[*classes.DeactivatingEvent]("test.deactivating", func(ctx context.Context, e *classes.DeactivatingEvent) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if e.User == nil || e.Tx == nil {
|
||||||
|
return errors.New("event without user or transaction")
|
||||||
|
}
|
||||||
|
fired = append(fired, e.User.ID)
|
||||||
|
// Inside the action's transaction the row is already deactivated.
|
||||||
|
var deleted int64
|
||||||
|
if err := e.Tx.Session(&gorm.Session{NewDB: true}).Unscoped().Model(&models.User{}).
|
||||||
|
Where("id = ? AND deleted_at IS NOT NULL", e.User.ID).Count(&deleted).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
txSawDeactivation[e.User.ID] = deleted == 1
|
||||||
|
if e.User.ID == failFor {
|
||||||
|
return errors.New("listener refuses")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
reset := func() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
fired, txSawDeactivation = nil, map[uint]bool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
a, b := env.seedUser(t, "deact-a", nil), env.seedUser(t, "deact-b", nil)
|
||||||
|
if rec := env.bulk(t, token, "deactivate", a, b); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("deactivate: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if len(fired) != 2 || !txSawDeactivation[a.ID] || !txSawDeactivation[b.ID] {
|
||||||
|
t.Fatalf("events %v, seen in the transaction %v", fired, txSawDeactivation)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
for _, u := range []*models.User{a, b} {
|
||||||
|
if row := env.reload(t, u.ID); row == nil || !row.DeletedAt.Valid {
|
||||||
|
t.Fatalf("user %d not deactivated", u.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already deactivated users fire nothing; a mixed selection fires only
|
||||||
|
// for the newly deactivated one.
|
||||||
|
reset()
|
||||||
|
c := env.seedUser(t, "deact-c", nil)
|
||||||
|
if rec := env.bulk(t, token, "deactivate", a, c); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("mixed deactivate: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if len(fired) != 1 || fired[0] != c.ID {
|
||||||
|
t.Fatalf("mixed selection fired %v, want only %d", fired, c.ID)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
// A listener error rolls the whole action back.
|
||||||
|
reset()
|
||||||
|
d, e := env.seedUser(t, "deact-d", nil), env.seedUser(t, "deact-e", nil)
|
||||||
|
failFor = e.ID
|
||||||
|
if rec := env.bulk(t, token, "deactivate", d, e); rec.Code < 400 {
|
||||||
|
t.Fatalf("deactivate with a refusing listener: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
failFor = 0
|
||||||
|
for _, u := range []*models.User{d, e} {
|
||||||
|
if row := env.reload(t, u.ID); row == nil || row.DeletedAt.Valid {
|
||||||
|
t.Fatalf("user %d deactivated although a listener refused", u.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other bulk actions fire nothing.
|
||||||
|
reset()
|
||||||
|
if rec := env.bulk(t, token, "restore", a); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("restore: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := env.bulk(t, token, "activate", d); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("activate: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if len(fired) != 0 {
|
||||||
|
t.Fatalf("restore and activate fired %v", fired)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@ type User struct {
|
|||||||
TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"`
|
TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"`
|
||||||
CreatedIPAddress *string `gorm:"column:created_ip_address"`
|
CreatedIPAddress *string `gorm:"column:created_ip_address"`
|
||||||
LastIPAddress *string `gorm:"column:last_ip_address"`
|
LastIPAddress *string `gorm:"column:last_ip_address"`
|
||||||
|
// Handle is the unique @mention identity (D-15). Generated on create,
|
||||||
|
// never mass-assigned, and omitted from user API payloads unless a
|
||||||
|
// golden requires it.
|
||||||
|
Handle *string `gorm:"column:handle" json:"-"`
|
||||||
// LastSeen is stamped by login and token refresh, at most once per five
|
// LastSeen is stamped by login and token refresh, at most once per five
|
||||||
// minutes (classes.TouchLastSeen). It is shown in the admin list and is
|
// minutes (classes.TouchLastSeen). It is shown in the admin list and is
|
||||||
// never part of a user API payload.
|
// never part of a user API payload.
|
||||||
|
|||||||
29
plugin.go
29
plugin.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"git.golem15.com/golem15/summercms/modules/party"
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
"git.golem15.com/golem15/summercms/modules/surf"
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
@@ -103,10 +105,13 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
trusted := surf.TrustedProxies(app.Config)
|
trusted := surf.TrustedProxies(app.Config)
|
||||||
return reg.Register(p.ID(), "api_token", &usermiddleware.APITokenGuard{
|
if err := reg.Register(p.ID(), "api_token", &usermiddleware.APITokenGuard{
|
||||||
App: app, Prefix: prefix,
|
App: app, Prefix: prefix,
|
||||||
ClientIP: func(r *http.Request) string { return surf.ClientIP(r, trusted) },
|
ClientIP: func(r *http.Request) string { return surf.ClientIP(r, trusted) },
|
||||||
})
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return classes.RegisterUserAuthorizer(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) {
|
func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) {
|
||||||
@@ -201,7 +206,25 @@ func (p *Plugin) MailLayouts() map[string]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Commands() []bonfire.Command {
|
func (p *Plugin) Commands() []bonfire.Command {
|
||||||
return append([]bonfire.Command{console.RequirePasswordChange(p.app)}, console.APITokenCommands(p.app)...)
|
return append([]bonfire.Command{console.RequirePasswordChange(p.withDB), console.BackfillHandles(p.app)}, console.APITokenCommands(p.app, p.withDB)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withDB runs fn with the application's database: the one the serve command
|
||||||
|
// published, or, when a console command runs, one opened from the config for
|
||||||
|
// the duration of fn.
|
||||||
|
func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error {
|
||||||
|
if p == nil || p.app == nil {
|
||||||
|
return fmt.Errorf("golem15.user: app is nil")
|
||||||
|
}
|
||||||
|
if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil {
|
||||||
|
return fn(gdb)
|
||||||
|
}
|
||||||
|
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
return fn(gdb)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||||
|
|||||||
297
session_user_test.go
Normal file
297
session_user_test.go
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"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/compass"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// failingBlacklist fails every call, as an unreachable blacklist table does.
|
||||||
|
type failingBlacklist struct{}
|
||||||
|
|
||||||
|
var errBlacklistDown = errors.New("blacklist down")
|
||||||
|
|
||||||
|
func (failingBlacklist) Add(context.Context, string, time.Time, time.Time) error {
|
||||||
|
return errBlacklistDown
|
||||||
|
}
|
||||||
|
func (failingBlacklist) IsBlacklisted(context.Context, string) (bool, error) {
|
||||||
|
return false, errBlacklistDown
|
||||||
|
}
|
||||||
|
func (failingBlacklist) Sweep(context.Context, time.Time) error { return errBlacklistDown }
|
||||||
|
|
||||||
|
// sessionUserApp is an application without a database: the plugin's JWT secret
|
||||||
|
// and, when cookie is set, the 19.3 cookie settings.
|
||||||
|
func sessionUserApp(t *testing.T, environ ...string) *backpack.App {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: session-user\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: append([]string{"SUMMER_ENV=development"}, environ...)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return backpack.New(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionRequest is a GET carrying the session token the way kind says:
|
||||||
|
// "bearer", "token" (cookie) or "auth_token" (cookie).
|
||||||
|
func sessionRequest(kind, token string) *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/oauth/authorize", nil)
|
||||||
|
switch kind {
|
||||||
|
case "bearer":
|
||||||
|
r.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
case "token", "auth_token":
|
||||||
|
r.AddCookie(&http.Cookie{Name: kind, Value: token})
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestUser pins classes.RequestUser (D-05): the session JWT from the
|
||||||
|
// bearer header, then the token and auth_token cookies, checked like the
|
||||||
|
// plugin's jwt guard; every rejected token is anonymous and only an
|
||||||
|
// infrastructure failure is an error.
|
||||||
|
func TestRequestUser(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
issue := func(u *models.User) string {
|
||||||
|
t.Helper()
|
||||||
|
token, err := controllers.IssueToken(env.app, u, httptest.NewRequest(http.MethodPost, "https://app.example.test/login", nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
expectUser := func(name string, r *http.Request, want *models.User) {
|
||||||
|
t.Helper()
|
||||||
|
got, err := classes.RequestUser(ctx, env.app, r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: error %v", name, err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case want == nil && got != nil:
|
||||||
|
t.Fatalf("%s: signed in user %d, want anonymous", name, got.ID)
|
||||||
|
case want != nil && (got == nil || got.ID != want.ID || got.Email != want.Email):
|
||||||
|
t.Fatalf("%s: user %+v, want %d", name, got, want.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ada, bea := env.seedUser(t, "ada", nil), env.seedUser(t, "bea", nil)
|
||||||
|
adaToken, beaToken := issue(ada), issue(bea)
|
||||||
|
|
||||||
|
expectUser("bearer", sessionRequest("bearer", adaToken), ada)
|
||||||
|
expectUser("token cookie", sessionRequest("token", adaToken), ada)
|
||||||
|
expectUser("auth_token cookie", sessionRequest("auth_token", adaToken), ada)
|
||||||
|
both := sessionRequest("bearer", adaToken)
|
||||||
|
both.AddCookie(&http.Cookie{Name: "token", Value: beaToken})
|
||||||
|
expectUser("bearer before cookie", both, ada)
|
||||||
|
cookies := sessionRequest("token", beaToken)
|
||||||
|
cookies.AddCookie(&http.Cookie{Name: "auth_token", Value: adaToken})
|
||||||
|
expectUser("token before auth_token", cookies, bea)
|
||||||
|
blankBearer := sessionRequest("token", beaToken)
|
||||||
|
blankBearer.Header.Set("Authorization", "Bearer ")
|
||||||
|
expectUser("blank bearer falls back to the cookie", blankBearer, bea)
|
||||||
|
|
||||||
|
expectUser("anonymous", httptest.NewRequest(http.MethodGet, "/", nil), nil)
|
||||||
|
if u, err := classes.RequestUser(ctx, env.app, nil); u != nil || err != nil {
|
||||||
|
t.Fatalf("nil request: %v %v", u, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := strconv.FormatUint(uint64(ada.ID), 10)
|
||||||
|
expired, _, _ := bouncer.Mint(sessionSecret, sub, "https://app.example.test", -time.Minute)
|
||||||
|
expectUser("expired", sessionRequest("bearer", expired), nil)
|
||||||
|
forged, _, _ := bouncer.Mint("another-secret-of-the-right-length-xyz1", sub, "https://app.example.test", time.Hour)
|
||||||
|
expectUser("another secret", sessionRequest("bearer", forged), nil)
|
||||||
|
expectUser("garbage", sessionRequest("bearer", "not-a-jwt"), nil)
|
||||||
|
for _, s := range []string{"abc", "0", "-1"} {
|
||||||
|
odd, _, _ := bouncer.Mint(sessionSecret, s, "https://app.example.test", time.Hour)
|
||||||
|
expectUser("sub "+s, sessionRequest("bearer", odd), nil)
|
||||||
|
}
|
||||||
|
gone, _, _ := bouncer.Mint(sessionSecret, strconv.FormatUint(uint64(bea.ID+1_000_000), 10), "https://app.example.test", time.Hour)
|
||||||
|
expectUser("unknown user", sessionRequest("bearer", gone), nil)
|
||||||
|
|
||||||
|
// Blacklisted: the jti EndRequestSession revoked signs nobody in.
|
||||||
|
carl := env.seedUser(t, "carl", nil)
|
||||||
|
carlToken := issue(carl)
|
||||||
|
expectUser("carl before sign-out", sessionRequest("bearer", carlToken), carl)
|
||||||
|
if err := classes.EndRequestSession(ctx, env.app, httptest.NewRecorder(), sessionRequest("bearer", carlToken)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expectUser("blacklisted", sessionRequest("bearer", carlToken), nil)
|
||||||
|
|
||||||
|
// tokens_valid_after after the token's iat (a password change).
|
||||||
|
dora := env.seedUser(t, "dora", nil)
|
||||||
|
doraToken := issue(dora)
|
||||||
|
cutoff := time.Now().Add(time.Minute)
|
||||||
|
if err := env.gdb.Model(&models.User{}).Where("id = ?", dora.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expectUser("tokens_valid_after", sessionRequest("bearer", doraToken), nil)
|
||||||
|
|
||||||
|
// A soft-deleted (deactivated) user.
|
||||||
|
eve := env.seedUser(t, "eve", nil)
|
||||||
|
eveToken := issue(eve)
|
||||||
|
if err := env.gdb.Model(&models.User{}).Where("id = ?", eve.ID).Update("deleted_at", time.Now()).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expectUser("soft-deleted", sessionRequest("bearer", eveToken), nil)
|
||||||
|
|
||||||
|
// Only infrastructure failures are errors.
|
||||||
|
noDB := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
||||||
|
if u, err := classes.RequestUser(ctx, noDB, sessionRequest("bearer", adaToken)); u != nil || err == nil {
|
||||||
|
t.Fatalf("no database handle: %v %v", u, err)
|
||||||
|
}
|
||||||
|
noSecret := sessionUserApp(t)
|
||||||
|
if u, err := classes.RequestUser(ctx, noSecret, sessionRequest("bearer", adaToken)); u != nil || err == nil {
|
||||||
|
t.Fatalf("no JWT secret: %v %v", u, err)
|
||||||
|
}
|
||||||
|
if u, err := classes.RequestUser(ctx, noSecret, httptest.NewRequest(http.MethodGet, "/", nil)); u != nil || err != nil {
|
||||||
|
t.Fatalf("anonymous without a secret: %v %v", u, err)
|
||||||
|
}
|
||||||
|
broken := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
||||||
|
if err := broken.Publish(env.gdb); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := broken.Publish[bouncer.BlacklistStore](failingBlacklist{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if u, err := classes.RequestUser(ctx, broken, sessionRequest("bearer", adaToken)); u != nil || !errors.Is(err, errBlacklistDown) {
|
||||||
|
t.Fatalf("failing blacklist: %v %v", u, err)
|
||||||
|
}
|
||||||
|
canceled, cancel := context.WithCancel(ctx)
|
||||||
|
cancel()
|
||||||
|
if u, err := classes.RequestUser(canceled, env.app, sessionRequest("bearer", adaToken)); u != nil || err == nil {
|
||||||
|
t.Fatalf("failing query: %v %v", u, err)
|
||||||
|
}
|
||||||
|
// Without a published blacklist the token is accepted on its own checks.
|
||||||
|
plain := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
||||||
|
if err := plain.Publish[*gorm.DB](env.gdb); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if u, err := classes.RequestUser(ctx, plain, sessionRequest("bearer", adaToken)); err != nil || u == nil || u.ID != ada.ID {
|
||||||
|
t.Fatalf("without a blacklist: %v %v", u, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEndRequestSession pins classes.EndRequestSession (D-04, D-05) without
|
||||||
|
// a database: the session's jti is blacklisted until the token expires, the
|
||||||
|
// forget cookie carries the configured Domain and Path, a request without a
|
||||||
|
// token still forgets the cookie, and a blacklist failure never fails the
|
||||||
|
// call.
|
||||||
|
func TestEndRequestSession(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
app := sessionUserApp(t,
|
||||||
|
"SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret,
|
||||||
|
"SUMMER_AUTH__COOKIE__DOMAIN=app.example.test",
|
||||||
|
"SUMMER_AUTH__COOKIE__PATH=/app",
|
||||||
|
)
|
||||||
|
bl := bouncer.NewMemoryBlacklist()
|
||||||
|
if err := app.Publish[bouncer.BlacklistStore](bl); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forgot := func(t *testing.T, rec *httptest.ResponseRecorder, domain, path string) {
|
||||||
|
t.Helper()
|
||||||
|
res := rec.Result()
|
||||||
|
var found *http.Cookie
|
||||||
|
for _, c := range res.Cookies() {
|
||||||
|
if c.Name == bouncer.DefaultCookieName {
|
||||||
|
found = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil || found.Value != "" || found.MaxAge >= 0 || found.Domain != domain || found.Path != path {
|
||||||
|
t.Fatalf("forget cookie = %+v (Set-Cookie %q)", found, res.Header.Values("Set-Cookie"))
|
||||||
|
}
|
||||||
|
if rec.Body.Len() != 0 {
|
||||||
|
t.Fatalf("EndRequestSession wrote a body: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, kind := range []string{"bearer", "token", "auth_token"} {
|
||||||
|
token, jti, err := bouncer.Mint(sessionSecret, "7", "https://app.example.test", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if err := classes.EndRequestSession(ctx, app, rec, sessionRequest(kind, token)); err != nil {
|
||||||
|
t.Fatalf("%s: %v", kind, err)
|
||||||
|
}
|
||||||
|
forgot(t, rec, "app.example.test", "/app")
|
||||||
|
if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked {
|
||||||
|
t.Fatalf("%s: the jti is not blacklisted", kind)
|
||||||
|
}
|
||||||
|
// Until the token's expiry, and no longer.
|
||||||
|
_ = bl.Sweep(ctx, time.Now())
|
||||||
|
if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked {
|
||||||
|
t.Fatalf("%s: the entry left before the token expired", kind)
|
||||||
|
}
|
||||||
|
_ = bl.Sweep(ctx, time.Now().Add(time.Hour+time.Minute))
|
||||||
|
if blocked, _ := bl.IsBlacklisted(ctx, jti); blocked {
|
||||||
|
t.Fatalf("%s: the entry outlived the token", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No token, a token of another secret, garbage: the cookie is forgotten
|
||||||
|
// and nothing is blacklisted.
|
||||||
|
foreign, foreignJTI, _ := bouncer.Mint("another-secret-of-the-right-length-xyz1", "7", "https://app.example.test", time.Hour)
|
||||||
|
for name, r := range map[string]*http.Request{
|
||||||
|
"no token": httptest.NewRequest(http.MethodPost, "/", nil),
|
||||||
|
"another secret": sessionRequest("bearer", foreign),
|
||||||
|
"garbage": sessionRequest("token", "not-a-jwt"),
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if err := classes.EndRequestSession(ctx, app, rec, r); err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
forgot(t, rec, "app.example.test", "/app")
|
||||||
|
}
|
||||||
|
if blocked, _ := bl.IsBlacklisted(ctx, foreignJTI); blocked {
|
||||||
|
t.Fatal("a token of another secret was blacklisted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blacklist failure is logged, never returned; the cookie still goes.
|
||||||
|
down := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
||||||
|
if err := down.Publish[bouncer.BlacklistStore](failingBlacklist{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token, _, _ := bouncer.Mint(sessionSecret, "7", "https://app.example.test", time.Hour)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if err := classes.EndRequestSession(ctx, down, rec, sessionRequest("bearer", token)); err != nil {
|
||||||
|
t.Fatalf("failing blacklist: %v", err)
|
||||||
|
}
|
||||||
|
forgot(t, rec, "", "/")
|
||||||
|
|
||||||
|
// Without an app or a writer it does what it can.
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
if err := classes.EndRequestSession(ctx, nil, rec, sessionRequest("bearer", token)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forgot(t, rec, "", "/")
|
||||||
|
if err := classes.EndRequestSession(ctx, app, nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// An invalid cookie configuration falls back to the defaults.
|
||||||
|
badCookie := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret, "SUMMER_AUTH__COOKIE__PATH=relative")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
if err := classes.EndRequestSession(ctx, badCookie, rec, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forgot(t, rec, "", "/")
|
||||||
|
if !strings.Contains(strings.Join(rec.Result().Header.Values("Set-Cookie"), ";"), "token=") {
|
||||||
|
t.Fatal("no Set-Cookie for the token cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
30
updates/202610160002_add_handle_to_users.go
Normal file
30
updates/202610160002_add_handle_to_users.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addHandleToUsers adds users.handle, the unique @mention identity (D-15),
|
||||||
|
// only when it is missing: an application whose users table already has the
|
||||||
|
// column (added on PHP by chatchannels) keeps it and its data. The rollback
|
||||||
|
// is a no-op for the same reason: the column may predate this plugin.
|
||||||
|
var addHandleToUsers = []*gormigrate.Migration{
|
||||||
|
{
|
||||||
|
ID: "202610160002_add_handle_to_users",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS handle VARCHAR(255) NULL`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS users_handle_unique ON users (handle)`).Error
|
||||||
|
},
|
||||||
|
Rollback: func(*gorm.DB) error {
|
||||||
|
// No-op: the shared users column may predate the plugin.
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(addHandleToUsers...)
|
||||||
|
}
|
||||||
@@ -49,6 +49,8 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
|||||||
"202610040001_create_user_api_tokens",
|
"202610040001_create_user_api_tokens",
|
||||||
"202610040002_add_users_last_seen",
|
"202610040002_add_users_last_seen",
|
||||||
"202610040003_create_frontend_permissions",
|
"202610040003_create_frontend_permissions",
|
||||||
|
"202610050001_create_oauth_identities",
|
||||||
|
"202610160002_add_handle_to_users",
|
||||||
}
|
}
|
||||||
if len(ids) != len(want) {
|
if len(ids) != len(want) {
|
||||||
t.Fatalf("history = %v", ids)
|
t.Fatalf("history = %v", ids)
|
||||||
@@ -59,9 +61,9 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The three admin migrations, API tokens, user groups, jwt blacklist,
|
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
|
||||||
// throttle, extend-users, then organisations.
|
// throttle, extend-users, then organisations.
|
||||||
for range 9 {
|
for range 11 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ func TestUserGroupsMigration(t *testing.T) {
|
|||||||
t.Fatalf("users_groups primary key = %q", pk)
|
t.Fatalf("users_groups primary key = %q", pk)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frontend permissions, last seen, API tokens, user permissions, then user groups.
|
// Handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
|
||||||
for range 5 {
|
for range 7 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ func TestExtendUsersMigration(t *testing.T) {
|
|||||||
if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded {
|
if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded {
|
||||||
t.Fatalf("defaults = %+v", row)
|
t.Fatalf("defaults = %+v", row)
|
||||||
}
|
}
|
||||||
// The three admin migrations, API tokens, user groups, jwt blacklist,
|
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
|
||||||
// throttle, then extend-users.
|
// throttle, then extend-users.
|
||||||
for range 8 {
|
for range 10 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -88,8 +88,8 @@ func TestCreateUserThrottleMigration(t *testing.T) {
|
|||||||
t.Fatalf("missing column %s", col)
|
t.Fatalf("missing column %s", col)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
|
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
|
||||||
for range 7 {
|
for range 9 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -120,8 +120,8 @@ func TestCreateJwtBlacklistMigration(t *testing.T) {
|
|||||||
t.Fatalf("missing column %s", col)
|
t.Fatalf("missing column %s", col)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The three admin migrations, API tokens, user groups, then jwt blacklist.
|
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
|
||||||
for range 6 {
|
for range 8 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user