7 Commits

Author SHA1 Message Date
Jakub Zych
930b1587a0 chore: require SummerCMS v0.4.3
The organisation role cleanup uses cabana's ClearOnChange, released in v0.4.3.
2026-10-22 10:42:22 +02:00
Jakub Zych
358ee5d308 fix: logout ends cookie-carried sessions, clears the session cookies and keeps the token revoked for its refresh window
POST /logout read the token from the Authorization header only, while the
jwt guard also accepts the token and auth_token cookies. A session carried
by a cookie (golem15.sociallogin's token_cookie sets an httpOnly one that
the browser cannot turn into a bearer header) was answered 401, nothing was
revoked and the cookie kept authenticating jwt.auth routes until the access
token expired (golem15.user.jwt.ttl, 60 minutes). Logout never cleared a
cookie either, and it and EndRequestSession kept the jwt_blacklist row only
until the access token's exp, so once the sweep removed the row POST
/refresh revived the logged-out token for the rest of its refresh window.

classes.EndSession, which Logout now runs, revokes every session token the
request carries (the bearer header, the token and auth_token cookies) that
verifies, an expired one too while it could still be refreshed, until the
later of exp and iat plus refresh_ttl, plus a minute, as bouncer.Refresh
and the admin logout do. The answer, a 401 included, expires the
auth.cookie token cookie and any other session cookie the request carries,
with that cookie's path, domain, SameSite and Secure. EndRequestSession is
its best-effort form. Response bodies are unchanged; an expired but still
refreshable token now logs out with 200 instead of 401.
2026-10-22 04:51:36 +02:00
Jakub Zych
aa8396916e fix: clear a stale organisation_role when a user's organisation changes in the admin
The members relation manager and the organisation field of the Users form
declare cabana ClearOnChange on organisation_role: adding or removing a
member, and a form save that moves a user to another organisation or to
none, clear the role unless the same save writes it. Needs a SummerCMS
release with ClearOnChange.
2026-10-22 03:33:12 +02:00
Jakub Zych
c3712dc0d4 fix: deactivate and ban revoke sessions and API tokens, and /refresh refuses deactivated, banned and cut-off subjects
classes.RevokeUserAccess stamps users.tokens_valid_after (whole seconds) and
revokes the users' personal API tokens; DeactivateUsers and BanUsers call
it for every selected user. Restore and unban revive nothing, and a
deactivated user who signs in again gets a fresh working token.

POST /refresh now loads the token's subject after bouncer.Refresh and
refuses a missing, deactivated or banned user and a token issued before
tokens_valid_after with the existing 401 body; a refused refresh stamps no
last_seen. Suspension is deliberately not checked.
2026-10-22 03:11:32 +02:00
Jakub Zych
e0b5ffe20d fix: log /register listener failures instead of discarding them
A failing user.Register or user.Created listener is logged at Error level
with the user ID; the committed registration still succeeds.
2026-10-22 03:07:22 +02:00
Jakub Zych
b6b7ac7534 fix: registration fills only an explicit allow-list and never takes the IP columns from the body
User.RegisterFillable lists what a registration body may set: name,
surname, email, password, preferred_locale and marketing_consent.
RegisterUser fills from it instead of Fillable, so organisation_id,
organisation_role, is_onboarded and must_change_password can no longer be
mass-assigned through POST /register, and created_ip_address and
last_ip_address come only from RegisterOptions.IP. Fillable is unchanged
for the admin form and trusted application code.
2026-10-22 03:03:18 +02:00
Jakub Zych
499b6dc408 test: migration rollback counts include the email_verified_at migration
The rollback tests of user groups, extend-users, throttle and jwt blacklist
stopped one migration short since 202610210001 was added.
2026-10-22 03:02:08 +02:00
19 changed files with 1152 additions and 144 deletions

View File

@@ -16,27 +16,28 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
## Features ## Features
- **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. - **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. A `/register` body sets only the `User.RegisterFillable` columns: `name`, `surname`, `email`, `password` (stored as its hash), `preferred_locale` and `marketing_consent`. It cannot set `organisation_id` or `organisation_role` (membership is the administrator's or the application's), `is_onboarded`, `must_change_password`, or the activation, reset, address proof, handle and permission columns; other keys are ignored (they still reach `RegisterEvent.Payload`). `created_ip_address` and `last_ip_address` are server-set from the client address only.
- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. Logged-out tokens go to the Postgres `jwt_blacklist`, which a background sweep prunes. - **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. A background sweep prunes the blacklist. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh.
- **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password. - **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password.
- **Must-change-password lock.** The `user:require-password-change` console command flags an account. The flag rides on the authenticated principal (`bouncer.Principal.MustChangePassword`) for guards and other plugins to act on, and a successful `POST /change-password` clears it. - **Must-change-password lock.** The `user:require-password-change` console command flags an account. The flag rides on the authenticated principal (`bouncer.Principal.MustChangePassword`) for guards and other plugins to act on, and a successful `POST /change-password` clears it.
- **Login throttling.** Failed logins are counted per user and IP in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. - **Login throttling.** Failed logins are counted per user and IP in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time.
- **Rate limiting.** Every route runs behind the `user-api` bucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls. - **Rate limiting.** Every route runs behind the `user-api` bucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls.
- **Profile.** `POST /update`, `POST /marketing-consent`, `POST /avatar` and `POST /avatar/remove` (the avatar is stored through the framework's attachment storage). - **Profile.** `POST /update`, `POST /marketing-consent`, `POST /avatar` and `POST /avatar/remove` (the avatar is stored through the framework's attachment storage).
- **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins. An organisation has a name, a slug (filled from the name by `Slugify` when it is left empty), a description, an avatar and its members: the users whose `organisation_id` points at it. - **Deactivation and bans.** Deactivation is self-service and reversible: a password login or a `golem15.sociallogin` sign-in restores a deactivated account and sends the reactivate mail, and an administrator can restore it too. It ends every earlier session JWT and personal API token of the user (`RevokeUserAccess` stamps `tokens_valid_after` and revokes the `user_api_tokens` rows), so the user signs in again and gets a fresh token, while the old API tokens stay revoked. A ban is the lock: password login, social sign-in and `POST /refresh` refuse a banned user, and banning revokes the user's sessions and API tokens. Neither unban nor restore revives a token. Upgrade note: accounts deactivated or banned before this version keep their API tokens until they are deactivated or banned again (both actions are idempotent) or `RevokeUserAccess` runs for them.
- **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins. An organisation has a name, a slug (filled from the name by `Slugify` when it is left empty), a description, an avatar and its members: the users whose `organisation_id` points at it. `organisation_role` belongs to that membership: the admin clears it whenever a user's organisation changes (see the Organisations screen), and an application that sets `organisation_id` itself sets or clears the role with it.
- **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No user API route reads or writes groups, and the user payload still answers `"groups":[]`. Membership is written in one place only: the `groups` field of the admin user form. Apart from that, rows leave `users_groups` only when a user or a group is deleted for good. The admin Users list can filter by group. - **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No user API route reads or writes groups, and the user payload still answers `"groups":[]`. Membership is written in one place only: the `groups` field of the admin user form. Apart from that, rows leave `users_groups` only when a user or a group is deleted for good. The admin Users list can filter by group.
- **Admin screens.** The plugin registers the Users screen with the framework's admin (`pact.HasAdminControllers`, controller ID `golem15.user.users`), the port of the WinterCMS `Users` controller: a list with search, the group, registration date and activated filters and row states (deactivated, banned, not activated); a read-only preview with a status hint; and a form with name, email, a password with confirmation, an optional invitation mail, the user's groups, the organisation, the avatar and the user's frontend permissions. Bulk actions activate, deactivate, restore, ban and unban users; the preview offers activate, unban and unsuspend while they apply. Deactivating is the soft delete; the delete button and the bulk delete remove a user for good, together with the throttle rows, group memberships and attachments. A user created in the admin starts not activated. There is no impersonation and no guest account. - **Admin screens.** The plugin registers the Users screen with the framework's admin (`pact.HasAdminControllers`, controller ID `golem15.user.users`), the port of the WinterCMS `Users` controller: a list with search, the group, registration date and activated filters and row states (deactivated, banned, not activated); a read-only preview with a status hint; and a form with name, email, a password with confirmation, an optional invitation mail, the user's groups, the organisation, the avatar and the user's frontend permissions. Bulk actions activate, deactivate, restore, ban and unban users; the preview offers activate, unban and unsuspend while they apply. Deactivating is the soft delete, and deactivate and ban both end the user's earlier sessions and personal API tokens (see Deactivation and bans); the delete button and the bulk delete remove a user for good, together with the throttle rows, group memberships and attachments. A user created in the admin starts not activated. There is no impersonation and no guest account.
- **User Groups screen.** Controller ID `golem15.user.usergroups`, the port of the WinterCMS `UserGroups` controller, behind `golem15.users.access_groups`: a list with the name, code, creation date and number of members, and a form with the name, the code (letters, digits, underscore and hyphen; unique), a description and the group's frontend permissions as checkboxes, stored in `user_groups.permissions` as a JSON object of code to `1`. A group can be deleted from its form; its memberships go with it and its members stay. The screen never changes who is in a group. - **User Groups screen.** Controller ID `golem15.user.usergroups`, the port of the WinterCMS `UserGroups` controller, behind `golem15.users.access_groups`: a list with the name, code, creation date and number of members, and a form with the name, the code (letters, digits, underscore and hyphen; unique), a description and the group's frontend permissions as checkboxes, stored in `user_groups.permissions` as a JSON object of code to `1`. A group can be deleted from its form; its memberships go with it and its members stay. The screen never changes who is in a group.
- **Organisations screen.** Controller ID `golem15.user.organisations`, the port of the WinterCMS `Organisations` controller, behind `golem15.users.access_users`: a list and a form with the name, slug, description and avatar, and on an existing organisation the Members tab. Adding a member sets that user's `organisation_id` and removing one clears it; a user is never deleted. Only users without an organisation can be added, so a user moves by being removed from one organisation and added to the other (or through the organisation field of the user form). Deleting an organisation clears `organisation_id` and `organisation_role` of its members first. - **Organisations screen.** Controller ID `golem15.user.organisations`, the port of the WinterCMS `Organisations` controller, behind `golem15.users.access_users`: a list and a form with the name, slug, description and avatar, and on an existing organisation the Members tab. Adding a member sets that user's `organisation_id` and removing one clears it; both also clear the user's `organisation_role`, so a role never moves to another organisation or outlives the membership. A user is never deleted. Only users without an organisation can be added, so a user moves by being removed from one organisation and added to the other (or through the organisation field of the user form, where a save that changes the organisation, to another one or to none, clears `organisation_role` unless the same save writes it). Deleting an organisation clears `organisation_id` and `organisation_role` of its members first.
- **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 a successful `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.
- **Address proof.** `users.email_verified_at` records when the account's owner proved control of the address (`User.EmailVerifiedAt`). Exactly two flows write it: the mailed activation code (`POST /activate`, `POST /activate-by-code` and the invitation link, through `VerifyActivationCode`) and a completed reset-code password reset (`POST /reset-password`). `POST /register` in `auto` or `admin` mode, `RegisterUser`, an administrator's create and the admin Activate action, `POST /change-password` and social sign-ins record none. The proof follows the address: an email change through `POST /update` or the admin Users form clears it, while a change of letter case only keeps it. The column is never mass-assigned and is in no user API payload, admin form or list. No migration or code path backfills it: accounts from before v0.1.5 have no proof. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not. A social-login plugin links a verified provider's identity onto an existing account only when it is set. - **Address proof.** `users.email_verified_at` records when the account's owner proved control of the address (`User.EmailVerifiedAt`). Exactly two flows write it: the mailed activation code (`POST /activate`, `POST /activate-by-code` and the invitation link, through `VerifyActivationCode`) and a completed reset-code password reset (`POST /reset-password`). `POST /register` in `auto` or `admin` mode, `RegisterUser`, an administrator's create and the admin Activate action, `POST /change-password` and social sign-ins record none. The proof follows the address: an email change through `POST /update` or the admin Users form clears it, while a change of letter case only keeps it. The column is never mass-assigned and is in no user API payload, admin form or list. No migration or code path backfills it: accounts from before v0.1.5 have no proof. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not. A social-login plugin links a verified provider's identity onto an existing account only when it is set.
- **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. - **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). - **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). `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. - **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), so it is untrusted client input. `CreatedEvent` (`classes.CreatedEventName` `user.Created`) fires from API register and admin create. `/register` logs a failing `RegisterEvent` or `CreatedEvent` listener at Error level and continues: a committed registration is never undone or failed by a listener. `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 the social sign-in providers a social-login plugin has enabled, read on every request from its published `classes.OAuthProviderSource`. Without one it answers the empty list, as before. - **OAuth providers.** `GET /oauth-providers` lists the social sign-in providers a social-login plugin has enabled, read on every request from its published `classes.OAuthProviderSource`. Without one it answers the empty list, as before.
- **Sign-in helpers for social login.** `RegisterOptions.PasswordLess`, `FinishLogin`, `RegistrationSettings`, `RegistrationsFromIP`, `RefreshTTL` and `RemoteIP` let a social-login plugin create accounts and finish logins with the user plugin's own rules instead of copies. - **Sign-in helpers for social login.** `RegisterOptions.PasswordLess`, `FinishLogin`, `RegistrationSettings`, `RegistrationsFromIP`, `RefreshTTL` and `RemoteIP` let a social-login plugin create accounts and finish logins with the user plugin's own rules instead of copies.
@@ -126,7 +127,7 @@ The three screens are served by the framework's generic admin API under `<admin
| Admin controllers | `golem15.user.users`, `golem15.user.usergroups` and `golem15.user.organisations`, from `controllers.AdminControllers` | | Admin controllers | `golem15.user.users`, `golem15.user.usergroups` and `golem15.user.organisations`, from `controllers.AdminControllers` |
| Navigation | main item `user` (order 555) with the side items `users`, `usergroups` and `organisations`, each shown to an administrator who holds its permission | | Navigation | main item `user` (order 555) with the side items `users`, `usergroups` and `organisations`, each shown to an administrator who holds its permission |
| Relation manager | `members` on `golem15.user.organisations` (link and unlink) | | Relation manager | `members` on `golem15.user.organisations` (link and unlink) |
| Bulk actions (Users) | `activate`, `deactivate`, `restore`, `ban`, `unban`, and the built-in delete | | Bulk actions (Users) | `activate`, `deactivate`, `restore`, `ban`, `unban`, and the built-in delete; `deactivate` and `ban` also revoke the selected users' sessions and personal API tokens |
| Record actions (Users) | `activate`, `unban`, `unsuspend` | | Record actions (Users) | `activate`, `unban`, `unsuspend` |
Backend permissions, all on the tab `golem15.user::lang.plugin.tab`: Backend permissions, all on the tab `golem15.user::lang.plugin.tab`:
@@ -168,11 +169,11 @@ Migrations added for address proof, all additive:
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; `RegisterOptions.PasswordLess` stores `has_self_set_password=false` for an account whose random password the user never knew (a social sign-in), while the zero value keeps `true`. - `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 fills only the `User.RegisterFillable` columns and ignores every other key of `fields`: an organisation membership and role are set with an explicit update after creation, as an application's onboarding does. 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 and is the only source of `created_ip_address` and `last_ip_address` (both stay NULL without it, whatever `fields` says); `RegisterOptions.PasswordLess` stores `has_self_set_password=false` for an account whose random password the user never knew (a social sign-in), while the zero value keeps `true`.
- `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` logs it and continues). `Payload` carries every extra key the client sent, so treat it as untrusted. Fire it after the user row is committed.
- `FireCreatedEvent(ctx, app, user)` fires `CreatedEvent` (`user.Created`). `/register` ignores that error. - `FireCreatedEvent(ctx, app, user)` fires `CreatedEvent` (`user.Created`) and returns the listeners' error; `/register` logs it and continues.
- `FireDeletingEvent(ctx, app, user, tx)` fires `DeletingEvent` (`user.Deleting`) inside the delete transaction. - `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. - `FireDeactivatingEvent(ctx, app, user, tx)` fires `DeactivatingEvent` (`user.Deactivating`) inside the deactivation transaction. The admin deactivate bulk action fires it after `DeactivateUsers` (so the user's sessions and API tokens are already revoked) 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.
- `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks. - `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks.
@@ -182,13 +183,15 @@ Packages:
- `MergedPermissions(ctx, db, userID)`, `HasPermission(merged, code)` and `UserHasPermission(ctx, db, userID, code)`, the frontend permission resolver, and `FrontendPermissionOptions(ctx, db)`, the catalogue by tab and code. - `MergedPermissions(ctx, db, userID)`, `HasPermission(merged, code)` and `UserHasPermission(ctx, db, userID, code)`, the frontend permission resolver, and `FrontendPermissionOptions(ctx, db)`, the catalogue by tab and code.
- `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.
- `RevokeUserAccess(ctx, db, userIDs)` ends every session JWT issued before the current second (it stamps `users.tokens_valid_after`, deactivated rows included) and revokes every personal API token of the users. `DeactivateUsers` and `BanUsers` call it; an application may call it for its own offboarding and after writing a ban directly to `user_throttle`.
- `TouchLastSeen(ctx, db, userID)`. - `TouchLastSeen(ctx, db, userID)`.
- `OAuthProvider` (`Name`, `Label`) and `OAuthProviderSource` (`EnabledOAuthProviders(ctx)`, `KnownOAuthProviders()`), the seam a social-login plugin publishes with an explicit type argument, `app.Publish[classes.OAuthProviderSource](src)`, so the lookup finds it. `GET /oauth-providers` lists its enabled providers in order; the unlink routes accept its known names. - `OAuthProvider` (`Name`, `Label`) and `OAuthProviderSource` (`EnabledOAuthProviders(ctx)`, `KnownOAuthProviders()`), the seam a social-login plugin publishes with an explicit type argument, `app.Publish[classes.OAuthProviderSource](src)`, so the lookup finds it. `GET /oauth-providers` lists its enabled providers in order; the unlink routes accept its known names.
- `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. - `EndSession(ctx, app, w, r)` is what `POST /logout` runs: it blacklists every session JWT the request carries (bearer header, `token` and `auth_token` cookies) that verifies, an expired one too while its refresh window is open, until that window closes, writes the forget cookies described under JWT sessions, and returns the subject of the token the guard would read (`""` when there is none or it does not verify); only a failing blacklist write is an error.
- `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` is the best-effort `EndSession`: it revokes and forgets the same way (the forget cookie of `bouncer.LoadCookieConfig`), but 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. - `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`. - `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`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code.
- `updates`: the gormigrate migrations, returned by `updates.All()`. - `updates`: the gormigrate migrations, returned by `updates.All()`.
- `console`: `RequirePasswordChange`, `BackfillHandles` and `APITokenCommands`. - `console`: `RequirePasswordChange`, `BackfillHandles` and `APITokenCommands`.
@@ -233,7 +236,8 @@ Each command uses the database the serve command published or, run from the cons
## Dependencies ## Dependencies
- The SummerCMS framework v0.4.0, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `diary`, `pact`, `party`, `surf` and others). - The SummerCMS framework v0.4.3, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `diary`, `pact`, `party`, `surf` and others).
- The organisation role cleanup of the admin (members manager and user form) uses cabana's `ClearOnChange`, added in SummerCMS v0.4.3.
- gormigrate, GORM, golang-jwt and gocloud.dev, as pinned in `go.mod`. - gormigrate, GORM, golang-jwt and gocloud.dev, as pinned in `go.mod`.
- testcontainers-go for the Postgres-backed tests. - testcontainers-go for the Postgres-backed tests.
@@ -248,4 +252,12 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
`TestCommandsDocumented` (in `-short`) logs, without failing, each command whose help lacks a description, long help, examples or an option description (`bonfire.CheckDocs`). `TestCommandsDocumented` (in `-short`) logs, without failing, each command whose help lacks a description, long help, examples or an option description (`bonfire.CheckDocs`).
`TestRegisterIgnoresProtectedFields` posts organisation, IP and account-flag keys to `/register` and `RegisterUser` and checks that only the allow-listed columns and the server-side IP are stored; `TestRegisterListenerErrors` checks that failing register and created listeners do not fail the registration.
`TestOffboarding` runs the admin deactivate and ban bulk actions and checks that the user's older session JWT and personal API token stop working, that a password login restores a deactivated account with a working token while the old credentials stay dead, and that unban revives nothing; `TestRefreshSubjectChecks` covers the `/refresh` subject checks; `TestRevokeUserAccess` (in `classes`) covers the revocation writes.
`TestLogoutEndsTheSession` signs in through `POST /login` and logs out with the bearer header, with only the `token` or `auth_token` cookie, and with both a bearer and a different cookie token, then checks that the token is refused through every carrier on a `jwt.auth` route, at `/fetch` and at `/refresh`, that the forget cookies match the token cookie's attributes (also with a configured domain and SameSite), that the blacklist row lasts for the refresh window so `/refresh` stays refused after the sweep that follows the access token's expiry, that an expired but refreshable token is revoked, and which tokens a logout refuses with 401.
`TestAdminOrganisationMembershipClearsRole` checks that the members manager's unlink and link and a Users form save that changes the organisation clear `organisation_role`, that a save keeping the organisation keeps it, and that deleting an organisation clears both columns.
The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`. The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`.

View File

@@ -162,13 +162,13 @@ func TestAdminOrganisationsEdge(t *testing.T) {
} }
} }
// TestAdminOrganisationRoleOutlivesMembership pins a known gap // TestAdminOrganisationMembershipClearsRole: a role belongs to one
// (12.1-SECURITY-REVIEW.md, open item 6): removing a member through the // organisation (12.1-SECURITY-REVIEW.md open item 6, closed). Removing a
// members manager clears organisation_id and nothing else, so the user keeps // member through the members manager clears organisation_id and
// organisation_role and carries it into the next organisation they are // organisation_role, adding one clears a leftover role, and changing the
// added to. Deleting the organisation clears both. When the unlink learns to // organisation in the Users form clears the role while a save that keeps the
// clear the role, this test changes with it. // organisation keeps it. Deleting the organisation clears both.
func TestAdminOrganisationRoleOutlivesMembership(t *testing.T) { func TestAdminOrganisationMembershipClearsRole(t *testing.T) {
env := newAdminEnv(t) env := newAdminEnv(t)
token := env.admin(t, permAccessUsers) token := env.admin(t, permAccessUsers)
stamp := adminStamp() stamp := adminStamp()
@@ -182,18 +182,49 @@ func TestAdminOrganisationRoleOutlivesMembership(t *testing.T) {
env.expect(t, http.StatusOK, http.MethodPost, organisationPath(first.ID, "/relations/members/unlink"), token, ids) env.expect(t, http.StatusOK, http.MethodPost, organisationPath(first.ID, "/relations/members/unlink"), token, ids)
got := env.reload(t, member.ID) got := env.reload(t, member.ID)
if got.OrganisationID != nil { if got.OrganisationID != nil || got.OrganisationRole != nil {
t.Fatalf("organisation_id after the unlink = %d", *got.OrganisationID) t.Fatalf("after the unlink: organisation %v role %v, want both NULL", got.OrganisationID, got.OrganisationRole)
}
if got.OrganisationRole == nil || *got.OrganisationRole != "owner" {
t.Fatalf("organisation_role after the unlink = %v; the known gap is closed, update this test and the review", got.OrganisationRole)
} }
env.expect(t, http.StatusOK, http.MethodPost, organisationPath(second.ID, "/relations/members/link"), token, ids) env.expect(t, http.StatusOK, http.MethodPost, organisationPath(second.ID, "/relations/members/link"), token, ids)
got = env.reload(t, member.ID) got = env.reload(t, member.ID)
if got.OrganisationID == nil || *got.OrganisationID != second.ID || got.OrganisationRole == nil || *got.OrganisationRole != "owner" { if got.OrganisationID == nil || *got.OrganisationID != second.ID || got.OrganisationRole != nil {
t.Fatalf("after the link elsewhere: organisation %v role %v", got.OrganisationID, got.OrganisationRole) t.Fatalf("after the link elsewhere: organisation %v role %v", got.OrganisationID, got.OrganisationRole)
} }
// An organisation-less user with a leftover role loses it when added.
leftover := env.seedUser(t, "role-leftover-"+stamp, func(u *models.User) { u.OrganisationRole = &owner })
env.expect(t, http.StatusOK, http.MethodPost, organisationPath(first.ID, "/relations/members/link"), token, map[string]any{"ids": []uint{leftover.ID}})
if got := env.reload(t, leftover.ID); got.OrganisationID == nil || *got.OrganisationID != first.ID || got.OrganisationRole != nil {
t.Fatalf("linked leftover: organisation %v role %v", got.OrganisationID, got.OrganisationRole)
}
// The Users form: keeping the organisation keeps the role, changing it
// (to another organisation or to none) clears it.
formUser := env.seedUser(t, "role-form-"+stamp, func(u *models.User) { u.OrganisationID, u.OrganisationRole = &first.ID, &owner })
setRole := func(t *testing.T) {
t.Helper()
if err := env.gdb.Model(&models.User{}).Where("id = ?", formUser.ID).UpdateColumn("organisation_role", owner).Error; err != nil {
t.Fatal(err)
}
}
saveForm := func(t *testing.T, organisation any) *models.User {
t.Helper()
env.expect(t, http.StatusOK, http.MethodPut, userPath(formUser.ID, ""), token, map[string]any{
"name": "role-form", "email": formUser.Email, "organisation": organisation,
})
return env.reload(t, formUser.ID)
}
if got := saveForm(t, first.ID); got.OrganisationRole == nil || *got.OrganisationRole != owner {
t.Fatalf("a save that keeps the organisation dropped the role: %v", got.OrganisationRole)
}
if got := saveForm(t, second.ID); got.OrganisationID == nil || *got.OrganisationID != second.ID || got.OrganisationRole != nil {
t.Fatalf("after moving to another organisation: organisation %v role %v", got.OrganisationID, got.OrganisationRole)
}
setRole(t)
if got := saveForm(t, nil); got.OrganisationID != nil || got.OrganisationRole != nil {
t.Fatalf("after clearing the organisation: organisation %v role %v", got.OrganisationID, got.OrganisationRole)
}
// The delete of an organisation releases its members completely. // The delete of an organisation releases its members completely.
env.expect(t, http.StatusOK, http.MethodDelete, organisationPath(doomed.ID, ""), token, nil) env.expect(t, http.StatusOK, http.MethodDelete, organisationPath(doomed.ID, ""), token, nil)
if got := env.reload(t, other.ID); got == nil || got.OrganisationID != nil || got.OrganisationRole != nil { if got := env.reload(t, other.ID); got == nil || got.OrganisationID != nil || got.OrganisationRole != nil {

View File

@@ -475,6 +475,11 @@ func TestAdminOrganisationMembers(t *testing.T) {
if rec := mutate(there, "unlink", foreign); rec.Code != http.StatusOK { if rec := mutate(there, "unlink", foreign); rec.Code != http.StatusOK {
t.Fatalf("unlink there = %d %s", rec.Code, rec.Body.String()) t.Fatalf("unlink there = %d %s", rec.Code, rec.Body.String())
} }
// The role belonged to the organisation left: the unlink cleared it.
if got := env.reload(t, foreign.ID); got.OrganisationRole != nil {
t.Fatalf("organisation_role after leaving = %q", *got.OrganisationRole)
}
stored[foreign.ID].OrganisationRole = nil
if rec := mutate(here, "link", foreign); rec.Code != http.StatusOK || organisationOf(foreign) != here.ID { if rec := mutate(here, "link", foreign); rec.Code != http.StatusOK || organisationOf(foreign) != here.ID {
t.Fatalf("link after the release = %d %s", rec.Code, rec.Body.String()) t.Fatalf("link after the release = %d %s", rec.Code, rec.Body.String())
} }

View File

@@ -90,8 +90,41 @@ func ActivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int,
return changed, nil return changed, nil
} }
// RevokeUserAccess ends every session JWT issued before now and every
// personal API token of userIDs: it stamps users.tokens_valid_after with the
// current time truncated to whole seconds (soft-deleted rows included) and
// sets revoked_at on each of their user_api_tokens rows that is not revoked
// yet (earlier revoked_at values stay). The jwt guard, RequestUser and
// /refresh refuse a token whose iat precedes tokens_valid_after; the
// user.api_token guard refuses a revoked row. JWT iat has whole-second
// precision, so the cutoff is truncated: a token minted right after the
// revocation (a reactivating login) is valid, and so is one minted earlier
// within that same second. An empty list writes nothing. It takes the
// handle of the caller's transaction. DeactivateUsers and BanUsers call it;
// an application may call it for its own offboarding, and after writing a
// ban directly to user_throttle.
func RevokeUserAccess(ctx context.Context, db *gorm.DB, userIDs []uint) error {
if db == nil {
return fmt.Errorf("golem15.user: gorm handle is missing")
}
if len(userIDs) == 0 {
return nil
}
cutoff := time.Now().Truncate(time.Second)
if err := fresh(ctx, db).Unscoped().Model(&models.User{}).Where("id IN ?", userIDs).UpdateColumn("tokens_valid_after", cutoff).Error; err != nil {
return err
}
return fresh(ctx, db).Model(&models.APIToken{}).
Where("user_id IN ? AND revoked_at IS NULL", userIDs).
UpdateColumn("revoked_at", cutoff).Error
}
// DeactivateUsers soft-deletes the users that are not deactivated yet, the // DeactivateUsers soft-deletes the users that are not deactivated yet, the
// PHP $user->delete(), and returns how many it changed. // PHP $user->delete(), and returns how many it changed. It then ends the
// sessions and personal API tokens of every selected user, already
// deactivated ones included (RevokeUserAccess), so a deactivated account
// keeps no working credential: its owner signs in again, which restores
// it, and gets a fresh token. Restoring revives no revoked token.
func DeactivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) { func DeactivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
if db == nil { if db == nil {
return 0, fmt.Errorf("golem15.user: gorm handle is missing") return 0, fmt.Errorf("golem15.user: gorm handle is missing")
@@ -109,6 +142,13 @@ func DeactivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (in
user.DeletedAt = gorm.DeletedAt{Time: now, Valid: true} user.DeletedAt = gorm.DeletedAt{Time: now, Valid: true}
changed++ changed++
} }
ids, err := userIDs(db, users)
if err != nil {
return 0, err
}
if err := RevokeUserAccess(ctx, db, ids); err != nil {
return 0, err
}
return changed, nil return changed, nil
} }
@@ -136,7 +176,9 @@ func RestoreUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int,
// BanUsers bans the users and returns how many were not banned before. A ban // BanUsers bans the users and returns how many were not banned before. A ban
// is set on every user_throttle row of the user, and a row without an IP // is set on every user_throttle row of the user, and a row without an IP
// address is created when the user has none, so a login from any address is // address is created when the user has none, so a login from any address is
// refused (RejectIfThrottled falls back to that row). // refused (RejectIfThrottled falls back to that row). It then ends the
// sessions and personal API tokens of every selected user, already banned
// ones included (RevokeUserAccess); unbanning revives none of them.
func BanUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) { func BanUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
ids, err := userIDs(db, users) ids, err := userIDs(db, users)
if err != nil { if err != nil {
@@ -164,6 +206,9 @@ func BanUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, erro
changed++ changed++
} }
} }
if err := RevokeUserAccess(ctx, db, ids); err != nil {
return 0, err
}
return changed, nil return changed, nil
} }

View File

@@ -437,3 +437,126 @@ func TestAdminActions(t *testing.T) {
} }
}) })
} }
// TestRevokeUserAccess: the cutoff is stamped in whole seconds on active and
// deactivated users, every live API token of the users is revoked, earlier
// revocations keep their time, other users are untouched, and DeactivateUsers
// and BanUsers revoke for every selected user with unchanged counts.
func TestRevokeUserAccess(t *testing.T) {
ctx := t.Context()
db := throttleDB(t)
token := func(t *testing.T, user *models.User, revoked *time.Time) models.APIToken {
t.Helper()
row := models.APIToken{UserID: user.ID, TokenHash: "hash-" + strconv.FormatUint(uint64(user.ID), 10) + "-" + strconv.FormatInt(time.Now().UnixNano(), 10), RevokedAt: revoked}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
return row
}
reloadToken := func(t *testing.T, id uint) models.APIToken {
t.Helper()
var row models.APIToken
if err := db.Take(&row, id).Error; err != nil {
t.Fatal(err)
}
return row
}
t.Run("a missing handle is an error and an empty list writes nothing", func(t *testing.T) {
if err := RevokeUserAccess(ctx, nil, []uint{1}); err == nil {
t.Fatal("nil handle accepted")
}
if err := RevokeUserAccess(ctx, db, nil); err != nil {
t.Fatal(err)
}
})
t.Run("stamps the cutoff and revokes live tokens only", func(t *testing.T) {
active := actionUser(t, db, "revoke-active", nil)
gone := actionUser(t, db, "revoke-gone", func(u *models.User) {
u.DeletedAt = gorm.DeletedAt{Time: time.Now(), Valid: true}
})
other := actionUser(t, db, "revoke-other", nil)
earlier := time.Now().Add(-time.Hour).Truncate(time.Microsecond)
live := token(t, active, nil)
old := token(t, active, &earlier)
goneTok := token(t, gone, nil)
otherTok := token(t, other, nil)
before := time.Now().Truncate(time.Second)
if err := RevokeUserAccess(ctx, db, []uint{active.ID, gone.ID}); err != nil {
t.Fatal(err)
}
after := time.Now()
for _, id := range []uint{active.ID, gone.ID} {
row := actionReload(t, db, id)
if row.TokensValidAfter == nil || row.TokensValidAfter.Before(before) || row.TokensValidAfter.After(after) ||
!row.TokensValidAfter.Equal(row.TokensValidAfter.Truncate(time.Second)) {
t.Fatalf("user %d tokens_valid_after = %v", id, row.TokensValidAfter)
}
}
if row := actionReload(t, db, other.ID); row.TokensValidAfter != nil {
t.Fatalf("other user stamped: %v", row.TokensValidAfter)
}
for _, id := range []uint{live.ID, goneTok.ID} {
if row := reloadToken(t, id); row.RevokedAt == nil {
t.Fatalf("token %d not revoked", id)
}
}
if row := reloadToken(t, old.ID); row.RevokedAt == nil || !row.RevokedAt.Equal(earlier) {
t.Fatalf("earlier revocation changed: %v want %v", row.RevokedAt, earlier)
}
if row := reloadToken(t, otherTok.ID); row.RevokedAt != nil {
t.Fatalf("other user's token revoked")
}
})
t.Run("deactivate and ban revoke for every selected user", func(t *testing.T) {
fresh := actionUser(t, db, "revoke-deactivate", nil)
already := actionUser(t, db, "revoke-already", func(u *models.User) {
u.DeletedAt = gorm.DeletedAt{Time: time.Now(), Valid: true}
})
a, b := token(t, fresh, nil), token(t, already, nil)
changed, err := DeactivateUsers(ctx, db, []*models.User{fresh, already})
if err != nil || changed != 1 {
t.Fatalf("DeactivateUsers = %d, %v", changed, err)
}
for _, id := range []uint{a.ID, b.ID} {
if row := reloadToken(t, id); row.RevokedAt == nil {
t.Fatalf("token %d survived deactivate", id)
}
}
for _, id := range []uint{fresh.ID, already.ID} {
if row := actionReload(t, db, id); row.TokensValidAfter == nil {
t.Fatalf("user %d not stamped by deactivate", id)
}
}
ban := actionUser(t, db, "revoke-ban", nil)
bannedBefore := actionUser(t, db, "revoke-banned-before", nil)
if err := db.Create(&models.Throttle{UserID: bannedBefore.ID, IsBanned: true}).Error; err != nil {
t.Fatal(err)
}
c, d := token(t, ban, nil), token(t, bannedBefore, nil)
changed, err = BanUsers(ContextWithThrottle(ctx, nil), db, []*models.User{ban, bannedBefore})
if err != nil || changed != 1 {
t.Fatalf("BanUsers = %d, %v", changed, err)
}
for _, id := range []uint{c.ID, d.ID} {
if row := reloadToken(t, id); row.RevokedAt == nil {
t.Fatalf("token %d survived ban", id)
}
}
// Unban and restore revive nothing.
if _, err := UnbanUsers(ctx, db, []*models.User{ban}); err != nil {
t.Fatal(err)
}
if _, err := RestoreUsers(ctx, db, []*models.User{fresh}); err != nil {
t.Fatal(err)
}
for _, id := range []uint{a.ID, c.ID} {
if row := reloadToken(t, id); row.RevokedAt == nil {
t.Fatalf("token %d revived", id)
}
}
})
}

View File

@@ -59,8 +59,10 @@ type DeletingEvent struct {
// DeactivatingEvent fires after a user is soft-deleted (deactivated, the // DeactivatingEvent fires after a user is soft-deleted (deactivated, the
// PHP $user->delete()) by the admin deactivate action, inside the same // PHP $user->delete()) by the admin deactivate action, inside the same
// transaction, so listeners can revoke what a deactivated account must not // transaction, so listeners can revoke what a deactivated account must not
// keep (OAuth grants, for example) while leaving what a restore needs. A // keep (OAuth grants, for example) while leaving what a restore needs. The
// listener error rolls the deactivation back. // user's session JWTs and personal API tokens are already revoked when it
// fires (DeactivateUsers calls RevokeUserAccess). A listener error rolls the
// deactivation back.
type DeactivatingEvent struct { type DeactivatingEvent struct {
User *models.User User *models.User
Tx *gorm.DB Tx *gorm.DB

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -77,23 +78,77 @@ func RequestUser(ctx context.Context, app *backpack.App, r *http.Request) (*mode
return &user, nil return &user, nil
} }
// EndRequestSession signs the request's user out the way Logout does and // EndSession signs the request out; POST /logout runs it. Every session JWT
// forgets the token cookie. When r carries a valid session JWT its jti is // r carries (the Authorization bearer header and the token and auth_token
// added to the published bouncer.BlacklistStore until the token expires; // cookies the "jwt" guard reads) that verifies is added to the published
// the response always gets the forget cookie of // bouncer.BlacklistStore, so the guard, RequestUser and POST /refresh refuse
// bouncer.LoadCookieConfig(app.Config), so its Domain and Path match the // it from then on. A token verifies while it is unexpired, and a token with
// cookie the login wrote. It is best effort, as in the PHP provider: a // the user audience also after its access lifetime while its refresh window
// blacklist failure is logged and never returned, and the function writes // is open (it could still be refreshed). The row is kept until the later of
// no status or body. // the token's exp and its iat plus the refresh window
func EndRequestSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) error { // (golem15.user.jwt.refresh_ttl), plus one minute, as bouncer.Refresh keeps
if r != nil { // a rotated jti: a row that ended at exp would let /refresh revive the
if raw := sessionToken(r); raw != "" { // logged-out token for the rest of its window.
revokeSessionToken(ctx, app, raw) //
// When w is not nil the response gets the forget cookie of
// bouncer.LoadCookieConfig(app.Config), with the name, path, domain,
// SameSite and Secure of the token cookie a login writes, and one with the
// same attributes for each other session cookie r carries. It writes no
// status or body.
//
// It returns the subject of the token the guard would read (the bearer
// header first, then the cookies in order), or "" when r carries none or it
// does not verify. Only a failing blacklist write is an error; the cookies
// are forgotten and the other tokens revoked in any case. Without a
// published blacklist nothing is revoked.
func EndSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) (string, error) {
if w != nil {
forgetSessionCookies(app, w, r)
}
if r == nil || app == nil {
return "", nil
}
secret, err := JWTSecret(app)
if err != nil {
return "", nil
}
bl, _ := app.Lookup[bouncer.BlacklistStore]()
window := refreshWindow(app)
subject := ""
var firstErr error
for i, raw := range sessionTokens(r) {
sub, iat, exp, jti, ok := verifySessionToken(raw, secret, window)
if !ok {
continue
}
if i == 0 {
subject = sub
}
if bl == nil {
continue
}
if err := bl.Add(ctx, jti, revokedUntil(iat, exp, window), time.Now()); err != nil && firstErr == nil {
firstErr = err
} }
} }
if w == nil { return subject, firstErr
return nil }
// EndRequestSession is the best-effort EndSession, as in the PHP provider:
// it revokes the session JWTs r carries and forgets the session cookies the
// same way, logs a blacklist failure instead of returning it, and always
// returns nil. It writes no status or body.
func EndRequestSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) error {
if _, err := EndSession(ctx, app, w, r); err != nil {
pluginLog.Warn("golem15.user: end session: blacklist token", "err", err)
} }
return nil
}
// forgetSessionCookies expires the token cookie of
// bouncer.LoadCookieConfig(app.Config) and, with the same path, domain,
// SameSite and Secure, each other session cookie r carries.
func forgetSessionCookies(app *backpack.App, w http.ResponseWriter, r *http.Request) {
cfg := bouncer.CookieConfig{Name: bouncer.DefaultCookieName, Path: "/", SameSite: http.SameSiteLaxMode} cfg := bouncer.CookieConfig{Name: bouncer.DefaultCookieName, Path: "/", SameSite: http.SameSiteLaxMode}
if app != nil && app.Config != nil { if app != nil && app.Config != nil {
loaded, err := bouncer.LoadCookieConfig(app.Config) loaded, err := bouncer.LoadCookieConfig(app.Config)
@@ -104,48 +159,83 @@ func EndRequestSession(ctx context.Context, app *backpack.App, w http.ResponseWr
} }
} }
http.SetCookie(w, cfg.ForgetTokenCookie()) http.SetCookie(w, cfg.ForgetTokenCookie())
return nil if r == nil {
return
}
for _, name := range sessionCookieNames {
if name == cfg.Name {
continue
}
if _, err := r.Cookie(name); err != nil {
continue
}
other := cfg
other.Name = name
http.SetCookie(w, other.ForgetTokenCookie())
}
} }
// revokeSessionToken blacklists raw's jti until its expiry (best effort). // verifySessionToken accepts raw when it is a token the "jwt" guard accepts
func revokeSessionToken(ctx context.Context, app *backpack.App, raw string) { // or POST /refresh still rotates: a user-audience token whose refresh window
if app == nil { // is open (its exp may have passed), or an unexpired token, which also
return // covers one without an audience (issued by the PHP backend). A token
} // without a jti cannot be revoked and is refused.
secret, err := JWTSecret(app) func verifySessionToken(raw, secret string, window time.Duration) (sub string, iat, exp time.Time, jti string, ok bool) {
sub, iat, exp, jti, err := bouncer.VerifyRefreshableClaimsAudience(raw, secret, bouncer.AudienceUser, window)
if err != nil { if err != nil {
return sub, iat, exp, jti, err = bouncer.VerifyClaims(raw, secret)
} }
_, _, exp, jti, err := bouncer.VerifyClaims(raw, secret) return sub, iat, exp, jti, err == nil && jti != ""
if err != nil || jti == "" { }
return
// revokedUntil is the storage expiry of a revoked session jti: the later of
// exp and iat plus the refresh window, plus one minute (PHP jwt-auth's
// Blacklist::getMinutesUntilExpired, which bouncer.Refresh follows).
func revokedUntil(iat, exp time.Time, window time.Duration) time.Time {
until := iat.Add(window)
if exp.After(until) {
until = exp
} }
bl, ok := app.Lookup[bouncer.BlacklistStore]() return until.Add(time.Minute)
if !ok || bl == nil { }
return
} // refreshWindow is golem15.user.jwt.refresh_ttl minutes, default 20160: the
if err := bl.Add(ctx, jti, exp, time.Now()); err != nil { // window POST /refresh accepts (controllers.RefreshTTL reads the same key).
pluginLog.Warn("golem15.user: end session: blacklist token", "err", err) func refreshWindow(app *backpack.App) time.Duration {
mins := 20160
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.refresh_ttl") > 0 {
mins = app.Config.Int("golem15.user.jwt.refresh_ttl")
} }
return time.Duration(mins) * time.Minute
} }
// sessionToken extracts the session JWT as the "jwt" guard does: the // sessionToken extracts the session JWT as the "jwt" guard does: the
// Authorization bearer header, then each session cookie in order. // Authorization bearer header, then each session cookie in order.
func sessionToken(r *http.Request) string { func sessionToken(r *http.Request) string {
h := strings.TrimSpace(r.Header.Get("Authorization")) if tokens := sessionTokens(r); len(tokens) > 0 {
if token, ok := strings.CutPrefix(h, "Bearer "); ok { return tokens[0]
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 "" return ""
} }
// sessionTokens returns every distinct session JWT r carries, in the order
// the "jwt" guard tries them: the Authorization bearer header, then each
// session cookie. Empty values are skipped.
func sessionTokens(r *http.Request) []string {
var out []string
add := func(v string) {
if v = strings.TrimSpace(v); v != "" && !slices.Contains(out, v) {
out = append(out, v)
}
}
h := strings.TrimSpace(r.Header.Get("Authorization"))
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
add(token)
}
for _, name := range sessionCookieNames {
if c, err := r.Cookie(name); err == nil {
add(c.Value)
}
}
return out
}

View File

@@ -29,6 +29,7 @@ import (
"git.golem15.com/golem15/summercms/modules/postcard" "git.golem15.com/golem15/summercms/modules/postcard"
"git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/wire" "git.golem15.com/golem15/summercms/modules/wire"
"github.com/golang-jwt/jwt/v5"
"gocloud.dev/blob" "gocloud.dev/blob"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -125,17 +126,22 @@ func Login(app *backpack.App) http.HandlerFunc {
} }
} }
// Logout forever-blacklists the presented bearer token. // Logout ends the request's session through classes.EndSession: every
// session token it carries (the bearer header, or the token and auth_token
// cookies the jwt guard also reads) is blacklisted for the rest of its
// refresh window, so the next request with it is refused and /refresh
// cannot revive it, and the session cookies are expired, on a 401 too.
// It answers 200 {"message":"Logged out"} when the token the guard reads
// verifies (an expired one too while it can still be refreshed) and names a
// user, 401 otherwise, and 500 when the blacklist write fails.
func Logout(app *backpack.App) http.HandlerFunc { func Logout(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
_, secret, ok := sessionDeps(w, app) if _, _, ok := sessionDeps(w, app); !ok {
if !ok {
return return
} }
raw := bearerToken(r) sub, err := classes.EndSession(r.Context(), app, w, r)
sub, _, exp, jti, err := bouncer.VerifyClaims(raw, secret) if err != nil {
if err != nil || jti == "" { writeOpaque500(w)
writeUnauthorized(w)
return return
} }
id, err := strconv.ParseUint(sub, 10, 64) id, err := strconv.ParseUint(sub, 10, 64)
@@ -148,12 +154,6 @@ func Logout(app *backpack.App) http.HandlerFunc {
writeUnauthorized(w) writeUnauthorized(w)
return return
} }
if bl, ok := app.Lookup[bouncer.BlacklistStore](); ok && bl != nil {
if err := bl.Add(r.Context(), jti, exp, time.Now()); err != nil {
writeOpaque500(w)
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"message": "Logged out"}) writeJSON(w, http.StatusOK, map[string]any{"message": "Logged out"})
} }
} }
@@ -174,7 +174,9 @@ func Fetch(app *backpack.App) http.HandlerFunc {
} }
} }
// Refresh slides a still-refreshable bearer token. // Refresh slides a still-refreshable bearer token of a user who may still
// sign in: deactivated and banned users and tokens issued before the user's
// tokens_valid_after are refused with the token-failure 401 body.
func Refresh(app *backpack.App) http.HandlerFunc { func Refresh(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app) gdb, secret, ok := sessionDeps(w, app)
@@ -198,19 +200,85 @@ func Refresh(app *backpack.App) http.HandlerFunc {
}) })
return return
} }
// The refresh never loads the user row: the subject comes from the // bouncer.Refresh checked the token only (signature, audience,
// new token. A failed last_seen write is logged and never fails it. // refresh window, blacklist). The subject check loads the user: a
if sub, _, _, _, err := bouncer.VerifyClaims(next, secret); err == nil { // missing or deactivated user (who signs in again, which restores the
if id, err := strconv.ParseUint(sub, 10, 64); err == nil && id != 0 { // account), a token issued before tokens_valid_after, and a banned
if err := classes.TouchLastSeen(r.Context(), gdb, uint(id)); err != nil { // user are refused with the same 401 body. Suspension is not
pluginLog.Warn("golem15.user: stamp last_seen on refresh", "user_id", id, "err", err) // checked: it is the failed-password throttle, and refusing refresh
} // on it would let anyone who knows an email log its owner out.
} userID, refused, err := refreshSubjectRefused(r.Context(), app, gdb, raw)
if err != nil {
writeOpaque500(w)
return
}
if refused {
writeJSON(w, http.StatusUnauthorized, map[string]any{
"error": "Could not refresh token",
"msg": bouncer.ErrSubjectRejected.Error(),
})
return
}
// A failed last_seen write is logged and never fails the refresh.
if err := classes.TouchLastSeen(r.Context(), gdb, userID); err != nil {
pluginLog.Warn("golem15.user: stamp last_seen on refresh", "user_id", userID, "err", err)
} }
writeJSON(w, http.StatusOK, map[string]any{"token": next}) writeJSON(w, http.StatusOK, map[string]any{"token": next})
} }
} }
// refreshSubjectRefused reads the subject and iat of a token bouncer.Refresh
// has already verified and reports whether its user may not slide the
// session: not found by a scoped load (missing or deactivated), issued
// before the user's tokens_valid_after, or banned. Only a database failure
// is an error.
func refreshSubjectRefused(ctx context.Context, app *backpack.App, gdb *gorm.DB, raw string) (uint, bool, error) {
claims := jwt.MapClaims{}
if _, _, err := jwt.NewParser().ParseUnverified(raw, claims); err != nil {
return 0, true, nil
}
var id uint64
switch v := claims["sub"].(type) {
case string:
n, err := strconv.ParseUint(strings.TrimSpace(v), 10, 64)
if err != nil {
return 0, true, nil
}
id = n
case float64:
if v <= 0 || v != float64(uint64(v)) {
return 0, true, nil
}
id = uint64(v)
}
if id == 0 {
return 0, true, nil
}
iat, err := claims.GetIssuedAt()
if err != nil || iat == nil {
return 0, true, nil
}
var user models.User
result := gdb.WithContext(ctx).Where("id = ?", id).Limit(1).Find(&user)
if result.Error != nil {
return 0, false, result.Error
}
if result.RowsAffected == 0 {
return 0, true, nil
}
if user.TokensValidAfter != nil && iat.Time.Before(*user.TokensValidAfter) {
return 0, true, nil
}
banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, app), gdb.WithContext(ctx), []uint{user.ID})
if err != nil {
return 0, false, err
}
if banned[user.ID] {
return 0, true, nil
}
return user.ID, false, nil
}
// OAuthProviders lists the social sign-in providers a social-login plugin // OAuthProviders lists the social sign-in providers a social-login plugin
// has enabled, as {success: true, providers: [{name, label}]}. The plugin // has enabled, as {success: true, providers: [{name, label}]}. The plugin
// publishes a classes.OAuthProviderSource, looked up on every request // publishes a classes.OAuthProviderSource, looked up on every request
@@ -682,8 +750,14 @@ func Register(app *backpack.App) http.HandlerFunc {
}) })
return return
} }
_ = FireRegisterEvent(r.Context(), app, user, fields) // The row is committed: a failing listener is logged and never undoes
_ = FireCreatedEvent(r.Context(), app, user) // or fails the registration, as in Winter.
if err := FireRegisterEvent(r.Context(), app, user, fields); err != nil {
pluginLog.Error("golem15.user: register listener failed", "user_id", user.ID, "err", err)
}
if err := FireCreatedEvent(r.Context(), app, user); err != nil {
pluginLog.Error("golem15.user: created listener failed", "user_id", user.ID, "err", err)
}
ctx := r.Context() ctx := r.Context()
if mode == "user" { if mode == "user" {
vars := mailVars(displayName(user), "", "") vars := mailVars(displayName(user), "", "")

View File

@@ -23,8 +23,9 @@ const membersRelation = "members"
// manager. The screen and every write need golem15.users.access_users, as in // manager. The screen and every write need golem15.users.access_users, as in
// PHP. // PHP.
// //
// The members manager sets and clears users.organisation_id and nothing // The members manager sets and clears users.organisation_id, and clears
// else. It never writes users_groups. // users.organisation_role with it (a role belongs to one organisation). It
// never writes users_groups.
type organisationsAdminController struct { type organisationsAdminController struct {
app appAccess app appAccess
} }
@@ -73,12 +74,15 @@ func (c organisationsAdminController) requireSlugFormat(ctx context.Context, mod
// AdminRelationContracts declares the members relation manager: the users // AdminRelationContracts declares the members relation manager: the users
// whose organisation_id is the organisation. Linking sets the column and // whose organisation_id is the organisation. Linking sets the column and
// unlinking clears it; the column is nullable, so a user is never deleted. // unlinking clears it; the column is nullable, so a user is never deleted.
// Both also clear organisation_role (ClearOnChange), so a role never moves
// to another organisation or outlives the membership.
func (organisationsAdminController) AdminRelationContracts() []cabana.RelationContract { func (organisationsAdminController) AdminRelationContracts() []cabana.RelationContract {
return []cabana.RelationContract{{ return []cabana.RelationContract{{
Name: membersRelation, Name: membersRelation,
Kind: cabana.RelationHasMany, Kind: cabana.RelationHasMany,
NewRelated: func() any { return &models.User{} }, NewRelated: func() any { return &models.User{} },
ForeignKey: "organisation_id", ForeignKey: "organisation_id",
ClearOnChange: []string{"organisation_role"},
Columns: map[string]string{ Columns: map[string]string{
"name": "name", "name": "name",
"email": "email", "email": "email",

View File

@@ -22,7 +22,9 @@ import (
// RegisterOptions tunes RegisterUser. Activate creates the account already // RegisterOptions tunes RegisterUser. Activate creates the account already
// activated (activate_mode auto, or a caller that activates explicitly). // activated (activate_mode auto, or a caller that activates explicitly).
// IP, when set, is recorded as created_ip_address and last_ip_address. // IP, when set, is recorded as created_ip_address and last_ip_address; it is
// the only source of those columns, so with an empty IP they stay NULL
// whatever the fields say.
type RegisterOptions struct { type RegisterOptions struct {
Activate bool Activate bool
IP string IP string
@@ -34,12 +36,16 @@ type RegisterOptions struct {
} }
// RegisterUser validates fields against the user model's rules, hashes the // RegisterUser validates fields against the user model's rules, hashes the
// password with golem15.user.password.bcrypt_cost (default 10), fills the // password with golem15.user.password.bcrypt_cost (default 10), fills only
// fillable columns and creates the users row on db, which may be a // the models.User RegisterFillable columns (name, surname, email, password,
// transaction. Validation failures are returned as Laravel's errors object // preferred_locale, marketing_consent) and creates the users row on db,
// (attribute to messages) with a nil user and nothing written; they are not // which may be a transaction. Any other key in fields is ignored: an
// written to a response. fields is not modified. The register event is not // organisation membership and role are set with an explicit update after
// fired: call FireRegisterEvent once the row is committed. // creation, as an application's onboarding does, and the IP columns come
// only from opts.IP. Validation failures are returned as Laravel's errors
// object (attribute to messages) with a nil user and nothing written; they
// are not written to a response. fields is not modified. The register event
// is not fired: call FireRegisterEvent once the row is committed.
func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields map[string]any, opts RegisterOptions) (*models.User, map[string][]string, error) { func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields map[string]any, opts RegisterOptions) (*models.User, map[string][]string, error) {
if db == nil { if db == nil {
return nil, nil, errors.New("golem15.user: gorm handle is missing") return nil, nil, errors.New("golem15.user: gorm handle is missing")
@@ -64,15 +70,18 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
return nil, nil, err return nil, nil, err
} }
input["password"] = hashed input["password"] = hashed
if opts.IP != "" {
input["created_ip_address"] = opts.IP
input["last_ip_address"] = opts.IP
}
user := &models.User{} user := &models.User{}
production := app == nil || app.Config == nil || !app.Config.Bool("app.debug") production := app == nil || app.Config == nil || !app.Config.Bool("app.debug")
if err := lagoon.Fill(user, user.Fillable(), input, production); err != nil { if err := lagoon.Fill(user, user.RegisterFillable(), input, production); err != nil {
return nil, nil, err return nil, nil, err
} }
// The IP columns are server-set after the fill, so a body value can never
// reach them.
if opts.IP != "" {
created, last := opts.IP, opts.IP
user.CreatedIPAddress = &created
user.LastIPAddress = &last
}
user.HasSelfSetPassword = !opts.PasswordLess user.HasSelfSetPassword = !opts.PasswordLess
if opts.Activate { if opts.Activate {
user.IsActivated = true user.IsActivated = true
@@ -124,8 +133,9 @@ func FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *mode
// FireRegisterEvent fires classes.RegisterEvent for user with Payload set // FireRegisterEvent fires classes.RegisterEvent for user with Payload set
// to a copy of fields without password and password_confirmation, and // to a copy of fields without password and password_confirmation, and
// returns the listeners' error. /register ignores that error, as Winter // returns the listeners' error. /register logs that error and continues, as
// does not stop a registration for a failing listener. // Winter does not stop a registration for a failing listener. Payload is
// untrusted client input: it carries every extra key the client sent.
func FireRegisterEvent(ctx context.Context, app *backpack.App, user *models.User, fields map[string]any) error { func FireRegisterEvent(ctx context.Context, app *backpack.App, user *models.User, fields map[string]any) error {
if app == nil || app.Events == nil { if app == nil || app.Events == nil {
return nil return nil
@@ -140,8 +150,8 @@ func FireRegisterEvent(ctx context.Context, app *backpack.App, user *models.User
} }
// FireCreatedEvent fires classes.CreatedEvent (user.Created) for user and // FireCreatedEvent fires classes.CreatedEvent (user.Created) for user and
// returns the listeners' error. /register ignores that error, as it does // returns the listeners' error. /register logs that error and continues, as
// for FireRegisterEvent. // it does for FireRegisterEvent.
func FireCreatedEvent(ctx context.Context, app *backpack.App, user *models.User) error { func FireCreatedEvent(ctx context.Context, app *backpack.App, user *models.User) error {
if app == nil || app.Events == nil { if app == nil || app.Events == nil {
return nil return nil

View File

@@ -364,7 +364,9 @@ const groupsField = "groups"
// AdminFieldRelations binds the organisation picker and the groups field. // AdminFieldRelations binds the organisation picker and the groups field.
// organisation_id is a protected column, so the contract opts in to writing // organisation_id is a protected column, so the contract opts in to writing
// it through this one field; the submitted id is still checked against the // it through this one field; the submitted id is still checked against the
// options. groups is the users_groups pivot: a save that carries the field // options. A save that changes the organisation (to another one or to none)
// clears organisation_role (ClearOnChange) unless the same save writes it.
// groups is the users_groups pivot: a save that carries the field
// replaces the user's memberships, and AdminRelationLocks decides which of // replaces the user's memberships, and AdminRelationLocks decides which of
// them the administrator may change. // them the administrator may change.
func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract { func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
@@ -376,6 +378,7 @@ func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract
ForeignKey: "organisation_id", ForeignKey: "organisation_id",
LabelColumn: "name", LabelColumn: "name",
WritableForeignKey: true, WritableForeignKey: true,
ClearOnChange: []string{"organisation_role"},
}, },
{ {
Field: groupsField, Field: groupsField,

2
go.mod
View File

@@ -3,7 +3,7 @@ module git.golem15.com/golem15/sm-user-plugin
go 1.27.0 go 1.27.0
require ( require (
git.golem15.com/golem15/summercms v0.4.0 git.golem15.com/golem15/summercms v0.4.3
github.com/go-gormigrate/gormigrate/v2 v2.1.7 github.com/go-gormigrate/gormigrate/v2 v2.1.7
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0

297
logout_test.go Normal file
View File

@@ -0,0 +1,297 @@
package user
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
"github.com/golang-jwt/jwt/v5"
)
const (
loggedOutBody = `{"message":"Logged out"}`
unauthorizedRaw = `{"error":true,"message":"Unauthorized"}`
blacklistedBody = `{"error":"Could not refresh token","msg":"The token has been blacklisted"}`
userTokensPath = "/_user/api/v1/tokens"
userLogoutPath = "/_user/api/v1/logout"
userRefreshPath = "/_user/api/v1/refresh"
)
// TestLogoutEndsTheSession: POST /logout ends the session however the token
// travels (the bearer header, or only the token or auth_token cookie the
// jwt guard also reads), so the next request with the same token or cookie
// is 401 on the jwt.auth routes, at /fetch and at /refresh. It clears the
// session cookies with the attributes the token cookie is written with, and
// the revocation lasts for the token's refresh window: a logged-out token is
// not refreshable after its access lifetime, and an expired but still
// refreshable token is revoked by a logout too.
func TestLogoutEndsTheSession(t *testing.T) {
e := newAdminEnv(t)
t.Run("bearer", func(t *testing.T) {
token := e.sessionLogin(t, "logout-bearer")
if code := e.guarded(t, token, ""); code != http.StatusOK {
t.Fatalf("jwt.auth before logout = %d", code)
}
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
assertLoggedOut(t, rec)
e.assertSessionEnded(t, token)
})
for _, name := range []string{"token", "auth_token"} {
t.Run("cookie "+name, func(t *testing.T) {
token := e.sessionLogin(t, "logout-"+name)
if code := e.guarded(t, token, name); code != http.StatusOK {
t.Fatalf("jwt.auth with the %s cookie before logout = %d", name, code)
}
rec := e.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": name + "=" + token})
assertLoggedOut(t, rec)
e.assertSessionEnded(t, token)
want := e.cookieConfig(t)
assertForgets(t, rec, want, want.Name)
if name != want.Name {
assertForgets(t, rec, want, name)
}
})
}
t.Run("bearer logout forgets the token cookie too", func(t *testing.T) {
token := e.sessionLogin(t, "logout-bearer-cookie")
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
assertLoggedOut(t, rec)
want := e.cookieConfig(t)
assertForgets(t, rec, want, want.Name)
for _, c := range rec.Result().Cookies() {
if c.Name == "auth_token" {
t.Fatalf("a request without the auth_token cookie got a forget cookie for it: %s", c)
}
}
})
t.Run("refused token still forgets the cookie", func(t *testing.T) {
rec := e.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": "token=not-a-token"})
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
t.Fatalf("logout with a bad cookie = %d %s", rec.Code, rec.Body.String())
}
assertForgets(t, rec, e.cookieConfig(t), "token")
missing := e.call(t, http.MethodPost, userLogoutPath, "", nil)
if missing.Code != http.StatusUnauthorized || missing.Body.String() != unauthorizedRaw {
t.Fatalf("logout without a token = %d %s", missing.Code, missing.Body.String())
}
})
t.Run("revocation outlives the access token", func(t *testing.T) {
token := e.sessionLogin(t, "logout-window")
claims := parseToken(t, token)
jti, _ := claims["jti"].(string)
iat, _ := claims.GetIssuedAt()
exp, _ := claims.GetExpirationTime()
assertLoggedOut(t, e.call(t, http.MethodPost, userLogoutPath, token, nil))
var expiresAt time.Time
if err := e.gdb.Raw(`SELECT expires_at FROM jwt_blacklist WHERE jti = ?`, jti).Scan(&expiresAt).Error; err != nil {
t.Fatal(err)
}
// The later of exp and iat plus the refresh window, plus a minute.
want := iat.Add(controllers.RefreshTTL(e.app)).Add(time.Minute)
if d := expiresAt.Sub(want); d < -time.Second || d > time.Second {
t.Fatalf("blacklist row expires at %v, want %v (refresh window plus a minute)", expiresAt, want)
}
// The sweep that runs once the access token has expired: the same
// predicate as bouncer.PostgresBlacklist.Sweep, limited to this jti
// so other tests' rows stay.
if err := e.gdb.Exec(`DELETE FROM jwt_blacklist WHERE jti = ? AND expires_at < ?`, jti, exp.Add(2*time.Minute)).Error; err != nil {
t.Fatal(err)
}
rec := e.refresh(t, token)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
t.Fatalf("refresh of a logged-out token after its access lifetime = %d %s", rec.Code, rec.Body.String())
}
})
t.Run("expired but refreshable token", func(t *testing.T) {
user := e.seedUser(t, "logout-expired", nil)
iat := time.Now().Add(-2 * time.Hour)
token := userAudienceToken(t, strconv.FormatUint(uint64(user.ID), 10), "logout-expired-"+adminStamp(), iat, iat.Add(time.Hour))
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
assertLoggedOut(t, rec)
if refreshed := e.refresh(t, token); refreshed.Code != http.StatusUnauthorized || refreshed.Body.String() != blacklistedBody {
t.Fatalf("refresh after logging out an expired token = %d %s", refreshed.Code, refreshed.Body.String())
}
})
t.Run("every token the request carries", func(t *testing.T) {
bearerToken := e.sessionLogin(t, "logout-both-bearer")
cookieToken := e.sessionLogin(t, "logout-both-cookie")
rec := e.send(t, http.MethodPost, userLogoutPath, bearerToken, nil, "application/json", map[string]string{"Cookie": "token=" + cookieToken})
assertLoggedOut(t, rec)
e.assertSessionEnded(t, bearerToken)
e.assertSessionEnded(t, cookieToken)
})
t.Run("refused tokens", func(t *testing.T) {
user := e.seedUser(t, "logout-refused", nil)
sub := strconv.FormatUint(uint64(user.ID), 10)
closedAt := time.Now().Add(-controllers.RefreshTTL(e.app) - time.Hour)
for name, token := range map[string]string{
// The refresh window closed: nothing left to revoke.
"closed window": userAudienceToken(t, sub, "logout-closed-"+adminStamp(), closedAt, closedAt.Add(time.Hour)),
// No audience (a PHP token) is accepted only while unexpired.
"expired without audience": craftToken(t, sub, "logout-legacy-old-"+adminStamp(), time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour)),
// No jti: it cannot be blacklisted.
"no jti": userAudienceToken(t, sub, "", time.Now(), time.Now().Add(time.Hour)),
// A user that does not exist.
"unknown user": userAudienceToken(t, "999999999", "logout-ghost-"+adminStamp(), time.Now(), time.Now().Add(time.Hour)),
} {
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
t.Fatalf("%s: logout = %d %s", name, rec.Code, rec.Body.String())
}
assertForgets(t, rec, e.cookieConfig(t), "token")
}
legacy := craftToken(t, sub, "logout-legacy-"+adminStamp(), time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
if code := e.guarded(t, legacy, ""); code != http.StatusOK {
t.Fatalf("jwt.auth with an unexpired token without audience = %d", code)
}
assertLoggedOut(t, e.call(t, http.MethodPost, userLogoutPath, legacy, nil))
if code := e.guarded(t, legacy, ""); code != http.StatusUnauthorized {
t.Fatalf("jwt.auth after logging out a token without audience = %d", code)
}
})
t.Run("configured cookie attributes and refresh window", func(t *testing.T) {
custom := newAdminEnv(t, func(c *compass.Config) {
for key, value := range map[string]any{
"auth.cookie.domain": "example.com",
"auth.cookie.same_site": "strict",
"auth.cookie.path": "/",
"golem15.user.jwt.refresh_ttl": 120,
} {
if err := c.Set(key, value); err != nil {
t.Fatal(err)
}
}
})
token := custom.sessionLogin(t, "logout-custom")
rec := custom.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": "token=" + token})
assertLoggedOut(t, rec)
want := custom.cookieConfig(t)
if want.Domain != "example.com" || want.SameSite != http.SameSiteStrictMode {
t.Fatalf("config not applied: %+v", want)
}
assertForgets(t, rec, want, "token")
custom.assertSessionEnded(t, token)
// Logout keeps the row for the window /refresh uses.
claims := parseToken(t, token)
jti, _ := claims["jti"].(string)
iat, _ := claims.GetIssuedAt()
var expiresAt time.Time
if err := custom.gdb.Raw(`SELECT expires_at FROM jwt_blacklist WHERE jti = ?`, jti).Scan(&expiresAt).Error; err != nil {
t.Fatal(err)
}
if window := controllers.RefreshTTL(custom.app); window != 120*time.Minute {
t.Fatalf("refresh window = %v", window)
}
if d := expiresAt.Sub(iat.Add(121 * time.Minute)); d < -time.Second || d > time.Second {
t.Fatalf("blacklist row expires at %v, want iat plus 121 minutes", expiresAt)
}
})
}
// sessionLogin seeds a frontend user, signs in through POST /login and
// returns the session token.
func (e *adminEnv) sessionLogin(t *testing.T, label string) string {
t.Helper()
user := e.seedUser(t, label, nil)
code, token := e.userLogin(t, user.Email, "correct-horse")
if code != http.StatusOK || token == "" {
t.Fatalf("login %s = %d", label, code)
}
return token
}
// guarded calls GET /tokens, a jwt.auth route, with token as the bearer
// header (cookie "") or only in the named cookie, and returns the status.
func (e *adminEnv) guarded(t *testing.T, token, cookie string) int {
t.Helper()
if cookie == "" {
return e.call(t, http.MethodGet, userTokensPath, token, nil).Code
}
return e.send(t, http.MethodGet, userTokensPath, "", nil, "application/json", map[string]string{"Cookie": cookie + "=" + token}).Code
}
// assertSessionEnded: token no longer authenticates, through any carrier,
// and cannot be refreshed.
func (e *adminEnv) assertSessionEnded(t *testing.T, token string) {
t.Helper()
for _, carrier := range []string{"", "token", "auth_token"} {
if code := e.guarded(t, token, carrier); code != http.StatusUnauthorized {
t.Fatalf("jwt.auth after logout (cookie %q) = %d", carrier, code)
}
}
if code := e.fetchCode(t, token); code != http.StatusUnauthorized {
t.Fatalf("fetch after logout = %d", code)
}
if rec := e.refresh(t, token); rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
t.Fatalf("refresh after logout = %d %s", rec.Code, rec.Body.String())
}
}
func (e *adminEnv) cookieConfig(t *testing.T) bouncer.CookieConfig {
t.Helper()
cfg, err := bouncer.LoadCookieConfig(e.app.Config)
if err != nil {
t.Fatal(err)
}
return cfg
}
func assertLoggedOut(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusOK || rec.Body.String() != loggedOutBody {
t.Fatalf("logout = %d %s", rec.Code, rec.Body.String())
}
}
// assertForgets: the response deletes the cookie name with the path,
// domain, SameSite, Secure and HttpOnly of the token cookie cfg writes.
func assertForgets(t *testing.T, rec *httptest.ResponseRecorder, cfg bouncer.CookieConfig, name string) {
t.Helper()
set := cfg.TokenCookie("x", time.Hour)
for _, c := range rec.Result().Cookies() {
if c.Name != name {
continue
}
if c.Value != "" || c.MaxAge >= 0 || c.Path != set.Path || c.Domain != set.Domain ||
c.SameSite != set.SameSite || c.Secure != set.Secure || c.HttpOnly != set.HttpOnly {
t.Fatalf("forget cookie %s does not match the token cookie %s", c, set)
}
return
}
t.Fatalf("no forget cookie for %s in %v", name, rec.Result().Header.Values("Set-Cookie"))
}
// userAudienceToken signs a session token the way bouncer.Mint does (user
// audience, prv), with a chosen iat and exp.
func userAudienceToken(t *testing.T, sub, jti string, iat, exp time.Time) string {
t.Helper()
claims := jwt.MapClaims{
"sub": sub,
"aud": []string{bouncer.AudienceUser},
"iss": "http://example.com/_user/api/v1/login",
"iat": iat.Unix(),
"nbf": iat.Unix(),
"exp": exp.Unix(),
"jti": jti,
"prv": prvHash,
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(sessionSecret))
if err != nil {
t.Fatal(err)
}
return signed
}

View File

@@ -81,8 +81,12 @@ func (User) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: AvatarField, Public: true}} return []attach.Relation{{Name: AvatarField, Public: true}}
} }
// Fillable is the register/update field list. Username, PIN, and GDPR columns // Fillable is the mass-assignment backstop of the admin Users form and of
// stay out of Phase 7 because no ported handler reads them. // trusted application code: cabana intersects it with the form's writable
// fields (and an application's form extensions) and keeps organisation_id
// protected. Registration does not use it: RegisterUser fills only
// RegisterFillable. Username, PIN and GDPR columns stay out because no
// ported handler reads them.
func (User) Fillable() []string { func (User) Fillable() []string {
return []string{ return []string{
"name", "surname", "email", "password", "created_ip_address", "last_ip_address", "name", "surname", "email", "password", "created_ip_address", "last_ip_address",
@@ -91,6 +95,20 @@ func (User) Fillable() []string {
} }
} }
// RegisterFillable is the explicit allow-list of the columns a registration
// body may set (POST /register and controllers.RegisterUser): name, surname,
// email, password (stored as its hash), preferred_locale and
// marketing_consent. A registration body can never set organisation_id or
// organisation_role (membership is the administrator's or the
// application's), created_ip_address or last_ip_address (server-set from
// RegisterOptions.IP only), is_onboarded (application-owned onboarding
// state) or must_change_password (set by an administrator or the
// require-password-change command). The activation, reset, address proof,
// handle and permission columns are never fillable at all.
func (User) RegisterFillable() []string {
return []string{"name", "surname", "email", "password", "preferred_locale", "marketing_consent"}
}
// Hidden mirrors PHP User::$hidden for the secrets this model stores. // Hidden mirrors PHP User::$hidden for the secrets this model stores.
func (User) Hidden() []string { func (User) Hidden() []string {
return []string{"password", "reset_password_code", "activation_code"} return []string{"password", "reset_password_code", "activation_code"}

195
offboarding_test.go Normal file
View File

@@ -0,0 +1,195 @@
package user
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
usermiddleware "git.golem15.com/golem15/sm-user-plugin/middleware"
"git.golem15.com/golem15/sm-user-plugin/models"
)
const refusedRefreshBody = `{"error":"Could not refresh token","msg":"User not found"}`
// offboardingCreds gives a seeded user an older session JWT (issued two
// seconds ago, so a revocation cutoff in this second cuts it) and a usr_
// personal API token.
func offboardingCreds(t *testing.T, e *adminEnv, user *models.User) (jwt, apiSecret string) {
t.Helper()
now := time.Now()
jwt = craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "off-"+adminStamp(), now.Add(-2*time.Second), now.Add(time.Hour))
secret, token, err := classes.MintAPIToken("usr_", user.ID, "offboarding", []string{"read"}, nil, nil)
if err != nil {
t.Fatal(err)
}
if err := e.gdb.Create(token).Error; err != nil {
t.Fatal(err)
}
return jwt, secret
}
// apiTokenWorks authenticates secret through the user.api_token guard.
func apiTokenWorks(t *testing.T, e *adminEnv, secret string) bool {
t.Helper()
guard := &usermiddleware.APITokenGuard{App: e.app}
req := httptest.NewRequest(http.MethodGet, "/api/v1/example", nil)
req.Header.Set("Authorization", "Bearer "+secret)
principal, _, err := guard.AuthenticateCredential(req)
return err == nil && principal != nil
}
func (e *adminEnv) fetchCode(t *testing.T, token string) int {
t.Helper()
return e.call(t, http.MethodGet, "/_user/api/v1/fetch", token, nil).Code
}
func (e *adminEnv) refresh(t *testing.T, token string) *httptest.ResponseRecorder {
t.Helper()
return e.call(t, http.MethodPost, "/_user/api/v1/refresh", token, nil)
}
func assertRefreshRefused(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusUnauthorized || rec.Body.String() != refusedRefreshBody {
t.Fatalf("refresh = %d %s, want 401 %s", rec.Code, rec.Body.String(), refusedRefreshBody)
}
}
// TestOffboarding: deactivate and ban end every earlier session JWT and
// personal API token. A deactivated user reactivates by signing in and gets
// a working token; a banned one is refused. Restore and unban revive no
// token.
func TestOffboarding(t *testing.T) {
e := newAdminEnv(t)
admin := e.admin(t, controllers.PermissionAccessUsers)
t.Run("deactivate", func(t *testing.T) {
user := e.seedUser(t, "off-deactivate", nil)
jwt, secret := offboardingCreds(t, e, user)
if e.fetchCode(t, jwt) != http.StatusOK || !apiTokenWorks(t, e, secret) {
t.Fatal("credentials do not work before the deactivation")
}
e.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/deactivate"), admin, map[string]any{"ids": []uint{user.ID}})
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
t.Fatalf("fetch after deactivate = %d", code)
}
if apiTokenWorks(t, e, secret) {
t.Fatal("API token works after deactivate")
}
assertRefreshRefused(t, e.refresh(t, jwt))
// Signing in right away (the same second allowed) restores the
// account, mails the reactivation and returns a working token.
code, fresh := e.userLogin(t, user.Email, "correct-horse")
if code != http.StatusOK || fresh == "" {
t.Fatalf("login after deactivate = %d", code)
}
if row := e.reload(t, user.ID); row == nil || row.DeletedAt.Valid {
t.Fatal("login did not restore the account")
}
if len(e.mails(user.Email)) != 1 {
t.Fatalf("reactivate mails = %d", len(e.mails(user.Email)))
}
if code := e.fetchCode(t, fresh); code != http.StatusOK {
t.Fatalf("fetch with the fresh token = %d", code)
}
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
t.Fatalf("old JWT after the restore = %d", code)
}
if apiTokenWorks(t, e, secret) {
t.Fatal("old API token works after the restore")
}
})
t.Run("ban", func(t *testing.T) {
user := e.seedUser(t, "off-ban", nil)
jwt, secret := offboardingCreds(t, e, user)
e.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/ban"), admin, map[string]any{"ids": []uint{user.ID}})
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
t.Fatalf("fetch after ban = %d", code)
}
if apiTokenWorks(t, e, secret) {
t.Fatal("API token works after ban")
}
assertRefreshRefused(t, e.refresh(t, jwt))
rec := e.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"})
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
t.Fatalf("banned login = %d %s", rec.Code, rec.Body.String())
}
e.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/unban"), admin, map[string]any{"ids": []uint{user.ID}})
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
t.Fatalf("old JWT after unban = %d", code)
}
if apiTokenWorks(t, e, secret) {
t.Fatal("old API token works after unban")
}
code, fresh := e.userLogin(t, user.Email, "correct-horse")
if code != http.StatusOK || e.fetchCode(t, fresh) != http.StatusOK {
t.Fatalf("login after unban = %d", code)
}
})
}
// TestRefreshSubjectChecks: /refresh loads the subject even without a
// revocation stamp. Deactivated, banned and cut-off subjects are refused
// with the token-failure body and stamp no last_seen; a suspended user still
// refreshes, and a normal refresh stamps last_seen.
func TestRefreshSubjectChecks(t *testing.T) {
e := newAdminEnv(t)
token := func(t *testing.T, user *models.User, iat time.Time) string {
t.Helper()
return craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "refresh-"+adminStamp(), iat, iat.Add(time.Hour))
}
lastSeen := func(t *testing.T, user *models.User) *time.Time {
t.Helper()
return e.reload(t, user.ID).LastSeen
}
t.Run("deactivated", func(t *testing.T) {
user := e.seedUser(t, "refresh-deactivated", nil)
if err := e.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, user.ID).Error; err != nil {
t.Fatal(err)
}
assertRefreshRefused(t, e.refresh(t, token(t, user, time.Now())))
if lastSeen(t, user) != nil {
t.Fatal("a refused refresh stamped last_seen")
}
})
t.Run("banned", func(t *testing.T) {
user := e.seedUser(t, "refresh-banned", nil)
e.seedThrottle(t, user, "", true, nil)
assertRefreshRefused(t, e.refresh(t, token(t, user, time.Now())))
if lastSeen(t, user) != nil {
t.Fatal("a refused refresh stamped last_seen")
}
})
t.Run("issued before tokens_valid_after", func(t *testing.T) {
user := e.seedUser(t, "refresh-cutoff", nil)
if err := e.gdb.Exec(`UPDATE users SET tokens_valid_after = ? WHERE id = ?`, time.Now().Truncate(time.Second), user.ID).Error; err != nil {
t.Fatal(err)
}
assertRefreshRefused(t, e.refresh(t, token(t, user, time.Now().Add(-10*time.Second))))
})
t.Run("suspended", func(t *testing.T) {
user := e.seedUser(t, "refresh-suspended", nil)
now := time.Now()
e.seedThrottle(t, user, "203.0.113.50", false, &now)
if rec := e.refresh(t, token(t, user, time.Now())); rec.Code != http.StatusOK {
t.Fatalf("suspended refresh = %d %s", rec.Code, rec.Body.String())
}
})
t.Run("normal", func(t *testing.T) {
user := e.seedUser(t, "refresh-normal", nil)
if rec := e.refresh(t, token(t, user, time.Now())); rec.Code != http.StatusOK {
t.Fatalf("refresh = %d %s", rec.Code, rec.Body.String())
}
if lastSeen(t, user) == nil {
t.Fatal("refresh did not stamp last_seen")
}
})
}

View File

@@ -2,6 +2,7 @@ package user
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -248,6 +249,102 @@ func TestRegisterEventPayload(t *testing.T) {
} }
} }
// TestRegisterListenerErrors: failing register and created listeners are
// logged and neither fail nor undo the registration.
func TestRegisterListenerErrors(t *testing.T) {
app, gdb := sessionApp(t, "0s")
disableRegisterThrottle(t, app)
app.Events.Listen[*classes.RegisterEvent]("failing-register", func(context.Context, *classes.RegisterEvent) error {
return errors.New("register listener down")
})
app.Events.Listen[*classes.CreatedEvent]("failing-created", func(context.Context, *classes.CreatedEvent) error {
return errors.New("created listener down")
})
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"listener-fail@example.test","password":"correct-horse"}`)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"token"`) {
t.Fatalf("register %d %s", rec.Code, rec.Body.String())
}
var count int64
if err := gdb.Model(&models.User{}).Where("email = ?", "listener-fail@example.test").Count(&count).Error; err != nil || count != 1 {
t.Fatalf("stored users = %d, %v", count, err)
}
}
// TestRegisterIgnoresProtectedFields: a registration body sets only the
// RegisterFillable columns. Organisation membership, the IP columns and the
// account flags in the body are ignored, over HTTP and through RegisterUser,
// and the IP columns come only from the server-side address.
func TestRegisterIgnoresProtectedFields(t *testing.T) {
app, gdb := sessionApp(t, "0s")
disableRegisterThrottle(t, app)
org := &models.Organisation{Name: "Mass Assign Org", Slug: "mass-assign-org"}
if err := gdb.Create(org).Error; err != nil {
t.Fatal(err)
}
protected := func(email string) map[string]any {
return map[string]any{
"email": email, "password": "correct-horse", "password_confirmation": "correct-horse", "name": "Mal", "surname": "Lory",
"preferred_locale": "pl", "marketing_consent": true,
"organisation_id": org.ID, "organisation_role": "owner",
"created_ip_address": "203.0.113.9", "last_ip_address": "203.0.113.9",
"is_onboarded": true, "must_change_password": true,
}
}
check := func(t *testing.T, email string, wantIP *string) {
t.Helper()
var stored models.User
if err := gdb.Where("email = ?", email).Take(&stored).Error; err != nil {
t.Fatal(err)
}
if stored.Name == nil || *stored.Name != "Mal" || stored.Surname == nil || *stored.Surname != "Lory" ||
stored.PreferredLocale == nil || *stored.PreferredLocale != "pl" || !stored.MarketingConsent {
t.Fatalf("allowed columns not stored: %+v", stored)
}
if stored.OrganisationID != nil || stored.OrganisationRole != nil {
t.Fatalf("organisation from the body: id %v role %v", stored.OrganisationID, stored.OrganisationRole)
}
if stored.IsOnboarded || stored.MustChangePassword {
t.Fatalf("account flags from the body: onboarded %v must_change %v", stored.IsOnboarded, stored.MustChangePassword)
}
for name, got := range map[string]*string{"created_ip_address": stored.CreatedIPAddress, "last_ip_address": stored.LastIPAddress} {
switch {
case wantIP == nil && got != nil:
t.Fatalf("%s = %q, want NULL", name, *got)
case wantIP != nil && (got == nil || *got != *wantIP):
t.Fatalf("%s = %v, want %q", name, got, *wantIP)
}
}
}
t.Run("http", func(t *testing.T) {
body, err := json.Marshal(protected("mass-http@example.test"))
if err != nil {
t.Fatal(err)
}
rec := postRegister(t, app, "192.0.2.44", string(body))
if rec.Code != http.StatusOK {
t.Fatalf("register %d %s", rec.Code, rec.Body.String())
}
ip := "192.0.2.44"
check(t, "mass-http@example.test", &ip)
})
t.Run("RegisterUser with IP", func(t *testing.T) {
_, errs, err := controllers.RegisterUser(t.Context(), app, gdb, protected("mass-ip@example.test"), controllers.RegisterOptions{IP: "198.51.100.7"})
if err != nil || len(errs) != 0 {
t.Fatalf("RegisterUser = %v, %v", errs, err)
}
ip := "198.51.100.7"
check(t, "mass-ip@example.test", &ip)
})
t.Run("RegisterUser without IP", func(t *testing.T) {
_, errs, err := controllers.RegisterUser(t.Context(), app, gdb, protected("mass-noip@example.test"), controllers.RegisterOptions{})
if err != nil || len(errs) != 0 {
t.Fatalf("RegisterUser = %v, %v", errs, err)
}
check(t, "mass-noip@example.test", nil)
})
}
func TestRegisterImportDirection(t *testing.T) { func TestRegisterImportDirection(t *testing.T) {
cmd := exec.Command("go", "list", "-deps", "./...") cmd := exec.Command("go", "list", "-deps", "./...")
cmd.Dir = moduleRoot(t) cmd.Dir = moduleRoot(t)

View File

@@ -189,10 +189,11 @@ func TestRequestUser(t *testing.T) {
} }
// TestEndRequestSession pins classes.EndRequestSession (D-04, D-05) without // TestEndRequestSession pins classes.EndRequestSession (D-04, D-05) without
// a database: the session's jti is blacklisted until the token expires, the // a database: the session's jti is blacklisted until its refresh window
// forget cookie carries the configured Domain and Path, a request without a // closes (not only until the access token expires, or /refresh could revive
// token still forgets the cookie, and a blacklist failure never fails the // it), the forget cookie carries the configured Domain and Path, a request
// call. // without a token still forgets the cookie, and a blacklist failure never
// fails the call.
func TestEndRequestSession(t *testing.T) { func TestEndRequestSession(t *testing.T) {
ctx := t.Context() ctx := t.Context()
app := sessionUserApp(t, app := sessionUserApp(t,
@@ -234,14 +235,15 @@ func TestEndRequestSession(t *testing.T) {
if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked { if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked {
t.Fatalf("%s: the jti is not blacklisted", kind) t.Fatalf("%s: the jti is not blacklisted", kind)
} }
// Until the token's expiry, and no longer. // Past the token's expiry, until its refresh window closes, and no
_ = bl.Sweep(ctx, time.Now()) // longer.
_ = bl.Sweep(ctx, time.Now().Add(time.Hour+2*time.Minute))
if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked { if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked {
t.Fatalf("%s: the entry left before the token expired", kind) t.Fatalf("%s: the entry left when the access token expired", kind)
} }
_ = bl.Sweep(ctx, time.Now().Add(time.Hour+time.Minute)) _ = bl.Sweep(ctx, time.Now().Add(controllers.RefreshTTL(app)+2*time.Minute))
if blocked, _ := bl.IsBlacklisted(ctx, jti); blocked { if blocked, _ := bl.IsBlacklisted(ctx, jti); blocked {
t.Fatalf("%s: the entry outlived the token", kind) t.Fatalf("%s: the entry outlived the refresh window", kind)
} }
} }

View File

@@ -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)
} }
// Handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups. // Email verified at, handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
for range 7 { for range 8 {
if err := m.RollbackLast(); err != nil { if err := m.RollbackLast(); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -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)
} }
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, // Email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
// throttle, then extend-users. // throttle, then extend-users.
for range 10 { for range 11 {
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)
} }
} }
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle. // Email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
for range 9 { for range 10 {
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)
} }
} }
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist. // Email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
for range 8 { for range 9 {
if err := m.RollbackLast(); err != nil { if err := m.RollbackLast(); err != nil {
t.Fatal(err) t.Fatal(err)
} }