15 Commits

Author SHA1 Message Date
Jakub Zych
91af10b572 chore: require SummerCMS v0.5.2 2026-10-28 23:39:33 +01:00
Jakub Zych
8446a77605 Merge phase-32.1-mcp-implied-scopes: implied MCP scopes alongside the 403 missing scope fields 2026-10-28 23:36:43 +01:00
756797c2c4 test(32.1-05): keep implied MCP scopes off the subject
- MCPDispatch expands the credential and leaves subject.Scopes unchanged
2026-10-28 16:10:03 +01:00
193a381ece test(32.1-02): keep personal tokens unexpanded and reject bad Implies
- A personal token keeps its scopes and its credential TokenID
- A write grant does not satisfy Implies read, and a present name is not duplicated
- Document Implies and the pact.MemberTokenScopeCatalog publish
2026-10-28 15:03:28 +01:00
25fd981d7f feat(32.1-02): expand OAuth MCP scopes from the published catalog
- Publish the compiled member-token catalog as pact.MemberTokenScopeCatalog
- Imply declared names only when the MCP subject has a ClientID
- Reject a bad Implies and the reserved names backend and offline_access
2026-10-28 15:01:04 +01:00
66fc107eb9 feat: name the missing scope or permission in 403 bodies
A refusal by user.scope:<scope> now answers 403 with missing_scope and a
refusal by user.permission:<code> with missing_permission, next to the
unchanged error (true) and message, so a plain-HTTP script or a personal
token client learns what it lacks. user_me says it needs scope read.
2026-10-28 14:05:57 +01:00
bd2cc0c245 test: expect the missing scope or permission in 403 bodies 2026-10-28 14:05:28 +01:00
a1b78b80f5 fix: the MCP dispatch credential carries the personal token id
MCPUserGuard.MCPDispatch now sets the in-memory credential's ID to the
subject's TokenID when the subject came from a personal usr_ token (no
ClientID), so a handler running under MCP dispatch can record which
token made a change. An OAuth subject's TokenID names the OAuth
server's token row, so its credential keeps ID 0. The credential is
still never saved.
2026-10-28 11:30:44 +01:00
07196a4bcb fix: keep the device polling token off the QR code
golem15.user.device_auth.auth_url gains a {short_code} placeholder and
defaults to {frontend_url}/device/scan/{short_code}; POST
/devices/authorize accepts the short code in its token field, so a scan
page that posts what its URL carries keeps working. The status poll hands
the session token over only to a client with the User-Agent the device
initiated with; another client sees the session confirmed and takes
nothing.
2026-10-26 16:12:08 +01:00
557c03c595 fix: a password change or ban ends authorized device sessions
The device hand-over now refuses a session authorized before the user's
tokens_valid_after, and every AccessRevokedEvent revokes the user's
pending and confirmed device sessions inside the write's transaction
(classes.RevokeDeviceSessions).
2026-10-26 16:08:20 +01:00
20e7b175f8 fix: Reset 2FA keeps the privileged-group guard
Resetting the second factors of a privileged-group member now needs
golem15.users.manage_privileged_groups, as changing their password or
deleting them does; without it the action answers 403 before Reset.
2026-10-26 16:04:11 +01:00
cc3636cea9 fix: a device revoke cannot lose to a concurrent refresh
The revoke now reads the device's jti with UPDATE ... RETURNING under the
row lock, and the refresh's hand-over (classes.FollowDeviceJTI) reports a
session revoked meanwhile, so the refresh answers the blacklisted-token
401 without a token. A hand-over error fails the refresh closed instead
of leaving the session on a stale jti.
2026-10-26 16:00:43 +01:00
1f3266a417 test: device authorization flow and its migration
- TestDeviceCodes (classes, short): tokens, short-code alphabet and
  distribution, normalization
- TestDeviceAuthURL, TestDeviceDisplayName (short): auth_url template, TTL,
  PHP's user-agent order
- TestDeviceInitiate, TestDeviceStatus, TestDeviceAuthorize: PHP bodies for
  every state and refusal, bounded cleanup, short-code collision retry,
  concurrent authorizes binding one user
- TestDeviceHandOverConcurrent, TestDeviceListAndRevoke,
  TestDeviceRevokeAfterRefresh, TestDeviceTokensAtRest
- TestDeviceAuthSessionsMigration (updates): columns, constraints, indexes,
  cascade, rollback and re-up
2026-10-26 14:15:54 +01:00
2c689793f4 test: start the updates package's Postgres only without -short
go test passes -short as -test.short=true, so scanning os.Args for
-test.short never matched and every -short run started a container (and
failed without Docker). Read the flag through flag.Parse and
testing.Short instead.
2026-10-26 14:06:54 +01:00
6844d362c4 test: login gates, password-changed sources and the reset action
- TestLoginGates: registry order, first result or error wins, nil gates,
  concurrent Add and Check, CheckLoginGates without a registry
- TestLoginGateOrder: refused first factors never reach a gate, rehash
  before a challenge, restore and reactivate mail only after the gates,
  gate answers pass through, refresh never asks a gate
- TestPasswordChangedSources: reset, change and admin set fire once inside
  the write's transaction; rehash and registration fire none
- TestResetTwoFactorAction: offered only with a service and a factor,
  danger, permission, transaction and rollback
2026-10-26 14:06:09 +01:00
26 changed files with 3342 additions and 131 deletions

View File

@@ -20,39 +20,39 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
- **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. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts its background sweep, which prunes the blacklist, through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. `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. `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. `GET /fetch`, `POST /refresh`, `POST /update` and `POST /change-password`, like the plugin's other signed-in routes, read the session token as the `jwt` guard does: the bearer header first, then the `token` and `auth_token` cookies, so a cookie-only session works on them and a bearer header wins when both are sent. A `POST /refresh` whose token came from the `auth.cookie.*` cookie (`auth.cookie.name`, default `token`) with no bearer header gets that cookie back with the rotated token: the cookie has the `bouncer.LoadCookieConfig` attributes, HttpOnly and Max-Age `refresh_ttl`, and the answer is `{}`, so the token never reaches JavaScript. This holds whoever wrote the cookie (this plugin's `golem15.user.jwt.token_cookie`, `golem15.sociallogin`'s `token_cookie` or the application); a frontend that writes the `token` cookie itself and refreshes through it gets it back HttpOnly, without a body token, so such a frontend sends the bearer header to keep rotating in the body. A refresh carried by the bearer header (a cookie sent with it included) or by the other session cookie answers `{"token": …}` and writes no cookie; the old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so that caller stores the new token itself. A refused refresh writes and forgets no cookie. When `bouncer.LoadCookieConfig` refuses the `auth.cookie` block, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block. Keep `auth.cookie.name` at `token` or `auth_token`, the cookies the guard reads. With `golem15.user.jwt.token_cookie` on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set that HttpOnly session cookie (`bouncer.LoadCookieConfig` attributes, Max-Age `refresh_ttl`) and leave `token` out of the body; `user`, and `message` on activate-by-code, stay. With it off (the default) the token is in the body and no cookie is set. SameSite stays the `auth.cookie` setting (`lax` by default) and there is no CSRF token, so do not set `same_site: none` for this cookie. With the key on, a broken `auth.cookie` block answers those three routes with the opaque 500 before they sign in, create or activate anything. Personal API tokens (`POST /tokens`) are not sessions and are unaffected. A cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the `auth.cookie` default is `lax`), as on the `jwt.auth` routes.
- **Login gates.** Register publishes a `*classes.LoginGates` registry. Another plugin (a two-factor plugin, for example) looks it up with `app.Lookup[*classes.LoginGates]()` and adds a `classes.LoginGate` in its Boot. `POST /login` consults the gates through `controllers.CheckLoginGates` after the password, activation and throttle checks (and the bcrypt rehash) and before it restores a deactivated account, sends the reactivate mail, stamps `last_seen` and mints the token. A gate sees a `classes.LoginAttempt` (`User`, `Source`, the already-read login body as `Fields`, `Request`). A nil result allows the sign-in; a `classes.LoginGateResult` replaces the token response with its `Status` (0 means 200) and `Body`, for example `{"two_factor_required":true,"challenge_token":…,"available_methods":[…],"expires_at":…}`, and no token is issued, no account is restored and no login event fires. A gate error fails closed with the opaque 500. Without a gate, `/login` answers exactly as before. `POST /refresh` never consults the gates.
- **Login event.** Every sign-in that issued a session token fires `classes.LoginEvent` (`classes.LoginEventName`, `golem15.user.login`, PHP's name) with `User` and `Source` (`LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor`, `LoginSourceDevice`), after the token was minted. `POST /login` fires it with `password`; another plugin that completes a sign-in fires it through `controllers.FireLoginEvent`. A listener error is logged and never fails the sign-in. Refresh and `/register` fire none.
- **Password-change hook.** A password reset, the user's own `POST /change-password` and an administrator setting a password on the Users screen fire `classes.AccessRevokedEvent` inside the write's transaction (see Access revocation); `(*AccessRevokedEvent).PasswordChanged()` reports these three reasons. A two-factor plugin listens to it to revoke trusted devices; a listener error rolls the password write back. The bcrypt rehash on login and a new account fire nothing.
- **Password-change hook.** A password reset, the user's own `POST /change-password` and an administrator setting a password on the Users screen fire `classes.AccessRevokedEvent` inside the write's transaction (see Access revocation); `(*AccessRevokedEvent).PasswordChanged()` reports these three reasons. A two-factor plugin listens to it to revoke trusted devices, and this plugin revokes the users' pending and confirmed device sessions (see Device authorization); a listener error rolls the password write back. The bcrypt rehash on login and a new account fire nothing.
- **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. With `golem15.user.password.public_routes` false, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered, so they answer the router's own 404 like any unknown path (any method, so no 405 shows they exist), while the `controllers` handlers stay exported.
- **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 client address in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. The client address is `surf.ClientIP` with the framework's `http.trusted_proxies`, as for the `/register` IP limit and the `user-api` rate limit: behind a listed reverse proxy it is the `X-Forwarded-For` client, otherwise the connection address, and an `X-Forwarded-For` header from any other peer is ignored. A deployment behind a proxy lists the proxy's address in `http.trusted_proxies`; otherwise every visitor shares the proxy's counter.
- **Device authorization.** A device without a keyboard (a TV, a desktop app) signs in by QR code or short code, PHP's flow with its paths and bodies. It calls `POST /devices/initiate` (no session; optional `device_name`, at most 255 characters) and gets `{"success":true,"data":{"token","short_code","auth_url","expires_at"}}`: a 64-character token, a short code `XXXX-XXXX` over `23456789ABCDEFGHJKMNPQRSTUVWXYZ`, the page to show as a QR code (`golem15.user.device_auth.auth_url`, default `{frontend_url}/device/scan/{token}`) and the expiry (`golem15.user.device_auth.ttl_minutes`, default 5). A signed-in user opens that page, or types the short code, and the site calls `POST /devices/authorize` with the user's session token and `short_code` (any case, hyphen optional) or `token`; there is no second-factor check, the user already passed it at sign-in. The device polls `GET /devices/status/{token}`: `pending`, then on the first poll after the authorization `{"success":true,"data":{"status":"confirmed","user_id",…,"message":"Device authorized - complete login","token":<session JWT>,"user":<payload>}}`, exactly once (one guarded `UPDATE` moves the session from `confirmed` to `used`, so of concurrent polls one gets the token), then `used`. A pending or confirmed session past its expiry answers PHP's flat `{"success":true,"status":"expired","message":"Authorization session has expired"}`; a used or revoked session never turns into expired. The statuses are `pending`, `confirmed`, `used`, `expired` and `revoked`. Only the SHA-256 of the device token is stored (column `token`, `golem15_user_device_auth_sessions`), with the jti of the session token the device received (`jwt_jti`). The hand-over fires the login event with source `device`. Each initiate deletes up to 500 pending or expired sessions that expired over an hour ago. The token travels in the status URL, so a reverse proxy that logs paths records it; it is one-time and lives `ttl_minutes`. A signed-in user lists their devices with `GET /devices` (`{"success":true,"devices":[{"id","device_name","device_ip","authorized_at","last_activity","is_current"}]}`, confirmed and used sessions, most recent activity first; `device_name` is the stored name or one parsed from the user agent; `is_current` marks the session whose token the request carries) and revokes one with `DELETE /devices/{id}` (`{"success":true,"message":"Device revoked successfully"}`, 404 `{"error":"Device not found"}` for another user's session). A revoke marks the session `revoked` and blacklists the jti of the session token the device holds for the token's whole refresh window, so the device is signed out at once and cannot refresh. `POST /refresh` moves a used session's `jwt_jti` to the refreshed token (`classes.HandOverDeviceJTI`), so a device that refreshed before the revoke is signed out too. With `golem15.user.jwt.blacklist_grace` above 0, two concurrent refreshes of one token can fork a device's token lineage and only one fork is followed; a password change still ends both through `tokens_valid_after`. The device routes check the session inside the handler and answer PHP's 401 `{"error":"Unauthorized"}`.
- **Device authorization.** A device without a keyboard (a TV, a desktop app) signs in by QR code or short code, PHP's flow with its paths and bodies. It calls `POST /devices/initiate` (no session; optional `device_name`, at most 255 characters) and gets `{"success":true,"data":{"token","short_code","auth_url","expires_at"}}`: a 64-character token, a short code `XXXX-XXXX` over `23456789ABCDEFGHJKMNPQRSTUVWXYZ`, the page to show as a QR code (`golem15.user.device_auth.auth_url`, default `{frontend_url}/device/scan/{short_code}`) and the expiry (`golem15.user.device_auth.ttl_minutes`, default 5). A signed-in user opens that page, or types the short code, and the site calls `POST /devices/authorize` with the user's session token and `short_code` (any case, hyphen optional) or `token` (the device token, or the short code the default page carries); there is no second-factor check, the user already passed it at sign-in. The device polls `GET /devices/status/{token}`: `pending`, then on the first poll after the authorization `{"success":true,"data":{"status":"confirmed","user_id",…,"message":"Device authorized - complete login","token":<session JWT>,"user":<payload>}}`, exactly once (one guarded `UPDATE` moves the session from `confirmed` to `used`, so of concurrent polls one gets the token), then `used`. The device token is the device's polling secret: the default `auth_url` carries the short code instead, so a photo of the QR code, the page's URL in a browser history or a `Referer` gives an onlooker nothing to poll with, and only a poll with the `User-Agent` the device initiated with receives the session token (another client gets `{"success":true,"data":{"status":"confirmed"}}` and takes nothing). A template with `{token}` still works but puts the polling secret on screen. A user who is gone, deactivated or banned, or whose `tokens_valid_after` is later than the authorization (a password change or `RevokeUserAccess` after the user authorized the device), gets no token: the session becomes `revoked`. Every `AccessRevokedEvent` (a ban, a password reset, change or an administrator's new password) also revokes the user's pending and confirmed sessions inside the write's transaction (`classes.RevokeDeviceSessions`). A pending or confirmed session past its expiry answers PHP's flat `{"success":true,"status":"expired","message":"Authorization session has expired"}`; a used or revoked session never turns into expired. The statuses are `pending`, `confirmed`, `used`, `expired` and `revoked`. Only the SHA-256 of the device token is stored (column `token`, `golem15_user_device_auth_sessions`), with the jti of the session token the device received (`jwt_jti`). The hand-over fires the login event with source `device`. Each initiate deletes up to 500 pending or expired sessions that expired over an hour ago. The token travels in the status URL, so a reverse proxy that logs paths records it; it is one-time and lives `ttl_minutes`. A signed-in user lists their devices with `GET /devices` (`{"success":true,"devices":[{"id","device_name","device_ip","authorized_at","last_activity","is_current"}]}`, confirmed and used sessions, most recent activity first; `device_name` is the stored name or one parsed from the user agent; `is_current` marks the session whose token the request carries) and revokes one with `DELETE /devices/{id}` (`{"success":true,"message":"Device revoked successfully"}`, 404 `{"error":"Device not found"}` for another user's session). A revoke marks the session `revoked` and blacklists the jti of the session token the device holds for the token's whole refresh window, so the device is signed out at once and cannot refresh. `POST /refresh` moves a used session's `jwt_jti` to the refreshed token (`classes.FollowDeviceJTI`), so a device that refreshed before the revoke is signed out too. The revoke reads the jti with its own `UPDATE … RETURNING`, under the row lock the refresh's hand-over takes, so a revoke and a refresh that race cannot leave the device signed in: a refresh that comes second finds the session revoked and answers 401 `{"error":"Could not refresh token","msg":"The token has been blacklisted"}` without a token. The hand-over fails closed: a database error answers the opaque 500 instead of a token the session does not follow. With `golem15.user.jwt.blacklist_grace` above 0, two concurrent refreshes of one token can fork a device's token lineage and only one fork is followed; a password change still ends both through `tokens_valid_after`. The device routes check the session inside the handler and answer PHP's 401 `{"error":"Unauthorized"}`.
- **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).
- **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. A banned user's account link answers no administrator (`LinkedBackendUser`). Neither unban nor restore revives a token. Activation gates login, deactivation does not: while `require_activation` is on, an account that is both deactivated and not activated is refused and stays deactivated, with no reactivate mail. 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, a logo and its members: the users whose `organisation_id` points at it. The logo is a second public image attachment (`logo`, `models.OrganisationLogoField`), kept in `system_files` like the avatar. The plugin serves no frontend organisation payload, so applications that build one add the logo with `classes.OrganisationLogoFields(ctx, app, org)`, which answers `has_logo` (bool), `logo` (the attachment's disk name, or null) and `logo_url` (a 256 px thumbnail URL, `classes.OrganisationLogoSize`, or null), the keys the user payload uses for its avatar; `classes.OrganisationLogoURL(ctx, app, org, size)` answers the URL at any size (the thumbnail, or the original file's public URL when the thumbnail fails, as `AvatarURL` does) and false when there is no logo or no file bucket. `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.
- **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.
- **Admin: Reset 2FA.** The Users screen declares the record action `reset_two_factor` ("Reset 2FA", danger confirmation) behind the backend permission `golem15.users.reset_two_factor`, which no role has by default. A two-factor plugin backs it by publishing a `classes.TwoFactorAdmin` (`app.Publish[classes.TwoFactorAdmin](svc)`): the screen looks the service up on every request and offers the action only when one is published and its `HasTwoFactor` reports a second factor for the user, so an application without a two-factor plugin never shows it. Running it calls the service's `Reset` in the action's transaction.
- **Admin: Reset 2FA.** The Users screen declares the record action `reset_two_factor` ("Reset 2FA", danger confirmation) behind the backend permission `golem15.users.reset_two_factor`, which no role has by default. A two-factor plugin backs it by publishing a `classes.TwoFactorAdmin` (`app.Publish[classes.TwoFactorAdmin](svc)`): the screen looks the service up on every request and offers the action only when one is published and its `HasTwoFactor` reports a second factor for the user, so an application without a two-factor plugin never shows it. Running it calls the service's `Reset` in the action's transaction. On a member of a privileged group it also needs `golem15.users.manage_privileged_groups` (see Privileged groups): without it the action answers 403 and `Reset` does not run.
- **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, avatar and logo (an image upload), 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, and removes its avatar and logo attachments with it (the files once the delete commits).
- **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, resetting their second factors (Reset 2FA), 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`.
- **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.
- **Handle.** `users.handle` is a unique nullable `VARCHAR(255)` generated on create (`classes.HandleGenerator`, charset `[a-z0-9._-]`, length 2–255). It is omitted from the user JSON payload (`json:"-"`). `user:backfill-handles` fills existing rows that still have none.
- **User lighthouse authorizer.** Boot calls `classes.RegisterUserAuthorizer`: `lighthouse.From` then `Registry.Register("user", …)` if the namespace is free. `classes.AuthorizeUser` allows only `user:{id}` for that user (`lighthouse.ChannelID` on the two-segment name).
- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:<scope>` limits delegated access and `user.permission:<code>` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created.
- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:<scope>` limits delegated access and `user.permission:<code>` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created. A refusal by `user.scope:<scope>` or `user.permission:<code>` answers 403 with the existing `error` and `message` plus `missing_scope` or `missing_permission` naming what is missing.
- **Member token scopes.** A member personal token (`usr_`) carries the built-in scopes `read`, `write` and `ai`, plus any scope another plugin declares with `pact.HasFrontendTokenScopes`:
```go
func (Plugin) FrontendTokenScopes() []pact.FrontendTokenScope {
return []pact.FrontendTokenScope{
{Name: "inventory:read", Label: "acme.inventory::lang.scopes.read", Description: "acme.inventory::lang.scopes.read_description"},
{Name: "inventory:write", Label: "acme.inventory::lang.scopes.write", Description: "acme.inventory::lang.scopes.write_description"},
{Name: "inventory:read", Label: "acme.inventory::lang.scopes.read", Description: "acme.inventory::lang.scopes.read_description", Implies: "read"},
{Name: "inventory:write", Label: "acme.inventory::lang.scopes.write", Description: "acme.inventory::lang.scopes.write_description", Implies: "write"},
}
}
```
Boot compiles the catalog from the activated plugins (`classes.CompileAPITokenScopes`, published as `*classes.APITokenScopeCatalog`) and fails on a name that does not pass `pact.ValidTokenScopeName`, a built-in redeclared, a name two plugins declare (the error names both), an empty label or description, or a label or description phrase key (containing `::`) that the translator does not have. `POST /tokens` and `classes.MintAPITokenFor(app, …)` accept the scopes of the application's own catalog (`classes.APITokenScopesFor(app)`) and answer an undeclared one with the usual 422 `The selected scope is invalid.`; the console's `user:token:create` uses `MintAPITokenFor` too. Applications mint with `MintAPITokenFor`. The older `classes.MintAPIToken` is deprecated; it keeps its signature and accepts the built-ins plus every scope a catalog compiled in the same process declared, a fallback for existing callers; production runs one application per process, so that is the application's own catalog, while a test binary that boots several applications shares it (`classes.ResetDeclaredAPITokenScopes` clears it, for tests only). `user.scope:<name>`, `middleware.Scope` and `APIToken.HasScope` check a declared scope like a built-in, and `GET /tokens` lists it in `scopes`. `GET /tokens/scopes` (`jwt.auth`) answers `{"data":[{"name","label","description","built_in"}]}`, the built-ins first, then the declared scopes in activation order, with phrase-key labels and descriptions translated for the request locale. The admin `g15_` token catalog never shows these scopes (it reads `pact.HasTokenScopes` only). `classes.AllowedAPITokenScopes` stays the built-in list.
Boot compiles the catalog from the activated plugins (`classes.CompileAPITokenScopes`, published as `*classes.APITokenScopeCatalog` and as `pact.MemberTokenScopeCatalog`) and fails on a name that does not pass `pact.ValidTokenScopeName`, a built-in redeclared, a reserved name `backend` or `offline_access`, a name two plugins declare (the error names both), an empty label or description, an `Implies` other than empty, `read` or `write` (the error names the plugin and that value), or a label or description phrase key (containing `::`) that the translator does not have. `Implies` is only those three values: empty means the declared scope is not implied, and the built-ins `read`, `write` and `ai` keep it empty. Implied names are not stored on a token row or a fingerprint. `POST /tokens` and `classes.MintAPITokenFor(app, …)` accept the scopes of the application's own catalog (`classes.APITokenScopesFor(app)`) and answer an undeclared one with the usual 422 `The selected scope is invalid.`; the console's `user:token:create` uses `MintAPITokenFor` too. Applications mint with `MintAPITokenFor`. The older `classes.MintAPIToken` is deprecated; it keeps its signature and accepts the built-ins plus every scope a catalog compiled in the same process declared, a fallback for existing callers; production runs one application per process, so that is the application's own catalog, while a test binary that boots several applications shares it (`classes.ResetDeclaredAPITokenScopes` clears it, for tests only). `user.scope:<name>`, `middleware.Scope` and `APIToken.HasScope` check a declared scope like a built-in, and `GET /tokens` lists it in `scopes`. `GET /tokens/scopes` (`jwt.auth`) answers `{"data":[{"name","label","description","built_in"}]}`, the built-ins first, then the declared scopes in activation order, with phrase-key labels and descriptions translated for the request locale. The admin `g15_` token catalog never shows these scopes (it reads `pact.HasTokenScopes` only). `classes.AllowedAPITokenScopes` stays the built-in list.
- **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), 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.
@@ -60,7 +60,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
- **Frontend account link for administrators.** A backend administrator's account can be linked to one frontend user, and a frontend user to one administrator (`golem15_user_backend_links`, unique on both sides). The administrator starts it on My account (section `frontend_account`, action `link`): the plugin stores a one-shot request (32 random bytes as hex, kept only as its SHA-256, valid `golem15.user.backend_links.request_ttl`) and answers the site URL `golem15.user.frontend_url` (else `app.url`) + `golem15.user.backend_links.page_path` (default `/link-admin`, `classes.BackendLinkPagePath`) + `#code=<code>`, which the admin opens in a new tab. The code travels in the URL fragment, which browsers never send to a server, so it stays out of access logs. The site page reads the fragment, keeps the code in the browser (for example `sessionStorage`) across the sign-in, shows the request through `POST /backend-link/show` with `{"code":…}` in the body (the administrator's login and display name and the expiry, nothing else), shows which frontend account is signed in, and posts the code to `POST /backend-link/confirm` behind `jwt.auth`. Both POST routes read the code from the body only (JSON, or a form body), never from the query string. `GET /backend-link/{code}` answers the same as the POST show route and stays for compatibility; it is deprecated, because its path puts the code in access logs, so pages use POST. The link is written only by the confirm, in one transaction with the request row locked, and only when the session was signed in at or after the request was created: the proof is the session token's `auth_time` claim (`bouncer.VerifyAuthTime`), the time of the sign-in itself, compared in whole seconds with the request. `POST /refresh` copies `auth_time` and never renews it, so a refreshed old session does not pass, and a browser that was already signed in must sign in again; password, social login and any login gate apply as usual. Upgrade note: session tokens issued before the framework stamped `auth_time` (SummerCMS v0.5.0) carry no `auth_time`, and a refresh carries that absence forward, so a frontend user signed in before the upgrade gets `stale_session` and must sign in again before linking. A code works once; a used, expired or unknown code answers 404. No link is ever made by matching emails. The administrator removes it on My account (`unlink`), and an administrator holding `golem15.users.manage_backend_links` links or unlinks another administrator on the Administrators screen (see Admin). Deleting or deactivating the frontend user removes the link in the same transaction; deleting an administrator removes it through the foreign key, and a deactivated or soft-deleted administrator is not answered by the resolver. Boot publishes `*classes.BackendLinks` and the same value as `pact.MCPLinkedBackendResolver` (`LinkedBackendUser`, one query per call), which the framework's MCP endpoint uses to give a frontend user the linked administrator's backend tools. `BackendLinkedEvent` and `BackendUnlinkedEvent` fire inside the write transaction.
- **Frontend permission codes from plugins.** Another plugin declares the frontend permission codes its routes check (`user.permission:<code>`) with `pact.HasFrontendPermissions`. The Users and User Groups permission editors offer the rows of `golem15_user_frontend_permissions` first, unchanged, then the declared codes of the activated plugins in activation order (`classes.FrontendPermissionCatalog`); a seeded row wins over a declared code with the same code, and the first declaring plugin wins over a later one. Nothing is written to the table; the stored permissions keep their shape.
- **Access revocation.** Banning a user (the admin `ban` bulk action, for the users it newly bans), a password reset (`POST /reset-password`), an administrator setting a password on the Users screen and the user's own `POST /change-password` fire `classes.AccessRevokedEvent` (`user.AccessRevoked`: `UserIDs`, `Before`, `Reason`, `Tx`) through `classes.FireAccessRevoked`, inside the write's transaction (a listener error rolls the write back), and a "revoked" `pact.MCPSubjectChanged` per user after it commits, so the users' MCP sessions end. `Before` is the untruncated instant of the revocation for every reason, the self-service change included, so the event ends every OAuth grant of the user however recently its client refreshed it. `tokens_valid_after` keeps its whole-second value: the second of `Before` for a ban, a reset or an administrator's password, and for the self-service change the start of the calling session's `iat` second, so the calling session and the session tokens issued since that second survive. `Reason` is `ban`, `password_reset`, `password_set_by_admin` or `password_changed` (`AccessRevokedBan`, `AccessRevokedPasswordReset`, `AccessRevokedPasswordSetByAdmin`, `AccessRevokedPasswordChanged`). Listeners revoke what such a user still holds elsewhere: `golem15.oauthserver` revokes the users' OAuth access and refresh tokens created before `Before`. `RevokeUserAccess`, `BanUsers` and `DeactivateUsers` keep their behaviour and fire nothing: an application that calls `RevokeUserAccess` or `BanUsers` directly calls `FireAccessRevoked` in the same transaction too. Deactivation and deletion keep their own events (`DeactivatingEvent`, `DeletingEvent`).
- **MCP guard and the `user_me` tool.** Boot registers `classes.MCPUserGuard` as the MCP guard provider `user` in the application's `*pact.MCPGuards` and publishes it as `pact.MCPPersonalTokenResolver`, so the framework's MCP endpoint (built with `-tags summercms_mcp`) can bind tools to routes behind `user.api_token`, `user.scope:<scope>` and `user.permission:<code>` and accept the plugin's personal API tokens (the `golem15.user.api_tokens.prefix`). The guard decides with the routes' own checks: every scope of the route must be granted to the MCP session, and every permission code must be granted by one of the user's groups, as `user.permission` checks it (a user's own permission override does not grant). A tool call runs the route with the user and an in-memory token credential carrying the session's scopes (ID 0, never saved). The plugin's one tool, `me` (exposed as `user_me`), runs `GET /token/me` and answers the signed-in user's payload. A personal token used at the MCP endpoint gets `last_used_at` stamped, and `last_used_ip` when the endpoint passes the client address (`towel.ClientIP`), as the `user.api_token` guard does. Revoking a personal token, saving a user on the Users screen, saving or deleting a group and the link changes fire `pact.MCPSubjectChanged` after they commit, so open MCP sessions re-list their tools or end; revoking a personal token (`DELETE /tokens/{id}`) carries the token's id (`TokenID`), so only the sessions that token opened end. `BackendLinkedEvent` and `BackendUnlinkedEvent`, with `Link.UserID` set, fire on every link and unlink: the confirmation, the Administrators screen, My account and the deletion or deactivation of the frontend user. Deleting an administrator removes the link through the foreign key and fires nothing.
- **MCP guard and the `user_me` tool.** Boot registers `classes.MCPUserGuard` as the MCP guard provider `user` in the application's `*pact.MCPGuards` and publishes it as `pact.MCPPersonalTokenResolver`, so the framework's MCP endpoint (built with `-tags summercms_mcp`) can bind tools to routes behind `user.api_token`, `user.scope:<scope>` and `user.permission:<code>` and accept the plugin's personal API tokens (the `golem15.user.api_tokens.prefix`). The guard decides with the routes' own checks: every scope of the route must be granted to the MCP session, and every permission code must be granted by one of the user's groups, as `user.permission` checks it (a user's own permission override does not grant). For an OAuth subject (`ClientID` non-empty), `MCPAllows` and `MCPDispatch` also treat a declared scope as granted when that catalog entry's `Implies` is `read` or `write` and the grant holds that stock scope. `write` does not satisfy `Implies` `read`. A personal token (`ClientID` empty) is not expanded: its scopes stay the grant. The expanded names exist only for that check and on the in-memory credential; they are not written back onto `subject.Scopes`, the token row, or a fingerprint. A tool call runs the route with the user and an in-memory token credential carrying those effective scopes; for a session opened with a personal token the credential's ID is that token's id (the subject's `TokenID`), so a handler can record which token made a change, while an OAuth session's credential has ID 0. The credential is never saved. The plugin's one tool, `me` (exposed as `user_me`), runs `GET /token/me` and answers the signed-in user's payload. A personal token used at the MCP endpoint gets `last_used_at` stamped, and `last_used_ip` when the endpoint passes the client address (`towel.ClientIP`), as the `user.api_token` guard does. Revoking a personal token, saving a user on the Users screen, saving or deleting a group and the link changes fire `pact.MCPSubjectChanged` after they commit, so open MCP sessions re-list their tools or end; revoking a personal token (`DELETE /tokens/{id}`) carries the token's id (`TokenID`), so only the sessions that token opened end. `BackendLinkedEvent` and `BackendUnlinkedEvent`, with `Link.UserID` set, fire on every link and unlink: the confirmation, the Administrators screen, My account and the deletion or deactivation of the frontend user. Deleting an administrator removes the link through the foreign key and fires nothing.
- **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.
## Usage
@@ -179,7 +179,7 @@ Backend permissions, all on the tab `golem15.user::lang.plugin.tab`:
| `golem15.users.access_groups` | `developer` | The User Groups screen. |
| `golem15.users.access_settings` | `developer` | Registered for roles that already name it; no screen uses it yet. |
| `golem15.users.impersonate_user` | `developer` | Registered for roles that already name it; impersonation is not available. |
| `golem15.users.manage_privileged_groups` | none | Adding a privileged group to a user or removing it; creating, re-coding or deleting a privileged group; changing the email or password of a privileged-group member, and deleting one for good. Only a superuser or a role that was granted it holds it. |
| `golem15.users.manage_privileged_groups` | none | Adding a privileged group to a user or removing it; creating, re-coding or deleting a privileged group; changing the email or password of a privileged-group member, resetting their second factors, and deleting one for good. Only a superuser or a role that was granted it holds it. |
| `golem15.users.manage_backend_links` | none | Linking a frontend account to another administrator, and unlinking one, on the Administrators screen. Only a superuser or a role that was granted it holds it. |
| `golem15.users.reset_two_factor` | none | The Users screen's "Reset 2FA" record action, which removes every second factor of a user through the two-factor plugin's `classes.TwoFactorAdmin`. Only a superuser or a role that was granted it holds it. |
@@ -238,7 +238,7 @@ Packages:
- `DeviceInitiate`, `DeviceStatus`, `DeviceAuthorize`, `DeviceIndex` and `DeviceDestroy`, the device authorization handlers (see Device authorization).
- `RegistrationSettings(app)` returns `allow_registration`, `use_register_throttle`, `activate_mode` and `require_activation` with their defaults (`true`, `true`, `auto`, `true`), and `RegistrationsFromIP(ctx, db, ip)` counts the accounts created from an IP in the last 60 minutes (`/register` refuses at 3 with the throttle on).
- `RefreshTTL(app)` is the refresh window, `golem15.user.jwt.refresh_ttl` minutes (default 20160), and `RemoteIP(r)` the host part of `r.RemoteAddr`, with no proxy handling (`/login` and `/register` use `surf.ClientIP` with `http.trusted_proxies`).
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `MintAPITokenFor`, `HashAPIToken` and `RevokeAPIToken`, the member token scope catalog `APITokenScope`, `APITokenScopeCatalog` (`Scopes`, `Allowed`), `CompileAPITokenScopes`, `APITokenScopesFor` and the test seam `ResetDeclaredAPITokenScopes`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `MintAPITokenFor`, `HashAPIToken` and `RevokeAPIToken`, the member token scope catalog `APITokenScope`, `APITokenScopeCatalog` (`Scopes`, `Allowed`, `MemberTokenScopes`), `CompileAPITokenScopes`, `APITokenScopesFor` and the test seam `ResetDeclaredAPITokenScopes`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
- `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.
- `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction.
@@ -255,7 +255,7 @@ Packages:
- `AccessRevokedEvent` / `AccessRevokedEventName` and `FireAccessRevoked(ctx, app, tx, userIDs, before, reason)` (see Access revocation).
- `LoginAttempt`, `LoginGateResult`, `LoginGate` (`CheckLogin(ctx, attempt)`), `LoginGates` with `NewLoginGates()`, `Add(gate)` and `Check(ctx, attempt)`, the login-gate seam (see Login gates), and the sources `LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor` and `LoginSourceDevice`.
- `LoginEvent` / `LoginEventName` (see Login event).
- `DeviceShortCodeAlphabet`, `NewDeviceToken()`, `NewDeviceShortCode()`, `NormalizeDeviceShortCode(s)`, `DeviceAuthTTL(app)`, `DeviceAuthURL(app, token)`, `DeviceDisplayName(stored, userAgent)` and `HandOverDeviceJTI(ctx, db, oldJTI, newJTI)` (moves a used session to a refreshed token's jti; `POST /refresh` calls it), the device authorization helpers; device tokens are hashed with `HashAPIToken`.
- `DeviceShortCodeAlphabet`, `NewDeviceToken()`, `NewDeviceShortCode()`, `NormalizeDeviceShortCode(s)`, `DeviceAuthTTL(app)`, `DeviceAuthURLFor(app, token, shortCode)`, `DeviceAuthURL(app, token)` (the same with the token in place of the short code), `DeviceDisplayName(stored, userAgent)`, `RevokeDeviceSessions(ctx, db, userIDs...)` (revokes the users' pending and confirmed sessions; the plugin calls it for every `AccessRevokedEvent`), `FollowDeviceJTI(ctx, db, oldJTI, newJTI)` (moves a used session to a refreshed token's jti and reports a session revoked meanwhile; `POST /refresh` calls it and refuses the refresh then) and `HandOverDeviceJTI(ctx, db, oldJTI, newJTI)` (the same move without the report), the device authorization helpers; device tokens are hashed with `HashAPIToken`.
- `TwoFactorAdmin` (`HasTwoFactor(ctx, tx, userID)`, `Reset(ctx, tx, user)`) and `PermissionResetTwoFactor`, the Reset 2FA slot (see Admin: Reset 2FA), and `(*AccessRevokedEvent).PasswordChanged()` (see Password-change hook).
- `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
@@ -289,9 +289,9 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
| `golem15.user.password.min_length` | `8` | Minimum password length. |
| `golem15.user.password.public_routes` | `true` | When `false`, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered and answer 404 like any unknown path; `POST /register` (still gated by `allow_registration` and guarded by `golem15.user.turnstile`) and every other route stay. It is read when the routes are built, so a change needs a restart. It takes `true` or `false`, also as a string (`SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false`); an empty value means `true`; any other value fails boot. |
| `golem15.user.api_tokens.prefix` | `usr_` | Prefix for newly minted and accepted shared API-token secrets. Override an existing application's legacy prefix without creating a plugin-specific token table. |
| `golem15.user.privileged_groups` | `[admin]` | Codes of the privileged user groups. Membership of such a group, the group's code and its delete, and the email, password and permanent delete of its members need `golem15.users.manage_privileged_groups`. Read on every request. An empty list protects no group. |
| `golem15.user.privileged_groups` | `[admin]` | Codes of the privileged user groups. Membership of such a group, the group's code and its delete, and the email, password, Reset 2FA and permanent delete of its members need `golem15.users.manage_privileged_groups`. Read on every request. An empty list protects no group. |
| `golem15.user.frontend_url` | `""` | The site the plugin's own links point to: the account link page is this URL + `backend_links.page_path` + `#code=<code>`, and it fills `{frontend_url}` in `device_auth.auth_url`. Empty falls back to `app.url`; a trailing slash is dropped. |
| `golem15.user.device_auth.auth_url` | `{frontend_url}/device/scan/{token}` | The `auth_url` a device shows as its QR code, where a signed-in user authorizes it. `{frontend_url}` is `golem15.user.frontend_url` (else `app.url`, no trailing slash) and `{token}` the device token. |
| `golem15.user.device_auth.auth_url` | `{frontend_url}/device/scan/{short_code}` | The `auth_url` a device shows as its QR code, where a signed-in user authorizes it. `{frontend_url}` is `golem15.user.frontend_url` (else `app.url`, no trailing slash), `{short_code}` the session's short code and `{token}` the device token. `POST /devices/authorize` takes the short code in `token` too. Keep `{token}` out of it: the device token is also the secret the device collects its session token with. |
| `golem15.user.device_auth.ttl_minutes` | `5` | Minutes a device session can be authorized and handed over. |
| `golem15.user.backend_links.page_path` | `/link-admin` | The site page that confirms an account link. A value without a leading `/` gets one. |
| `golem15.user.backend_links.request_ttl` | `10m` | How long a link request an administrator starts on My account stays usable (a Go duration). A value that does not parse or is not positive fails boot. |
@@ -353,4 +353,8 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
`TestMemberTokenScopesBoot` (no Docker) checks that the scope Boot step fails for a name two plugins declare (naming both), for a redeclared `read`, `write` or `ai`, for a bad name, an empty label and a missing phrase key. With Docker: `TestMemberTokenScopes` walks the member token scope acceptance list item by item (mint and `middleware.Scope`, the 403 message, the 422 for an undeclared scope, the list, discovery in en and pl, the per-application catalog and the legacy fallback); `TestBackendLinkAuthTime` checks the link proof against login, request, refresh and re-login orders, a token without `auth_time` and another user's session token; `TestBackendLinkShowPost` checks that `POST /backend-link/show` answers like the GET route with `Cache-Control: no-store, private`, the fragment URL and `backend_links.page_path`; `TestAccessRevocation` checks `AccessRevokedEvent` from the ban action, the password reset, the password change and an administrator setting a password (ids, `Before`, reason, the transaction, the subject event after commit, the rollback on a failing listener) and that a banned user loses the backend link until unbanned; `TestUserMCPGuardClientIP` checks `last_used_ip` on the MCP personal-token path; `TestAPITokenRevokeEvent` checks the token-specific revoked event of `DELETE /tokens/{id}`; `TestOrganisationLogo` checks the logo field, the upload, `OrganisationLogoFields` with and without a logo, the URL fallback when the thumbnail fails, and that deleting an organisation removes its avatar and logo attachments, the rows and, once the delete commits, the blobs and thumbnails.
The login-gate seam, the password-change hook and the Reset 2FA slot have their own tests. `TestLoginGates` (in `-short`, clean under `-race`) pins the registry: a nil or empty registry allows, gates run in the order they were added, the first result or error wins and stops the chain, nil gates are ignored, and `controllers.CheckLoginGates` allows when no registry is published. `TestLoginGateOrder` pins where `POST /login` asks the gates: refused first factors (wrong password, unknown email, not activated, banned) never reach them; the gate sees the attempt with the body already read; the bcrypt rehash happens before a challenge and fires no password-change event; a challenged deactivated account stays deactivated with no mail and no login event, while an allowed one is restored with one reactivate mail and one login event; a gate's status and body pass through; `/refresh` never asks a gate. `TestPasswordChangedSources` checks that the reset, the user's own change and an administrator setting a password each fire one `AccessRevokedEvent` with `PasswordChanged()` true inside the write's transaction (a listener's write through `ev.Tx` commits with it, a listener error rolls both back), and that the rehash and a registration fire none. `TestResetTwoFactorAction` covers the `reset_two_factor` record action: not offered without a published `classes.TwoFactorAdmin`, offered with the danger confirmation when one reports a second factor, hidden when it reports none, refused without `golem15.users.reset_two_factor`, `Run` refused outside the action's transaction, `Reset` committed with the action and rolled back by its error, and a privileged-group member refused with 403 before `Reset` without `golem15.users.manage_privileged_groups`. The plan 01 smoke tests (`TestLoginGatesSmoke`, `TestLoginGateSmoke`, `TestPasswordChangedEventSmoke`, `TestResetTwoFactorSlotSmoke`) stay. The Postgres tests need Docker.
The device authorization flow is pinned by `TestDeviceCodes` (in `classes`, `-short`: 10,000 tokens and short codes, the alphabet in every position, no modulo bias, the normalization table), `TestDeviceAuthURL` and `TestDeviceDisplayName` (both `-short`: the `auth_url` template with `{short_code}` as the default, the TTL setting and PHP's user-agent order), and on Postgres through the assembled routes: `TestDeviceInitiate` (PHP's answer with an `auth_url` that carries the short code and not the token, the stored pending row, the TTL, the 255-character `device_name` bound, the cleanup of at most 500 stale pending or expired sessions per call, the retry of an insert whose short code collides with a stored one); `TestDeviceStatus` (unknown, pending, PHP's flat expired body for a pending or confirmed session past `expires_at`, used and revoked sessions that never expire); `TestDeviceAuthorize` (short-code forms, the 422 and 400 refusals, the token path, the short code in the token field, body-only fields, sessions bound to another user, a repeated authorize, expired, used and revoked sessions, concurrent authorizes by two users binding one, and the 401 for a missing, invalid, foreign-signed or ended session); `TestDeviceHandOverConcurrent` (50 concurrent polls hand one token over with one login event of source `device`; a poll with another `User-Agent` than the initiating device's takes nothing and the device still collects its token; a deactivated or banned user's session becomes revoked); `TestDeviceAccessRevocation` (a password change revokes the user's confirmed sessions and keeps used ones and other users' sessions, and a session authorized before `tokens_valid_after` hands nothing over without the event); `TestDeviceListAndRevoke` (only confirmed and used sessions, newest activity first, `is_current`, the display name, an empty list as `[]`, 404 for foreign and unknown ids, a repeated revoke, a revoked device signed out); `TestDeviceRevokeAfterRefresh` (the row follows one and two refreshes, a revoke ends the whole token lineage and the blacklist entry outlives the refresh window, a failed refresh leaves the row); `TestDeviceRevokeRacesRefresh` (a refresh of a session revoked after the blacklist check is refused without a token, and a device refreshing in a tight loop while it is revoked ends with its last token refused); `TestDeviceTokensAtRest` (the token column is the SHA-256 hex, no column holds the token or the session JWT). `TestDeviceAuthSessionsMigration` (in `updates`) checks the table's PHP column names plus `jwt_jti`, the types, the unique `token` and `short_code`, the status CHECK, the indexes, the cascade on user delete, the rollback and a second up. The plan 01 smoke tests `TestDeviceCodesSmoke`, `TestDeviceFlowSmoke` and `TestDeviceRevokeSmoke` stay.
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

@@ -15,23 +15,29 @@ import (
// a built-in (read, write, ai) or one a plugin declared through
// pact.HasFrontendTokenScopes. Label and Description are phrase keys
// (containing "::") or literal text; PluginID is the declaring plugin, empty
// for a built-in.
// for a built-in. Implies is empty on a built-in. On a declared scope it is
// empty, read, or write: an OAuth MCP grant of that stock scope also
// satisfies the declared name. The name is not stored on the grant.
type APITokenScope struct {
Name string
Label string
Description string
PluginID string
BuiltIn bool
Implies string
}
// APITokenScopeCatalog is the compiled list of member token scopes of one
// application: the built-ins first, then the declared scopes in activation
// order. Boot publishes it; APITokenScopesFor reads it.
// order. Boot publishes it, and the same value as pact.MemberTokenScopeCatalog.
// APITokenScopesFor reads the concrete catalog.
type APITokenScopeCatalog struct {
scopes []APITokenScope
byName map[string]int
}
var _ pact.MemberTokenScopeCatalog = (*APITokenScopeCatalog)(nil)
// Scopes returns a copy of the catalog, built-ins first.
func (c *APITokenScopeCatalog) Scopes() []APITokenScope {
if c == nil {
@@ -49,6 +55,23 @@ func (c *APITokenScopeCatalog) Allowed(name string) bool {
return ok
}
// MemberTokenScopes returns the compiled list as pact scopes, built-ins
// first, then declared scopes. Implied names are not a separate entry: they
// are the Implies field of the declared scope.
func (c *APITokenScopeCatalog) MemberTokenScopes() []pact.FrontendTokenScope {
scopes := c.Scopes()
out := make([]pact.FrontendTokenScope, 0, len(scopes))
for _, scope := range scopes {
out = append(out, pact.FrontendTokenScope{
Name: scope.Name,
Label: scope.Label,
Description: scope.Description,
Implies: scope.Implies,
})
}
return out
}
// builtInAPITokenScopeKeys are the label and description phrase keys of
// the built-in scopes, in AllowedAPITokenScopes order.
var builtInAPITokenScopeKeys = map[string][2]string{
@@ -78,12 +101,14 @@ func newAPITokenScopeCatalog(scopes []APITokenScope) *APITokenScopeCatalog {
// CompileAPITokenScopes builds the member token scope catalog from the
// built-ins and every plugin that implements pact.HasFrontendTokenScopes, in
// the order given (party.Activated). A declared name is trimmed and must
// pass pact.ValidTokenScopeName, must not be a built-in (read, write, ai)
// and must not repeat a name another plugin (or the same one) declared; its
// label and description must not be empty. Any violation is an error, which
// fails boot. Phrase keys are checked against the translator by Boot. Every
// name of a catalog that compiles is added to the process-wide set the
// legacy MintAPIToken accepts.
// pass pact.ValidTokenScopeName, must not be a built-in (read, write, ai),
// must not be the reserved name backend or offline_access, and must not
// repeat a name another plugin (or the same one) declared; its label and
// description must not be empty, and Implies must be empty, read, or write.
// Built-ins keep Implies empty. Any violation is an error, which fails boot.
// Phrase keys are checked against the translator by Boot. Every name of a
// catalog that compiles is added to the process-wide set the legacy
// MintAPIToken accepts.
func CompileAPITokenScopes(plugins []party.Plugin) (*APITokenScopeCatalog, error) {
scopes := builtInAPITokenScopes()
owner := map[string]string{}
@@ -111,14 +136,22 @@ func CompileAPITokenScopes(plugins []party.Plugin) (*APITokenScopeCatalog, error
}
return nil, fmt.Errorf("golem15.user: duplicate member token scope %q declared by %s and %s", name, other, id)
}
if name == "backend" || name == "offline_access" {
return nil, fmt.Errorf("golem15.user: plugin %s declared the reserved member token scope %q", id, name)
}
if strings.TrimSpace(scope.Label) == "" {
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has an empty label", id, name)
}
if strings.TrimSpace(scope.Description) == "" {
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has an empty description", id, name)
}
if scope.Implies != "" && scope.Implies != "read" && scope.Implies != "write" {
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has implies %q", id, name, scope.Implies)
}
owner[name] = id
scopes = append(scopes, APITokenScope{Name: name, Label: scope.Label, Description: scope.Description, PluginID: id})
scopes = append(scopes, APITokenScope{
Name: name, Label: scope.Label, Description: scope.Description, PluginID: id, Implies: scope.Implies,
})
declared = append(declared, name)
}
}

View File

@@ -15,8 +15,11 @@ import (
// recovery-code alphabet: no 0, 1, I, L or O).
const DeviceShortCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
// defaultDeviceAuthURL is golem15.user.device_auth.auth_url when unset.
const defaultDeviceAuthURL = "{frontend_url}/device/scan/{token}"
// defaultDeviceAuthURL is golem15.user.device_auth.auth_url when unset. It
// carries the short code, never the device token: the token is the secret
// the device polls the session token with, and a QR code, a scan URL in a
// browser history or a Referer must not hand it to an onlooker.
const defaultDeviceAuthURL = "{frontend_url}/device/scan/{short_code}"
// defaultDeviceAuthTTL is golem15.user.device_auth.ttl_minutes when unset.
const defaultDeviceAuthTTL = 5 * time.Minute
@@ -88,12 +91,21 @@ func DeviceAuthTTL(app *backpack.App) time.Duration {
return defaultDeviceAuthTTL
}
// DeviceAuthURL is the auth_url a new device shows as its QR code: the
// template golem15.user.device_auth.auth_url (default
// {frontend_url}/device/scan/{token}) with {frontend_url} replaced by
// FrontendURL (golem15.user.frontend_url, else app.url, no trailing slash)
// and {token} by the device token.
// DeviceAuthURL is DeviceAuthURLFor without a short code: {short_code}
// is filled with the device token, as the template's only key.
func DeviceAuthURL(app *backpack.App, token string) string {
return DeviceAuthURLFor(app, token, "")
}
// DeviceAuthURLFor is the auth_url a new device shows as its QR code: the
// template golem15.user.device_auth.auth_url (default
// {frontend_url}/device/scan/{short_code}) with {frontend_url} replaced by
// FrontendURL (golem15.user.frontend_url, else app.url, no trailing slash),
// {short_code} by the session's short code and {token} by the device
// token. POST /devices/authorize accepts the short code in its token field
// too, so a page that posts what its URL carries as token works with
// either template. An empty shortCode fills {short_code} with the token.
func DeviceAuthURLFor(app *backpack.App, token, shortCode string) string {
tpl := ""
if app != nil && app.Config != nil {
tpl = strings.TrimSpace(app.Config.String("golem15.user.device_auth.auth_url"))
@@ -101,7 +113,10 @@ func DeviceAuthURL(app *backpack.App, token string) string {
if tpl == "" {
tpl = defaultDeviceAuthURL
}
return strings.NewReplacer("{frontend_url}", FrontendURL(app), "{token}", token).Replace(tpl)
if shortCode == "" {
shortCode = token
}
return strings.NewReplacer("{frontend_url}", FrontendURL(app), "{short_code}", shortCode, "{token}", token).Replace(tpl)
}
// DeviceDisplayName is the name a device list shows (PHP's device_name
@@ -132,13 +147,55 @@ func DeviceDisplayName(stored *string, userAgent string) string {
// HandOverDeviceJTI moves a used device session from the jti of a session
// token to the jti of the token that refreshed it, and stamps its
// last_activity_at, so revoking the device still reaches the token the
// device holds now. POST /refresh calls it after a successful refresh;
// device holds now. It is FollowDeviceJTI without the revoked report;
// empty values do nothing.
func HandOverDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) error {
if db == nil || oldJTI == "" || newJTI == "" || oldJTI == newJTI {
_, err := FollowDeviceJTI(ctx, db, oldJTI, newJTI)
return err
}
// RevokeDeviceSessions marks the pending and confirmed device sessions of
// userIDs revoked, so none of them hands a session token over any more.
// Used sessions keep their status: their tokens end through
// tokens_valid_after or a revoke. The plugin calls it on db, the write's
// transaction, for every AccessRevokedEvent (a ban or a password change).
func RevokeDeviceSessions(ctx context.Context, db *gorm.DB, userIDs ...uint) error {
if db == nil || len(userIDs) == 0 {
return nil
}
return db.WithContext(ctx).Exec(`UPDATE golem15_user_device_auth_sessions
SET jwt_jti = ?, last_activity_at = NOW(), updated_at = NOW()
WHERE jwt_jti = ? AND status = 'used'`, newJTI, oldJTI).Error
SET status = 'revoked', updated_at = NOW()
WHERE user_id IN ? AND status IN ('pending', 'confirmed')`, userIDs).Error
}
// FollowDeviceJTI moves a used device session from oldJTI, the jti of the
// session token a refresh presented, to newJTI, the jti of the token it
// minted, and stamps last_activity_at. It reports revoked when no used
// session moved but a session with oldJTI exists in another status: the
// device was revoked after the refresh passed the blacklist check, so the
// caller must refuse the refresh and drop the new token. The move is one
// guarded UPDATE that takes the session's row lock, as the revoke's
// UPDATE … RETURNING jwt_jti does (controllers.DeviceDestroy): a revoke
// that runs after the move blacklists newJTI, and a move that runs after
// the revoke matches nothing and reports revoked. POST /refresh calls it
// and fails closed on an error. Empty or equal values do nothing.
func FollowDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) (revoked bool, err error) {
if db == nil || oldJTI == "" || newJTI == "" || oldJTI == newJTI {
return false, nil
}
res := db.WithContext(ctx).Exec(`UPDATE golem15_user_device_auth_sessions
SET jwt_jti = ?, last_activity_at = NOW(), updated_at = NOW()
WHERE jwt_jti = ? AND status = 'used'`, newJTI, oldJTI)
if res.Error != nil {
return false, res.Error
}
if res.RowsAffected > 0 {
return false, nil
}
var stale int64
if err := db.WithContext(ctx).Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions
WHERE jwt_jti = ? AND status <> 'used'`, oldJTI).Scan(&stale).Error; err != nil {
return false, err
}
return stale > 0, nil
}

129
classes/device_auth_test.go Normal file
View File

@@ -0,0 +1,129 @@
package classes
import (
"crypto/sha256"
"encoding/hex"
"regexp"
"strings"
"testing"
)
// TestDeviceCodes pins the device token and short code (D-11, D-06)
// without a database: 10,000 tokens are 64 base64url characters, all
// distinct, and their stored form is the SHA-256 hex; 10,000 short codes
// are XXXX-XXXX over DeviceShortCodeAlphabet with every character of the
// alphabet in every position and no character far from its expected
// share; NormalizeDeviceShortCode accepts case, spaces and a missing
// hyphen and refuses lengths other than eight and the excluded letters.
func TestDeviceCodes(t *testing.T) {
const draws = 10000
t.Run("tokens", func(t *testing.T) {
seen := make(map[string]struct{}, draws)
for range draws {
tok, err := NewDeviceToken()
if err != nil {
t.Fatal(err)
}
if len(tok) != 64 {
t.Fatalf("token %q has %d characters", tok, len(tok))
}
if strings.Trim(tok, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" {
t.Fatalf("token %q is not base64url", tok)
}
if _, dup := seen[tok]; dup {
t.Fatalf("token %q drawn twice", tok)
}
seen[tok] = struct{}{}
}
tok, _ := NewDeviceToken()
sum := sha256.Sum256([]byte(tok))
if got := HashAPIToken(tok); got != hex.EncodeToString(sum[:]) || len(got) != 64 || got == tok {
t.Fatalf("stored form %q is not the SHA-256 hex of the token", got)
}
})
t.Run("short_codes", func(t *testing.T) {
alphabet := DeviceShortCodeAlphabet
if alphabet != "23456789ABCDEFGHJKMNPQRSTUVWXYZ" || len(alphabet) != 31 {
t.Fatalf("alphabet = %q", alphabet)
}
format := regexp.MustCompile(`^[` + alphabet + `]{4}-[` + alphabet + `]{4}$`)
perPosition := make([]map[byte]int, 8)
for i := range perPosition {
perPosition[i] = map[byte]int{}
}
total := map[byte]int{}
seen := map[string]struct{}{}
for range draws {
code, err := NewDeviceShortCode()
if err != nil {
t.Fatal(err)
}
if !format.MatchString(code) {
t.Fatalf("short code %q", code)
}
seen[code] = struct{}{}
raw := code[:4] + code[5:]
for i := 0; i < 8; i++ {
perPosition[i][raw[i]]++
total[raw[i]]++
}
}
// 31^8 codes: 10,000 draws repeating more than a handful would mean
// a broken generator.
if len(seen) < draws-5 {
t.Fatalf("%d distinct codes in %d draws", len(seen), draws)
}
for i, counts := range perPosition {
for j := 0; j < len(alphabet); j++ {
if counts[alphabet[j]] == 0 {
t.Fatalf("character %q never drawn at position %d", alphabet[j], i)
}
}
}
// Each character is expected 80000/31 ≈ 2581 times; a modulo bias
// of the byte draw would push the first 8 characters ~3% up. Allow
// 10% either way, far outside the noise of a fair draw.
expected := float64(8*draws) / float64(len(alphabet))
for j := 0; j < len(alphabet); j++ {
got := float64(total[alphabet[j]])
if got < expected*0.9 || got > expected*1.1 {
t.Fatalf("character %q drawn %v times, expected about %.0f", alphabet[j], got, expected)
}
}
for _, excluded := range "01ILO" {
if total[byte(excluded)] != 0 {
t.Fatalf("excluded character %q drawn", excluded)
}
}
})
t.Run("normalization", func(t *testing.T) {
for _, c := range []struct{ in, want string }{
{"ABCD-EFGH", "ABCD-EFGH"},
{"abcd-efgh", "ABCD-EFGH"},
{"AbCd-eFgH", "ABCD-EFGH"},
{"ABCDEFGH", "ABCD-EFGH"},
{"abcdefgh", "ABCD-EFGH"},
{" ab cd ef gh ", "ABCD-EFGH"},
{"ABCD - EFGH", "ABCD-EFGH"},
{"A-B-C-D-E-F-G-H", "ABCD-EFGH"},
{"2345-6789", "2345-6789"},
{"zzzz zzzz", "ZZZZ-ZZZZ"},
} {
if got, ok := NormalizeDeviceShortCode(c.in); !ok || got != c.want {
t.Errorf("normalize %q = %q, %v; want %q", c.in, got, ok, c.want)
}
}
for _, in := range []string{
"", "-", "ABCDEFG", "ABCD-EFG", "ABCDEFGHJ", "ABCD-EFGHJ",
"ABCDEFG0", "ABCDEFG1", "ABCDEFGI", "ABCDEFGL", "ABCDEFGO", "abcdefgo", "abcdefgl", "abcdefgi",
"ABCD_EFGH", "ABCD.EFGH", "ABCDÉFGH", "ABCD\tEFGH",
} {
if got, ok := NormalizeDeviceShortCode(in); ok {
t.Errorf("normalize %q accepted as %q", in, got)
}
}
})
}

View File

@@ -84,12 +84,16 @@ func (g *MCPUserGuard) MCPRequirements(middleware []string) (pact.MCPRequirement
return req, nil
}
// MCPAllows requires every scope of req in the subject's scopes, then every
// permission code of req and of the tool, one by one (the route runs one
// user.permission middleware per code), granted to the user's groups.
// MCPAllows requires every scope of req in the subject's effective scopes,
// then every permission code of req and of the tool, one by one (the route
// runs one user.permission middleware per code), granted to the user's
// groups. Effective scopes are the grant itself for a personal token, and
// for an OAuth subject also the declared names the catalog implies. The
// expanded list is not written back onto the subject.
func (g *MCPUserGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string) (bool, error) {
scopes := g.mcpEffectiveScopes(subject)
for _, scope := range req.Scopes {
if !slices.Contains(subject.Scopes, scope) {
if !slices.Contains(scopes, scope) {
return false, nil
}
}
@@ -114,9 +118,16 @@ func (g *MCPUserGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, r
// MCPDispatch builds the principal (the user with the group grants, as the
// api_token guard loads it) and an in-memory token credential carrying the
// session's scopes, so user.scope sees what the session was granted. The
// credential has ID 0 and is never saved. The marker is honoured by the
// api_token guard only.
// subject's effective scopes, so user.scope sees what the session may call.
// For an OAuth subject that includes declared names the catalog marks as
// implied by a stock scope the grant holds; a personal token's scopes are
// the grant unchanged. For a personal-token subject (ClientID empty) the
// credential's ID is the subject's TokenID, the user_api_tokens row the
// session was opened with, so a handler can record which token made a
// change; an OAuth subject's TokenID is the OAuth server's own token row,
// not one of this plugin's, and its credential keeps ID 0. The credential
// is never saved, and implied names are not written onto a token row or
// onto subject.Scopes. The marker is honoured by the api_token guard only.
func (g *MCPUserGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
db, err := g.db()
if err != nil {
@@ -131,11 +142,38 @@ func (g *MCPUserGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject,
}
credential := &models.APIToken{
UserID: subject.UserID,
Scopes: lagoon.Jsonable[[]string]{Data: slices.Clone(subject.Scopes), Valid: true},
Scopes: lagoon.Jsonable[[]string]{Data: g.mcpEffectiveScopes(subject), Valid: true},
}
if subject.ClientID == "" {
credential.ID = subject.TokenID
}
return pact.MCPDispatch{Principal: principal, Credential: credential, Guards: []string{"api_token"}}, nil
}
// mcpEffectiveScopes is the scope list MCPAllows and MCPDispatch share.
// A personal token (ClientID empty) is returned unchanged: the same slice,
// with no declared name added. An OAuth subject gets a clone of its grant
// plus each catalog name whose Implies is read or write, when the clone
// holds that stock scope and not the name. write does not satisfy Implies
// read. Nothing the catalog does not mark is appended. The clone is not
// written back onto subject.Scopes, a token row, or a fingerprint.
func (g *MCPUserGuard) mcpEffectiveScopes(subject pact.MCPSubject) []string {
if subject.ClientID == "" {
return subject.Scopes
}
scopes := slices.Clone(subject.Scopes)
for _, entry := range APITokenScopesFor(g.app()).MemberTokenScopes() {
if entry.Implies != "read" && entry.Implies != "write" {
continue
}
if entry.Name == "" || slices.Contains(scopes, entry.Name) || !slices.Contains(scopes, entry.Implies) {
continue
}
scopes = append(scopes, entry.Name)
}
return scopes
}
// MCPResolvePersonalToken resolves a personal API token of this plugin into
// an MCP subject with the token's own scopes. A token without the configured
// prefix answers false; one with it that is unknown, revoked, expired or

View File

@@ -45,8 +45,11 @@ frontend_url: ""
device_auth:
# The page a new device shows as its QR code, where a signed-in user
# authorizes it. {frontend_url} is frontend_url (else app.url, no trailing
# slash) and {token} the device token.
auth_url: "{frontend_url}/device/scan/{token}"
# slash), {short_code} the session's short code and {token} the device
# token. Keep {token} out of it: the device token is also the secret the
# device collects its session token with, and whoever sees the QR code or
# the page's URL could take the session.
auth_url: "{frontend_url}/device/scan/{short_code}"
# Minutes a device session can be authorized and handed over.
ttl_minutes: 5
backend_links:

View File

@@ -300,29 +300,52 @@ func Refresh(app *backpack.App) http.HandlerFunc {
})
return
}
// A device session follows its token through refresh (D-12b), so a
// revoke blacklists the token the device holds now. Reading the old
// jti unverified is safe: bouncer.Refresh has just verified it. The
// hand-over and the revoke take the session's row lock, so whichever
// runs second sees the other: a device revoked after bouncer.Refresh
// checked the blacklist is refused here with the blacklisted-token
// answer and the new token is never sent. The hand-over fails
// closed: a database error answers the opaque 500 (the presented
// token is already rotated out), never a token the device row does
// not follow. With golem15.user.jwt.blacklist_grace above 0 two
// concurrent refreshes of one token can fork a device's lineage and
// only one fork is followed; a password change still ends both
// (tokens_valid_after). Log lines carry no token or jti.
if oldJTI := unverifiedJTI(raw); oldJTI != "" {
_, _, _, newJTI, err := bouncer.VerifyClaims(next, secret)
if err != nil {
pluginLog.Error("golem15.user: read the refreshed token", "user_id", userID, "err", err)
writeOpaque500(w)
return
}
revoked, err := classes.FollowDeviceJTI(r.Context(), gdb, oldJTI, newJTI)
if err != nil {
pluginLog.Error("golem15.user: hand a device session over on refresh", "user_id", userID, "err", err)
writeOpaque500(w)
return
}
if revoked {
writeJSON(w, http.StatusUnauthorized, map[string]any{
"error": "Could not refresh token",
"msg": refreshBlacklistedMsg,
})
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)
}
// A device session follows its token through refresh (D-12b), so a
// revoke blacklists the token the device holds now. Reading the old
// jti unverified is safe: bouncer.Refresh has just verified it. With
// golem15.user.jwt.blacklist_grace above 0 two concurrent refreshes of
// one token can fork a device's lineage and only one fork is
// followed; a password change still ends both (tokens_valid_after).
// A failure is logged without any token or jti and never fails the
// refresh.
if oldJTI := unverifiedJTI(raw); oldJTI != "" {
if _, _, _, newJTI, err := bouncer.VerifyClaims(next, secret); err == nil {
if err := classes.HandOverDeviceJTI(r.Context(), gdb, oldJTI, newJTI); err != nil {
pluginLog.Warn("golem15.user: hand a device session over on refresh", "user_id", userID, "err", err)
}
}
}
writeSession(w, app, rotate, next, map[string]any{})
}
}
// refreshBlacklistedMsg is bouncer.Refresh's message for a blacklisted
// token, which POST /refresh also answers for a revoked device session.
const refreshBlacklistedMsg = "The token has been blacklisted"
// unverifiedJTI returns the jti claim of a token without verifying it, ""
// when there is none. Only call it on a token that was verified already.
func unverifiedJTI(raw string) string {

View File

@@ -149,7 +149,7 @@ func DeviceInitiate(app *backpack.App) http.HandlerFunc {
"data": map[string]any{
"token": token,
"short_code": *row.ShortCode,
"auth_url": classes.DeviceAuthURL(app, token),
"auth_url": classes.DeviceAuthURLFor(app, token, *row.ShortCode),
"expires_at": wire.Time{Time: expires},
},
})
@@ -166,7 +166,10 @@ func DeviceInitiate(app *backpack.App) http.HandlerFunc {
// guarded confirmed-to-used UPDATE decides which poll that is, so of
// concurrent polls exactly one gets the token; the others, and every later
// poll, answer {"success":true,"data":{"status":"used"}}. Other statuses
// answer {"success":true,"data":{"status":…}}.
// answer {"success":true,"data":{"status":…}}. Only a poll with the
// User-Agent the device initiated with receives the token: another client
// that learned the device token answers {"status":"confirmed"} and takes
// nothing, so the device still collects its session.
func DeviceStatus(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
@@ -182,7 +185,7 @@ func DeviceStatus(app *backpack.App) http.HandlerFunc {
writeJSON(w, http.StatusNotFound, map[string]any{"error": "Session not found"})
return
}
if row.Status == models.DeviceStatusConfirmed && row.UserID != nil && !row.ExpiredAt(time.Now()) {
if row.Status == models.DeviceStatusConfirmed && row.UserID != nil && !row.ExpiredAt(time.Now()) && sameDeviceClient(r, row) {
if handOverDevice(w, r, app, gdb, secret, row) {
return
}
@@ -198,6 +201,17 @@ func DeviceStatus(app *backpack.App) http.HandlerFunc {
}
}
// sameDeviceClient reports whether r comes with the User-Agent the device
// initiated the session with (none for none), the binding of the token
// hand-over to the initiating client.
func sameDeviceClient(r *http.Request, row models.DeviceAuthSession) bool {
stored := ""
if row.DeviceUserAgent != nil {
stored = *row.DeviceUserAgent
}
return r.UserAgent() == stored
}
// deviceByToken loads the session whose token hash matches token.
func deviceByToken(r *http.Request, gdb *gorm.DB, token string) (models.DeviceAuthSession, bool, error) {
var row models.DeviceAuthSession
@@ -241,7 +255,10 @@ func writeDeviceStatus(w http.ResponseWriter, r *http.Request, gdb *gorm.DB, row
// handOverDevice gives the confirmed session's user a session token for the
// device (D-12a). It reports false, having written nothing, when another
// poll already took the session or it expired meanwhile; the token it
// minted is then dropped unsent.
// minted is then dropped unsent. A user who is gone, deactivated or
// banned, or whose tokens_valid_after (a password change, a revocation)
// is later than the authorization, gets nothing: the session becomes
// revoked, as a token minted at the authorization would be refused.
func handOverDevice(w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, secret string, row models.DeviceAuthSession) bool {
ctx := r.Context()
var user models.User
@@ -257,7 +274,7 @@ func handOverDevice(w http.ResponseWriter, r *http.Request, app *backpack.App, g
writeOpaque500(w)
return true
}
refused = banned[user.ID]
refused = banned[user.ID] || authorizedBeforeCutoff(&user, row)
}
if refused {
// A deactivated, deleted or banned user signs no device in.
@@ -307,10 +324,22 @@ func handOverDevice(w http.ResponseWriter, r *http.Request, app *backpack.App, g
return true
}
// authorizedBeforeCutoff reports whether row was confirmed before the
// user's tokens_valid_after, compared as the jwt guard compares a token's
// whole-second iat.
func authorizedBeforeCutoff(user *models.User, row models.DeviceAuthSession) bool {
if user.TokensValidAfter == nil || row.ConfirmedAt == nil {
return false
}
return row.ConfirmedAt.Truncate(time.Second).Before(*user.TokensValidAfter)
}
// DeviceAuthorize is POST /devices/authorize: a signed-in user (a valid,
// not blacklisted session token; no second-factor check, D-12c) binds a
// pending device session to themselves by its short_code (hyphens and
// spaces optional, any case) or by its token (the QR code). A short_code of
// spaces optional, any case) or by its token (the QR code page). The token
// field also takes the short code, which the default auth_url carries in
// place of the device token. A short_code of
// other than 8 characters after removing hyphens answers 422 {"error":
// "Please enter a valid 8-character code"}; an unknown, expired, used,
// revoked or foreign session answers 400 {"error":"Invalid or expired
@@ -358,10 +387,22 @@ func DeviceAuthorize(app *backpack.App) http.HandlerFunc {
}
found = res.RowsAffected == 1
} else if _, has := fields["token"]; has {
row, found, err = deviceByToken(r, gdb, strField(fields, "token"))
if err != nil {
writeOpaque500(w)
return
raw := strField(fields, "token")
if code, isCode := classes.NormalizeDeviceShortCode(raw); isCode {
// The QR page of the default auth_url carries the short
// code; a 64-character device token never normalizes.
res := gdb.WithContext(r.Context()).Where("short_code = ?", code).Limit(1).Find(&row)
if res.Error != nil {
writeOpaque500(w)
return
}
found = res.RowsAffected == 1
} else {
row, found, err = deviceByToken(r, gdb, raw)
if err != nil {
writeOpaque500(w)
return
}
}
}
if !found {
@@ -458,12 +499,15 @@ func DeviceIndex(app *backpack.App) http.HandlerFunc {
// device sessions. In one transaction the row is marked revoked and the jti
// of the session token the device holds (followed through refresh) is
// added to the JWT blacklist for the token's whole refresh window, so the
// device is signed out at once and cannot refresh. A blacklist failure
// answers the opaque 500 and leaves the row as it was. Success answers
// PHP's body with success true and the message Device revoked
// successfully, also for a row already revoked; a row of another user, or
// none, answers 404 with the error Device not found; no session answers
// 401 {"error":"Unauthorized"}.
// device is signed out at once and cannot refresh. The jti is the one the
// revoking UPDATE returns, read under the row lock a concurrent refresh's
// hand-over also takes (classes.FollowDeviceJTI), so a refresh that moved
// the session first is blacklisted and one that comes after is refused. A
// blacklist failure answers the opaque 500 and leaves the row as it was.
// Success answers PHP's body with success true and the message Device
// revoked successfully, also for a row already revoked; a row of another
// user, or none, answers 404 with the error Device not found; no session
// answers 401 {"error":"Unauthorized"}.
func DeviceDestroy(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
@@ -480,22 +524,24 @@ func DeviceDestroy(app *backpack.App) http.HandlerFunc {
notFound()
return
}
var row models.DeviceAuthSession
res := gdb.WithContext(r.Context()).Where("id = ? AND user_id = ?", id, user.ID).Limit(1).Find(&row)
if res.Error != nil {
writeOpaque500(w)
return
}
if res.RowsAffected == 0 {
notFound()
return
}
found := false
err = lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.WithContext(ctx).Exec(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW()
WHERE id = ? AND user_id = ?`, row.ID, user.ID).Error; err != nil {
// The jti current at the revoke, under the row lock: never one
// read before a concurrent refresh moved the session on.
var revoked []struct {
JWTJTI *string `gorm:"column:jwt_jti"`
}
if err := tx.WithContext(ctx).Raw(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW()
WHERE id = ? AND user_id = ?
RETURNING jwt_jti`, id, user.ID).Scan(&revoked).Error; err != nil {
return err
}
if row.JWTJTI == nil || *row.JWTJTI == "" {
if len(revoked) == 0 {
return nil
}
found = true
jti := revoked[0].JWTJTI
if jti == nil || *jti == "" {
return nil
}
bl, ok := app.Lookup[bouncer.BlacklistStore]()
@@ -503,13 +549,17 @@ func DeviceDestroy(app *backpack.App) http.HandlerFunc {
return errors.New("golem15.user: no JWT blacklist store is published")
}
now := time.Now()
return bl.Add(ctx, *row.JWTJTI, now.Add(refreshTTL(app)+sessionTTL(app)+time.Minute), now)
return bl.Add(ctx, *jti, now.Add(refreshTTL(app)+sessionTTL(app)+time.Minute), now)
})
if err != nil {
pluginLog.Error("golem15.user: revoke device session", "user_id", user.ID, "err", err)
writeOpaque500(w)
return
}
if !found {
notFound()
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "Device revoked successfully"})
}
}

View File

@@ -798,7 +798,10 @@ func (c usersAdminController) AdminRecordActions() []pact.AdminRecordAction {
// resetTwoFactorAction builds the reset_two_factor record action with the
// shared action builder, behind classes.PermissionResetTwoFactor and marked
// as dangerous.
// as dangerous. A member of a privileged group keeps the takeover guard of
// a password change and a delete: without
// golem15.users.manage_privileged_groups Run refuses with a 403 and its
// message before Reset runs, inside the action's transaction.
func (c usersAdminController) resetTwoFactorAction(action func(name, label, confirm, success string,
applies func(ctx context.Context, user *models.User) (bool, error),
run func(ctx context.Context, tx *gorm.DB, user *models.User) error,
@@ -820,6 +823,9 @@ func (c usersAdminController) resetTwoFactorAction(action func(name, label, conf
if !ok {
return errors.New("users admin: no two-factor plugin publishes classes.TwoFactorAdmin")
}
if err := c.guardPrivilegedMember(ctx, user.ID, usersLang+"privileged_member_reset_two_factor_forbidden", nil); err != nil {
return err
}
return svc.Reset(ctx, tx, user)
})
reset.Permissions = []string{classes.PermissionResetTwoFactor}

1318
device_auth_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -202,7 +202,7 @@ func TestDeviceFlowSmoke(t *testing.T) {
}
dev := initiateDevice(t, app, "Living room TV")
if len(dev.Token) != 64 || !deviceShortCodeRE.MatchString(dev.ShortCode) || !strings.Contains(dev.AuthURL, dev.Token) || !strings.HasSuffix(dev.ExpiresAt, "+00:00") {
if len(dev.Token) != 64 || !deviceShortCodeRE.MatchString(dev.ShortCode) || !strings.HasSuffix(dev.AuthURL, "/device/scan/"+dev.ShortCode) || strings.Contains(dev.AuthURL, dev.Token) || !strings.HasSuffix(dev.ExpiresAt, "+00:00") {
t.Fatalf("initiate data = %+v", dev)
}
row := deviceRow(t, gdb, dev.Token)

12
go.mod
View File

@@ -3,20 +3,20 @@ module git.golem15.com/golem15/sm-user-plugin
go 1.27.0
require (
git.golem15.com/golem15/summercms v0.5.0
git.golem15.com/golem15/summercms v0.5.2
github.com/go-gormigrate/gormigrate/v2 v2.1.7
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.10.0
github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
gocloud.dev v0.46.0
gorm.io/driver/postgres v1.6.3
gorm.io/gorm v1.31.2
)
require (
charm.land/bubbles/v2 v2.2.1 // indirect
charm.land/bubbletea/v2 v2.1.0 // indirect
charm.land/fang/v2 v2.0.1 // indirect
charm.land/huh/v2 v2.0.3 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect
dario.cat/mergo v1.0.2 // indirect
@@ -50,7 +50,6 @@ require (
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
github.com/charmbracelet/x/ansi v0.11.9 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
@@ -120,10 +119,6 @@ require (
github.com/moby/term v0.5.2 // indirect
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/mango v0.1.0 // indirect
github.com/muesli/mango-cobra v1.2.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
@@ -177,7 +172,6 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gorm.io/driver/postgres v1.6.3 // indirect
)
replace git.golem15.com/golem15/summercms => ../../../../summercms.go
replace git.golem15.com/golem15/summercms => ../summercms.go

12
go.sum
View File

@@ -4,8 +4,6 @@ charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
@@ -110,8 +108,6 @@ github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
@@ -299,14 +295,6 @@ github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtw
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=

View File

@@ -58,6 +58,7 @@ users:
reset_two_factor_success: Two-factor authentication of the user has been reset.
privileged_member_forbidden: This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved.
privileged_member_delete_forbidden: Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted.
privileged_member_reset_two_factor_forbidden: This user belongs to a privileged group. Resetting their two-factor authentication needs the permission to manage privileged groups. Nothing was changed.
privileged_group_forbidden: You do not have permission to change membership of privileged groups. Nothing was saved.
user:
label: User

View File

@@ -58,6 +58,7 @@ users:
reset_two_factor_success: Uwierzytelnianie dwuskładnikowe użytkownika zostało zresetowane.
privileged_member_forbidden: Ten użytkownik należy do grupy uprzywilejowanej. Nie masz uprawnień do zmiany jego adresu e-mail ani hasła. Nic nie zostało zapisane.
privileged_member_delete_forbidden: Użytkowników należących do grupy uprzywilejowanej nie można usunąć bez uprawnienia do zarządzania grupami uprzywilejowanymi. Nic nie zostało usunięte.
privileged_member_reset_two_factor_forbidden: Ten użytkownik należy do grupy uprzywilejowanej. Zresetowanie jego uwierzytelniania dwuskładnikowego wymaga uprawnienia do zarządzania grupami uprzywilejowanymi. Nic nie zostało zmienione.
privileged_group_forbidden: Nie masz uprawnień do zmiany członkostwa w grupach uprzywilejowanych. Nic nie zostało zapisane.
user:
label: Użytkownik

401
login_gate_test.go Normal file
View File

@@ -0,0 +1,401 @@
package user
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
// TestLoginGates pins the registry of login gates (D-02) without a
// database: a nil or empty registry allows, gates run in the order they were
// added, the first result wins and stops the chain, an error stops the chain
// and is returned, nil gates are ignored, and Add and Check are safe for
// concurrent use (run with -race). CheckLoginGates allows a sign-in when no
// app or no registry is published.
func TestLoginGates(t *testing.T) {
ctx := context.Background()
attempt := classes.LoginAttempt{Source: classes.LoginSourcePassword}
t.Run("nil_and_empty_allow", func(t *testing.T) {
var nilGates *classes.LoginGates
if res, err := nilGates.Check(ctx, attempt); res != nil || err != nil {
t.Fatalf("nil registry = %v, %v", res, err)
}
nilGates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
t.Fatal("a gate added to a nil registry ran")
return nil, nil
}))
if res, err := classes.NewLoginGates().Check(ctx, attempt); res != nil || err != nil {
t.Fatalf("empty registry = %v, %v", res, err)
}
g := classes.NewLoginGates()
g.Add(nil)
if res, err := g.Check(ctx, attempt); res != nil || err != nil {
t.Fatalf("registry with a nil gate = %v, %v", res, err)
}
})
t.Run("order_and_first_result_wins", func(t *testing.T) {
var order []string
gate := func(name string, res *classes.LoginGateResult) classes.LoginGate {
return gateFunc(func(_ context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
if a.Source != classes.LoginSourcePassword {
t.Fatalf("gate %s saw source %q", name, a.Source)
}
order = append(order, name)
return res, nil
})
}
first := &classes.LoginGateResult{Status: http.StatusOK, Body: map[string]any{"two_factor_required": true}}
second := &classes.LoginGateResult{Status: http.StatusTooManyRequests, Body: map[string]any{"error": "later"}}
g := classes.NewLoginGates()
g.Add(gate("a", nil))
g.Add(gate("b", nil))
g.Add(gate("c", first))
g.Add(gate("d", second))
res, err := g.Check(ctx, attempt)
if err != nil || res != first {
t.Fatalf("Check = %v, %v; want the first result", res, err)
}
if fmt.Sprint(order) != "[a b c]" {
t.Fatalf("gates ran %v, want [a b c]", order)
}
order = nil
all := classes.NewLoginGates()
all.Add(gate("x", nil))
all.Add(gate("y", nil))
if res, err := all.Check(ctx, attempt); res != nil || err != nil {
t.Fatalf("allowing gates = %v, %v", res, err)
}
if fmt.Sprint(order) != "[x y]" {
t.Fatalf("allowing gates ran %v, want [x y]", order)
}
})
t.Run("error_stops_the_chain", func(t *testing.T) {
boom := errors.New("challenge store down")
later := 0
g := classes.NewLoginGates()
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return nil, nil }))
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
// A gate that answers both a result and an error fails closed.
return &classes.LoginGateResult{Body: map[string]any{"ignored": true}}, boom
}))
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
later++
return nil, nil
}))
res, err := g.Check(ctx, attempt)
if res != nil || !errors.Is(err, boom) {
t.Fatalf("erroring gate = %v, %v; want nil and the error", res, err)
}
if later != 0 {
t.Fatal("a gate after the erroring one ran")
}
})
t.Run("concurrent_add_and_check", func(t *testing.T) {
g := classes.NewLoginGates()
var calls atomic.Int64
allow := gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
calls.Add(1)
return nil, nil
})
var wg sync.WaitGroup
for i := range 64 {
wg.Add(1)
go func() {
defer wg.Done()
if i%2 == 0 {
g.Add(allow)
return
}
if res, err := g.Check(ctx, attempt); res != nil || err != nil {
t.Errorf("concurrent Check = %v, %v", res, err)
}
}()
}
wg.Wait()
calls.Store(0)
if res, err := g.Check(ctx, attempt); res != nil || err != nil {
t.Fatalf("Check after the adds = %v, %v", res, err)
}
if calls.Load() != 32 {
t.Fatalf("Check ran %d gates, want the 32 added", calls.Load())
}
})
t.Run("check_login_gates_lookup", func(t *testing.T) {
if res, err := controllers.CheckLoginGates(ctx, nil, attempt); res != nil || err != nil {
t.Fatalf("no app = %v, %v", res, err)
}
app := backpack.New(sessionConfig(t))
if res, err := controllers.CheckLoginGates(ctx, app, attempt); res != nil || err != nil {
t.Fatalf("no published registry = %v, %v", res, err)
}
gates := classes.NewLoginGates()
if err := app.Publish(gates); err != nil {
t.Fatal(err)
}
want := &classes.LoginGateResult{Body: map[string]any{"two_factor_required": true}}
// A gate added after publishing is seen: the registry is looked up
// on every call.
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return want, nil }))
if res, err := controllers.CheckLoginGates(ctx, app, attempt); res != want || err != nil {
t.Fatalf("published registry = %v, %v", res, err)
}
})
}
// countingGate counts its calls, records the attempts it saw and answers
// with result (nil allows).
type countingGate struct {
mu sync.Mutex
calls int
seen []classes.LoginAttempt
result *classes.LoginGateResult
}
func (g *countingGate) CheckLogin(_ context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
g.mu.Lock()
defer g.mu.Unlock()
g.calls++
snapshot := a
if a.User != nil {
user := *a.User
snapshot.User = &user
}
g.seen = append(g.seen, snapshot)
return g.result, nil
}
func (g *countingGate) count() int {
g.mu.Lock()
defer g.mu.Unlock()
return g.calls
}
// gatedEnv boots the plugin through the admin harness with one counting
// gate added to the registry its Register published.
func gatedEnv(t *testing.T, result *classes.LoginGateResult) (*adminEnv, *countingGate) {
t.Helper()
env := newAdminEnv(t)
gates, ok := env.app.Lookup[*classes.LoginGates]()
if !ok || gates == nil {
t.Fatal("the plugin's Register published no *classes.LoginGates")
}
gate := &countingGate{result: result}
gates.Add(gate)
return env, gate
}
// TestLoginGateOrder pins where POST /login consults the gates (D-02, D-18
// A10): after the ban check, the password, the activation and the bcrypt
// rehash, and before the restore of a deactivated account, its reactivate
// mail, the mint and the login event. A gate's answer passes through with
// its status and body; JWT refresh never consults a gate.
func TestLoginGateOrder(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
challenge := &classes.LoginGateResult{Body: map[string]any{
"two_factor_required": true,
"challenge_token": "c",
"available_methods": []string{"totp"},
"expires_at": "2026-10-26T12:00:00+00:00",
}}
t.Run("first_factor_failures_never_reach_the_gate", func(t *testing.T) {
env, gate := gatedEnv(t, challenge)
user := env.seedUser(t, "lgo-wrong", nil)
if status, _ := env.userLogin(t, user.Email, "wrong-password"); status != http.StatusUnauthorized {
t.Fatalf("wrong password = %d", status)
}
if status, _ := env.userLogin(t, "lgo-nobody-"+adminStamp()+"@example.test", "correct-horse"); status != http.StatusUnauthorized {
t.Fatalf("unknown email = %d", status)
}
inactive := env.seedUser(t, "lgo-inactive", func(u *models.User) { u.IsActivated = false })
if status, _ := env.userLogin(t, inactive.Email, "correct-horse"); status != http.StatusUnauthorized {
t.Fatalf("not activated = %d", status)
}
banned := env.seedUser(t, "lgo-banned", nil)
env.seedThrottle(t, banned, "", true, nil)
if status, _ := env.userLogin(t, banned.Email, "correct-horse"); status != http.StatusUnauthorized {
t.Fatalf("banned = %d", status)
}
if gate.count() != 0 {
t.Fatalf("the gate ran %d times for refused first factors", gate.count())
}
})
t.Run("gate_sees_the_attempt", func(t *testing.T) {
env, gate := gatedEnv(t, challenge)
user := env.seedUser(t, "lgo-attempt", nil)
rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{
"email": user.Email, "password": "correct-horse", "trusted_device_token": "td-attempt",
})
if rec.Code != http.StatusOK {
t.Fatalf("login = %d %s", rec.Code, rec.Body.String())
}
if gate.count() != 1 {
t.Fatalf("gate calls = %d, want 1", gate.count())
}
seen := gate.seen[0]
if seen.Source != classes.LoginSourcePassword || seen.User == nil || seen.User.ID != user.ID || seen.Request == nil {
t.Fatalf("attempt = %+v", seen)
}
if seen.Fields["trusted_device_token"] != "td-attempt" || seen.Fields["email"] != user.Email {
t.Fatalf("attempt fields = %#v", seen.Fields)
}
})
t.Run("rehash_before_a_challenge", func(t *testing.T) {
env, gate := gatedEnv(t, challenge)
probe := &passwordProbe{}
env.app.Events.Listen("test.login-gate-order.rehash", func(_ context.Context, ev *classes.AccessRevokedEvent) error {
probe.mu.Lock()
probe.seen = append(probe.seen, *ev)
probe.mu.Unlock()
return nil
})
user := env.seedUser(t, "lgo-rehash", nil)
if err := env.app.Config.Set("golem15.user.password.bcrypt_cost", 11); err != nil {
t.Fatal(err)
}
rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"})
if rec.Code != http.StatusOK || decodeBody(t, rec.Body.Bytes())["two_factor_required"] != true {
t.Fatalf("challenged login = %d %s", rec.Code, rec.Body.String())
}
if gate.count() != 1 {
t.Fatalf("gate calls = %d", gate.count())
}
stored := env.reload(t, user.ID).Password
if stored == user.Password || bouncer.NeedsRehash(stored, 11) || !bouncer.CheckPassword(stored, "correct-horse") {
t.Fatal("a challenged login did not rehash the password at the higher cost")
}
if seen := probe.take(); len(seen) != 0 {
t.Fatalf("the rehash fired %+v", seen)
}
})
t.Run("challenged_soft_deleted_stays_deleted", func(t *testing.T) {
env, gate := gatedEnv(t, challenge)
events := listenLoginEvents(env.app, "test.login-gate-order.soft")
user := env.seedUser(t, "lgo-soft", nil)
if err := env.gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil {
t.Fatal(err)
}
rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"})
body := decodeBody(t, rec.Body.Bytes())
if rec.Code != http.StatusOK || body["two_factor_required"] != true || body["token"] != nil {
t.Fatalf("challenged login = %d %s", rec.Code, rec.Body.String())
}
if !gate.seen[0].User.DeletedAt.Valid {
t.Fatal("the gate saw a restored account; the restore must wait for the gates")
}
if !env.reload(t, user.ID).DeletedAt.Valid {
t.Fatal("a challenged login restored the deactivated account")
}
if mails := env.mails(user.Email); len(mails) != 0 {
t.Fatalf("a challenged login sent %d mails", len(mails))
}
if events.count() != 0 {
t.Fatalf("a challenged login fired %d login events", events.count())
}
})
t.Run("allowed_soft_deleted_restored_once", func(t *testing.T) {
env, gate := gatedEnv(t, nil)
events := listenLoginEvents(env.app, "test.login-gate-order.allowed")
user := env.seedUser(t, "lgo-allowed", nil)
if err := env.gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil {
t.Fatal(err)
}
status, token := env.userLogin(t, user.Email, "correct-horse")
if status != http.StatusOK || token == "" {
t.Fatalf("allowed login = %d", status)
}
if gate.count() != 1 {
t.Fatalf("gate calls = %d", gate.count())
}
if env.reload(t, user.ID).DeletedAt.Valid {
t.Fatal("an allowed login did not restore the account")
}
if mails := env.mails(user.Email); len(mails) != 1 {
t.Fatalf("reactivate mails = %d, want 1", len(mails))
}
if events.count() != 1 || events.got[0].Source != classes.LoginSourcePassword || events.got[0].User.ID != user.ID {
t.Fatalf("login events = %+v", events.got)
}
})
t.Run("gate_answer_passes_through", func(t *testing.T) {
for _, c := range []struct {
name string
result *classes.LoginGateResult
status int
body string
}{
{"status_zero_is_200", &classes.LoginGateResult{Body: map[string]any{"two_factor_required": true}}, http.StatusOK, `{"two_factor_required":true}`},
{"refusal", &classes.LoginGateResult{Status: http.StatusUnauthorized, Body: map[string]any{"error": "Invalid or expired challenge."}}, http.StatusUnauthorized, `{"error":"Invalid or expired challenge."}`},
{"other_status", &classes.LoginGateResult{Status: http.StatusTooManyRequests, Body: map[string]any{"error": "slow down", "retry": 3}}, http.StatusTooManyRequests, `{"error":"slow down","retry":3}`},
} {
t.Run(c.name, func(t *testing.T) {
env, _ := gatedEnv(t, c.result)
events := listenLoginEvents(env.app, "test.login-gate-order."+c.name)
user := env.seedUser(t, "lgo-"+c.name, nil)
rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"})
if rec.Code != c.status || rec.Body.String() != c.body {
t.Fatalf("answer = %d %s, want %d %s", rec.Code, rec.Body.String(), c.status, c.body)
}
if events.count() != 0 {
t.Fatal("a gate answer fired a login event")
}
if env.reload(t, user.ID).LastSeen != nil {
t.Fatal("a gate answer stamped last_seen")
}
})
}
})
t.Run("refresh_never_consults_a_gate", func(t *testing.T) {
env, gate := gatedEnv(t, nil)
user := env.seedUser(t, "lgo-refresh", nil)
status, token := env.userLogin(t, user.Email, "correct-horse")
if status != http.StatusOK {
t.Fatalf("login = %d", status)
}
if gate.count() != 1 {
t.Fatalf("gate calls after login = %d", gate.count())
}
// From now on the gate would challenge: refresh must not ask it.
gate.mu.Lock()
gate.result = challenge
gate.mu.Unlock()
for range 2 {
rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/refresh", token, nil)
var body struct {
Token string `json:"token"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" {
t.Fatalf("refresh body %s: %v", rec.Body.String(), err)
}
token = body.Token
}
if gate.count() != 1 {
t.Fatalf("refresh consulted the gate: %d calls", gate.count())
}
})
}

2
mcp.go
View File

@@ -15,7 +15,7 @@ func (p *Plugin) MCP() pact.MCP {
return pact.MCP{Tools: []pact.MCPTool{{
Name: "me",
Title: "Current user",
Description: "Returns the profile of the signed-in frontend user.",
Description: "Returns the profile of the signed-in frontend user. Needs scope read.",
Guard: "user",
Method: http.MethodGet,
Path: "/_user/api/v1/token/me",

View File

@@ -21,7 +21,9 @@ import (
// do expired ones; and the in-memory credential MCPDispatch builds passes
// the route's own user.scope:read middleware when the subject was granted
// read and is refused (403) when it was not, through the plugin's real
// route chain in-process. Skips under -short.
// route chain in-process; that credential carries a personal token's id
// (the subject's TokenID) and keeps ID 0 for an OAuth subject. Skips under
// -short.
func TestUserMCPGuard(t *testing.T) {
env := newAdminEnv(t)
ctx := context.Background()
@@ -100,10 +102,63 @@ func TestUserMCPGuard(t *testing.T) {
t.Fatal("the dispatch credential is not an in-memory API token")
}
})
t.Run("dispatch credential carries the personal token id", func(t *testing.T) {
user := env.seedUser(t, "mcp-token-id", nil)
secret, minted := env.mintUserToken(t, user, "read")
subject, matched, err := resolver.MCPResolvePersonalToken(ctx, secret)
if !matched || err != nil {
t.Fatalf("resolve = %v, %v", matched, err)
}
if subject.ClientID != "" || subject.TokenID != minted.ID {
t.Fatalf("subject = %+v, want TokenID %d and no ClientID", subject, minted.ID)
}
d, err := guard.MCPDispatch(ctx, subject, http.MethodGet)
if err != nil {
t.Fatal(err)
}
tok, isToken := d.Credential.(*models.APIToken)
if !isToken {
t.Fatalf("credential = %+v", d.Credential)
}
if tok.ID != minted.ID || tok.UserID != user.ID || !tok.HasScope("read") || tok.HasScope("write") {
t.Fatalf("credential = %+v, want ID %d, UserID %d, scopes [read]", tok, minted.ID, user.ID)
}
principal, _ := d.Principal.(*bouncer.Principal)
req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/token/me", nil)
req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: d.Guards, Principal: principal, Credential: d.Credential}))
rec := httptest.NewRecorder()
env.h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"email":"`+user.Email+`"`) {
t.Fatalf("token/me = %d %s", rec.Code, rec.Body.String())
}
var rows int64
if err := env.gdb.Model(&models.APIToken{}).Where("user_id = ?", user.ID).Count(&rows).Error; err != nil {
t.Fatal(err)
}
if rows != 1 {
t.Fatalf("user_api_tokens rows = %d, want 1 (the credential was saved)", rows)
}
})
t.Run("dispatch credential of an OAuth subject has no id", func(t *testing.T) {
user := env.seedUser(t, "mcp-oauth-id", nil)
_, minted := env.mintUserToken(t, user, "read")
subject := pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: []string{"read"}, ClientID: "oauth-client-" + adminStamp(), TokenID: minted.ID}
d, err := guard.MCPDispatch(ctx, subject, http.MethodGet)
if err != nil {
t.Fatal(err)
}
tok, isToken := d.Credential.(*models.APIToken)
if !isToken || tok.ID != 0 || tok.UserID != user.ID || !tok.HasScope("read") {
t.Fatalf("credential = %+v, want ID 0, UserID %d, scopes [read]", d.Credential, user.ID)
}
})
}
// mustDispatchCredential returns the credential MCPDispatch builds for a
// read subject; it is never saved (ID 0).
// read subject without a TokenID, which therefore has ID 0 and is not
// saved.
func mustDispatchCredential(t *testing.T, guard pact.MCPGuard, userID uint) any {
t.Helper()
d, err := guard.MCPDispatch(context.Background(), pact.MCPSubject{Guard: "user", UserID: userID, Scopes: []string{"read"}}, http.MethodGet)

335
mcp_implies_test.go Normal file
View File

@@ -0,0 +1,335 @@
package user
import (
"context"
"database/sql"
"database/sql/driver"
"io"
"slices"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
pgdriver "gorm.io/driver/postgres"
"gorm.io/gorm"
)
// impliesServersRead is the one declared scope the tracer exercises: an
// OAuth grant of read satisfies it, a grant of write does not.
func impliesServersRead() []pact.FrontendTokenScope {
return []pact.FrontendTokenScope{{
Name: "servers:read", Label: "Read servers", Description: "List servers.", Implies: "read",
}}
}
// impliesApp boots the member-token catalog with scopes declared and
// publishes a database handle the guard can load a principal from. The
// handle answers one user and no group grants, in memory, so the scope
// check can reach a credential without Postgres. Nothing is written.
func impliesApp(t *testing.T, scopes []pact.FrontendTokenScope) (*backpack.App, *classes.MCPUserGuard) {
t.Helper()
t.Cleanup(classes.ResetDeclaredAPITokenScopes)
app := scopeBootApp(t, scopeDeclarer{"acme.scopes", scopes})
if err := (&Plugin{}).bootAPITokenScopes(app); err != nil {
t.Fatal(err)
}
sqlDB, err := sql.Open("user_implies", "")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(pgdriver.New(pgdriver.Config{Conn: sqlDB}), &gorm.Config{DisableAutomaticPing: true})
if err != nil {
t.Fatal(err)
}
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
return app, &classes.MCPUserGuard{App: app}
}
// TestMCPImpliesOAuth pins the OAuth half of D-01a: a grant of read satisfies
// a declared scope whose Implies is read, in MCPAllows and on the credential
// MCPDispatch builds, and that credential keeps ID 0. The grant slice itself
// is not rewritten, and the catalog is published as pact.MemberTokenScopeCatalog.
func TestMCPImpliesOAuth(t *testing.T) {
app, guard := impliesApp(t, impliesServersRead())
catalog, ok := app.Lookup[pact.MemberTokenScopeCatalog]()
if !ok || catalog == nil {
t.Fatal("pact.MemberTokenScopeCatalog is not published")
}
if _, ok := app.Lookup[*classes.APITokenScopeCatalog](); !ok {
t.Fatal("the concrete catalog publish was removed")
}
var sawRead, sawDeclared bool
for _, scope := range catalog.MemberTokenScopes() {
switch scope.Name {
case "read", "write", "ai":
if scope.Implies != "" {
t.Fatalf("built-in %s Implies = %q", scope.Name, scope.Implies)
}
if scope.Name == "read" {
sawRead = true
}
case "servers:read":
sawDeclared = scope.Implies == "read"
}
}
if !sawRead || !sawDeclared {
t.Fatalf("catalog = %+v", catalog.MemberTokenScopes())
}
ctx := context.Background()
subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"read"}}
orig := subject.Scopes
ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{Scopes: []string{"servers:read"}}, nil)
if err != nil || !ok {
t.Fatalf("MCPAllows(servers:read) = %v, %v", ok, err)
}
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
if err != nil {
t.Fatal(err)
}
credential, isToken := dispatch.Credential.(*models.APIToken)
if !isToken || credential.ID != 0 || credential.OAuthClientID != nil || !credential.HasScope("servers:read") || !credential.HasScope("read") {
t.Fatalf("credential = %+v", dispatch.Credential)
}
if len(subject.Scopes) != 1 || &subject.Scopes[0] != &orig[0] || subject.Scopes[0] != "read" {
t.Fatalf("subject.Scopes = %v, want the original grant slice", subject.Scopes)
}
if slices.Contains(subject.Scopes, "servers:read") {
t.Fatal("implied name was written onto subject.Scopes")
}
}
// TestMCPImpliesPersonalUnchanged pins D-11: a personal token is not expanded,
// and the credential ID stays the subject's TokenID.
func TestMCPImpliesPersonalUnchanged(t *testing.T) {
_, guard := impliesApp(t, impliesServersRead())
ctx := context.Background()
subject := pact.MCPSubject{Guard: "user", UserID: 7, TokenID: 42, Scopes: []string{"read"}}
orig := subject.Scopes
ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{Scopes: []string{"servers:read"}}, nil)
if err != nil || ok {
t.Fatalf("MCPAllows = %v, %v", ok, err)
}
if len(subject.Scopes) != 1 || &subject.Scopes[0] != &orig[0] || subject.Scopes[0] != "read" {
t.Fatalf("subject.Scopes = %v, want the original grant slice", subject.Scopes)
}
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
if err != nil {
t.Fatal(err)
}
credential, isToken := dispatch.Credential.(*models.APIToken)
if !isToken || credential.ID != subject.TokenID || credential.HasScope("servers:read") || !credential.HasScope("read") {
t.Fatalf("credential = %+v, want ID %d and scopes [read]", dispatch.Credential, subject.TokenID)
}
got := credential.Scopes.Get()
if len(got) != 1 || &got[0] != &orig[0] {
t.Fatalf("credential scopes = %v, want the unexpanded grant slice", got)
}
}
// TestMCPImpliesWriteDoesNotGrantRead pins that a write grant does not
// satisfy a scope whose Implies is read.
func TestMCPImpliesWriteDoesNotGrantRead(t *testing.T) {
_, guard := impliesApp(t, impliesServersRead())
ctx := context.Background()
subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"write"}}
ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{Scopes: []string{"servers:read"}}, nil)
if err != nil || ok {
t.Fatalf("MCPAllows = %v, %v", ok, err)
}
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
if err != nil {
t.Fatal(err)
}
credential, isToken := dispatch.Credential.(*models.APIToken)
if !isToken || credential.ID != 0 || credential.HasScope("servers:read") || !credential.HasScope("write") {
t.Fatalf("credential = %+v", dispatch.Credential)
}
}
// TestMCPImpliesNotPersisted pins that MCPDispatch expands scopes only on
// the credential it returns. The subject's grant slice is the same backing
// array afterwards.
func TestMCPImpliesNotPersisted(t *testing.T) {
_, guard := impliesApp(t, impliesServersRead())
ctx := context.Background()
subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"read"}}
orig := subject.Scopes
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
if err != nil {
t.Fatal(err)
}
credential, isToken := dispatch.Credential.(*models.APIToken)
if !isToken || !credential.HasScope("servers:read") || !credential.HasScope("read") {
t.Fatalf("credential = %+v, want the expanded scopes on the credential only", dispatch.Credential)
}
if len(subject.Scopes) != 1 || &subject.Scopes[0] != &orig[0] || subject.Scopes[0] != "read" {
t.Fatalf("subject.Scopes = %v, want the original grant slice", subject.Scopes)
}
if slices.Contains(subject.Scopes, "servers:read") {
t.Fatal("implied name was written onto subject.Scopes")
}
}
// TestMCPImpliesAlreadyPresent does not append a declared name the grant
// already holds, and does not append a catalog name whose Implies is empty.
func TestMCPImpliesAlreadyPresent(t *testing.T) {
_, guard := impliesApp(t, []pact.FrontendTokenScope{
{Name: "servers:read", Label: "Read servers", Description: "List servers.", Implies: "read"},
{Name: "servers:list", Label: "List servers", Description: "List only.", Implies: ""},
})
ctx := context.Background()
subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"read", "servers:read"}}
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
if err != nil {
t.Fatal(err)
}
credential, isToken := dispatch.Credential.(*models.APIToken)
if !isToken || !slices.Equal(credential.Scopes.Get(), []string{"read", "servers:read"}) {
t.Fatalf("credential scopes = %+v", dispatch.Credential)
}
}
// TestMCPImpliesCompileRejects pins the boot errors for a bad Implies and
// for the reserved names backend and offline_access. The error names the
// plugin and the bad value. Empty, read, and write still compile.
func TestMCPImpliesCompileRejects(t *testing.T) {
t.Cleanup(classes.ResetDeclaredAPITokenScopes)
one := func(name, implies string) pact.FrontendTokenScope {
return pact.FrontendTokenScope{Name: name, Label: "Label", Description: "Description", Implies: implies}
}
boot := func(scope pact.FrontendTokenScope) error {
classes.ResetDeclaredAPITokenScopes()
return (&Plugin{}).bootAPITokenScopes(scopeBootApp(t, scopeDeclarer{"acme.scopes", []pact.FrontendTokenScope{scope}}))
}
for _, implies := range []string{"ai", "backend", "READ", " read"} {
err := boot(one("servers:read", implies))
if err == nil || !strings.Contains(err.Error(), "acme.scopes") || !strings.Contains(err.Error(), implies) {
t.Fatalf("implies %q err = %v", implies, err)
}
}
for _, name := range []string{"backend", "offline_access"} {
err := boot(one(name, ""))
if err == nil || !strings.Contains(err.Error(), "acme.scopes") || !strings.Contains(err.Error(), `"`+name+`"`) {
t.Fatalf("name %q err = %v", name, err)
}
}
for _, implies := range []string{"", "read", "write"} {
if err := boot(one("servers:read", implies)); err != nil {
t.Fatalf("implies %q: %v", implies, err)
}
}
}
func init() {
sql.Register("user_implies", impliesDriver{})
}
// impliesDriver is an in-memory database/sql driver for the Implies tests.
// A users query returns one row. A group-grant query returns none.
type impliesDriver struct{}
func (impliesDriver) Open(string) (driver.Conn, error) { return impliesConn{}, nil }
type impliesConn struct{}
func (impliesConn) Prepare(string) (driver.Stmt, error) {
return nil, driver.ErrSkip
}
func (impliesConn) Close() error { return nil }
func (impliesConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
func (impliesConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
return impliesRowsFor(query), nil
}
func (impliesConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) {
return impliesResult(0), nil
}
func impliesRowsFor(query string) driver.Rows {
upper := strings.ToUpper(query)
if strings.Contains(upper, "FROM \"USERS\"") || strings.Contains(upper, "FROM USERS") {
cols := impliesSelectCols(query)
vals := make([]driver.Value, len(cols))
for i, col := range cols {
vals[i] = impliesColValue(col)
}
return &impliesRows{cols: cols, vals: [][]driver.Value{vals}}
}
return &impliesRows{cols: []string{"permissions"}}
}
func impliesSelectCols(query string) []string {
upper := strings.ToUpper(query)
start := strings.Index(upper, "SELECT")
from := strings.Index(upper, " FROM ")
if start < 0 || from < 0 || from <= start+6 {
return []string{"id"}
}
raw := strings.Split(query[start+6:from], ",")
cols := make([]string, 0, len(raw))
for _, part := range raw {
name := strings.TrimSpace(part)
name = strings.Trim(name, `"`)
if i := strings.LastIndex(name, "."); i >= 0 {
name = name[i+1:]
}
name = strings.Trim(name, `"`)
name = strings.TrimSpace(name)
if name == "" || name == "*" {
continue
}
cols = append(cols, name)
}
if len(cols) == 0 {
return []string{"id"}
}
return cols
}
func impliesColValue(col string) driver.Value {
switch col {
case "id":
return int64(7)
case "email":
return "ada@example.test"
case "password":
return "x"
case "must_change_password", "is_activated", "has_self_set_password", "marketing_consent", "is_onboarded":
return false
case "created_at", "updated_at":
return time.Time{}
default:
return nil
}
}
type impliesRows struct {
cols []string
vals [][]driver.Value
i int
}
func (r *impliesRows) Columns() []string { return r.cols }
func (r *impliesRows) Close() error { return nil }
func (r *impliesRows) Next(dest []driver.Value) error {
if r.i >= len(r.vals) {
return io.EOF
}
copy(dest, r.vals[r.i])
r.i++
return nil
}
type impliesResult int64
func (r impliesResult) LastInsertId() (int64, error) { return int64(r), nil }
func (r impliesResult) RowsAffected() (int64, error) { return int64(r), nil }

View File

@@ -86,14 +86,15 @@ func (g *APITokenGuard) WriteUnauthorized(w http.ResponseWriter, _ error) {
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Invalid token."})
}
// Scope requires one delegated scope from the shared token credential.
// Scope requires one delegated scope from the shared token credential. A
// refusal answers 403 with error, message and missing_scope naming the scope.
func Scope(scope string) pact.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
credential, _ := bouncer.Credential(r.Context())
token, ok := credential.(*models.APIToken)
if !ok || token == nil || !token.HasScope(scope) {
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Missing required scope: " + scope})
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Missing required scope: " + scope, "missing_scope": scope})
return
}
next.ServeHTTP(w, r)
@@ -103,12 +104,14 @@ func Scope(scope string) pact.Middleware {
// Permission requires one current user-group permission. Tokens never carry
// a permission snapshot: revoking the user's grant takes effect immediately.
// A refusal answers 403 with error, message and missing_permission naming the
// code.
func Permission(code string) pact.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, _ := bouncer.User(r.Context())
if !cabana.Allows(principal, []string{code}) {
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Forbidden."})
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Forbidden.", "missing_permission": code})
return
}
next.ServeHTTP(w, r)

View File

@@ -0,0 +1,88 @@
package middleware
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
// forbiddenBody is the 403 body of Scope and Permission.
type forbiddenBody struct {
Error bool `json:"error"`
Message string `json:"message"`
MissingScope string `json:"missing_scope"`
MissingPermission string `json:"missing_permission"`
}
// tokenWith is an in-memory credential holding scopes, built the way
// MCPDispatch builds it.
func tokenWith(scopes ...string) *models.APIToken {
return &models.APIToken{Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true}}
}
// serve runs mw over a handler answering 200, with ctx on the request.
func serve(t *testing.T, mw func(http.Handler) http.Handler, ctx context.Context) (int, forbiddenBody) {
t.Helper()
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
mw(next).ServeHTTP(rec, req)
var body forbiddenBody
if rec.Code == http.StatusForbidden {
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("403 body %q: %v", rec.Body.String(), err)
}
}
return rec.Code, body
}
// TestForbiddenBodiesNameWhatIsMissing (D-15, D-20): a refusal by Scope or
// Permission keeps error true and the message, and adds missing_scope or
// missing_permission naming what is missing. No database.
func TestForbiddenBodiesNameWhatIsMissing(t *testing.T) {
scopeRefused := forbiddenBody{Error: true, Message: "Missing required scope: write", MissingScope: "write"}
t.Run("scope missing from the credential", func(t *testing.T) {
ctx := bouncer.WithCredential(context.Background(), tokenWith("read"))
code, body := serve(t, Scope("write"), ctx)
if code != http.StatusForbidden || body != scopeRefused {
t.Fatalf("Scope(write) with read = %d %+v, want 403 %+v", code, body, scopeRefused)
}
})
t.Run("no credential", func(t *testing.T) {
code, body := serve(t, Scope("write"), context.Background())
if code != http.StatusForbidden || body != scopeRefused {
t.Fatalf("Scope(write) without a credential = %d %+v, want 403 %+v", code, body, scopeRefused)
}
})
t.Run("scope held", func(t *testing.T) {
ctx := bouncer.WithCredential(context.Background(), tokenWith("read"))
if code, _ := serve(t, Scope("read"), ctx); code != http.StatusOK {
t.Fatalf("Scope(read) with read = %d, want 200", code)
}
})
t.Run("permission missing", func(t *testing.T) {
ctx := bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"jz.bm.access_schedule": true}})
want := forbiddenBody{Error: true, Message: "Forbidden.", MissingPermission: "jz.bm.access_courses"}
code, body := serve(t, Permission("jz.bm.access_courses"), ctx)
if code != http.StatusForbidden || body != want {
t.Fatalf("Permission without the grant = %d %+v, want 403 %+v", code, body, want)
}
})
t.Run("permission granted", func(t *testing.T) {
ctx := bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"jz.bm.access_courses": true}})
if code, _ := serve(t, Permission("jz.bm.access_courses"), ctx); code != http.StatusOK {
t.Fatalf("Permission with the grant = %d, want 200", code)
}
})
}

224
password_changed_test.go Normal file
View File

@@ -0,0 +1,224 @@
package user
import (
"context"
"errors"
"net/http"
"strconv"
"sync"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"gorm.io/gorm"
)
// passwordSource is what a D-07 listener observed for one event: the event
// itself, whether the stored hash it read through ev.Tx already checks
// against the new password, and the marker it wrote through ev.Tx.
type passwordSource struct {
ev classes.AccessRevokedEvent
newHash bool
}
// passwordListener is a D-07 listener: for every password-change event it
// reads the stored hash through ev.Tx, writes a marker row through ev.Tx
// (jwt_blacklist, jti = marker) and then fails when fail is set.
type passwordListener struct {
mu sync.Mutex
seen []passwordSource
fail bool
plain string
marker string
}
func (l *passwordListener) arm(plain, marker string, fail bool) {
l.mu.Lock()
defer l.mu.Unlock()
l.seen, l.plain, l.marker, l.fail = nil, plain, marker, fail
}
func (l *passwordListener) take() []passwordSource {
l.mu.Lock()
defer l.mu.Unlock()
out := l.seen
l.seen = nil
return out
}
func (l *passwordListener) handle(ctx context.Context, ev *classes.AccessRevokedEvent) error {
l.mu.Lock()
defer l.mu.Unlock()
if !ev.PasswordChanged() {
return nil
}
got := passwordSource{ev: *ev}
if ev.Tx != nil && len(ev.UserIDs) == 1 {
var hash string
if err := ev.Tx.WithContext(ctx).Raw(`SELECT password FROM users WHERE id = ?`, ev.UserIDs[0]).Scan(&hash).Error; err != nil {
return err
}
got.newHash = l.plain != "" && bouncer.CheckPassword(hash, l.plain)
if l.marker != "" {
if err := ev.Tx.WithContext(ctx).Exec(`INSERT INTO jwt_blacklist (jti, expires_at, valid_until) VALUES (?, NOW(), NOW())`, l.marker).Error; err != nil {
return err
}
}
}
l.seen = append(l.seen, got)
if l.fail {
return errors.New("listener refused")
}
return nil
}
func markerExists(t *testing.T, gdb *gorm.DB, marker string) bool {
t.Helper()
var n int64
if err := gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, marker).Scan(&n).Error; err != nil {
t.Fatal(err)
}
return n == 1
}
// TestPasswordChangedSources pins the D-07 hook from every source. A
// password reset, the user's own change and an administrator setting a
// password each fire exactly one AccessRevokedEvent with its own reason and
// PasswordChanged() true, inside the write's transaction: a marker the
// listener writes through ev.Tx commits with the write, and a listener error
// rolls back the write and the marker. Reset and change write the hash
// before they fire, so the listener reads the new hash through ev.Tx; the
// administrator's save fires from FormBeforeUpdate, before cabana writes
// the record in the same transaction. The bcrypt rehash on login and a new
// registration fire no password-change event.
func TestPasswordChangedSources(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
env := newAdminEnv(t)
listener := &passwordListener{}
env.app.Events.Listen("test.password_changed_sources", listener.handle)
admin := env.superAdmin(t)
ctx := context.Background()
session := func(user *models.User) string {
return craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "pcs-"+adminStamp(), time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
}
reset := func(t *testing.T, user *models.User, password string) int {
t.Helper()
code, err := classes.IssueResetCode(ctx, env.gdb, user)
if err != nil {
t.Fatal(err)
}
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
return env.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{
"code": packed, "password": password, "password_confirmation": password,
}).Code
}
change := func(t *testing.T, user *models.User, password string) int {
t.Helper()
return env.call(t, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{
"current_password": "correct-horse", "password": password, "password_confirmation": password,
}).Code
}
adminSet := func(t *testing.T, user *models.User, password string) int {
t.Helper()
return env.call(t, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{
"name": user.Name, "email": user.Email, "password": password, "password_confirmation": password,
}).Code
}
sources := []struct {
name string
reason string
write func(*testing.T, *models.User, string) int
mutate func(*models.User)
newHash bool
}{
{"reset", classes.AccessRevokedPasswordReset, reset, nil, true},
{"change", classes.AccessRevokedPasswordChanged, change, func(u *models.User) { u.HasSelfSetPassword = true }, true},
{"admin_set", classes.AccessRevokedPasswordSetByAdmin, adminSet, nil, false},
}
for _, src := range sources {
t.Run(src.name+"_fires_once_in_the_transaction", func(t *testing.T) {
user := env.seedUser(t, "pcs-"+src.name, src.mutate)
plain := "pcs-new-" + src.name + "-password"
marker := "pcs-marker-" + src.name + "-" + adminStamp()
listener.arm(plain, marker, false)
if status := src.write(t, user, plain); status != http.StatusOK {
t.Fatalf("%s = %d", src.name, status)
}
seen := listener.take()
if len(seen) != 1 {
t.Fatalf("%s fired %d password-change events, want 1", src.name, len(seen))
}
ev := seen[0].ev
if ev.Reason != src.reason || !ev.PasswordChanged() || ev.Tx == nil || len(ev.UserIDs) != 1 || ev.UserIDs[0] != user.ID {
t.Fatalf("event = %+v", ev)
}
if ev.Before.IsZero() || time.Since(ev.Before) > time.Minute {
t.Fatalf("event cutoff = %s", ev.Before)
}
if seen[0].newHash != src.newHash {
t.Fatalf("listener read the new hash through ev.Tx = %v, want %v", seen[0].newHash, src.newHash)
}
if !markerExists(t, env.gdb, marker) {
t.Fatal("the listener's write through ev.Tx did not commit with the password write")
}
if !bouncer.CheckPassword(env.reload(t, user.ID).Password, plain) {
t.Fatal("the new password was not stored")
}
})
t.Run(src.name+"_listener_error_rolls_back", func(t *testing.T) {
user := env.seedUser(t, "pcs-rb-"+src.name, src.mutate)
marker := "pcs-rb-marker-" + src.name + "-" + adminStamp()
listener.arm("pcs-never-set", marker, true)
if status := src.write(t, user, "pcs-never-set"); status == http.StatusOK {
t.Fatalf("%s with a failing listener answered 200", src.name)
}
if len(listener.take()) != 1 {
t.Fatal("the failing listener did not run exactly once")
}
if stored := env.reload(t, user.ID).Password; stored != user.Password {
t.Fatalf("%s: the stored hash changed although the listener failed", src.name)
}
if markerExists(t, env.gdb, marker) {
t.Fatalf("%s: the listener's write survived the rollback", src.name)
}
})
}
t.Run("rehash_fires_none", func(t *testing.T) {
user := env.seedUser(t, "pcs-rehash", nil)
if err := env.app.Config.Set("golem15.user.password.bcrypt_cost", 11); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = env.app.Config.Set("golem15.user.password.bcrypt_cost", 10) })
listener.arm("", "", false)
if status, _ := env.userLogin(t, user.Email, "correct-horse"); status != http.StatusOK {
t.Fatalf("login = %d", status)
}
if bouncer.NeedsRehash(env.reload(t, user.ID).Password, 11) {
t.Fatal("the login did not rehash")
}
if seen := listener.take(); len(seen) != 0 {
t.Fatalf("the rehash fired %d password-change events", len(seen))
}
})
t.Run("register_fires_none", func(t *testing.T) {
if err := env.app.Config.Set("golem15.user.registration.use_register_throttle", false); err != nil {
t.Fatal(err)
}
listener.arm("", "", false)
email := "pcs-register-" + adminStamp() + "@example.test"
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/register", "", map[string]string{
"email": email, "password": "correct-horse", "name": "Registrant",
})
if seen := listener.take(); len(seen) != 0 {
t.Fatalf("a registration fired %d password-change events", len(seen))
}
})
}

View File

@@ -116,6 +116,7 @@ func (p *Plugin) Boot(app *backpack.App) error {
if err := p.bootBackendLinks(app); err != nil {
return err
}
p.bootDeviceRevocation(app)
if err := p.bootAPITokenScopes(app); err != nil {
return err
}
@@ -125,9 +126,35 @@ func (p *Plugin) Boot(app *backpack.App) error {
return classes.RegisterUserAuthorizer(app)
}
// bootDeviceRevocation revokes the pending and confirmed device sessions of
// users who lose access (a ban or a password change, AccessRevokedEvent)
// inside the write's transaction, so a device authorized before the change
// is never handed a session token after it; a failed write rolls the change
// back.
func (p *Plugin) bootDeviceRevocation(app *backpack.App) {
if app == nil || app.Events == nil {
return
}
app.Events.Listen(p.ID(), func(ctx context.Context, ev *classes.AccessRevokedEvent) error {
if ev == nil || len(ev.UserIDs) == 0 {
return nil
}
db := ev.Tx
if db == nil {
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
return fmt.Errorf("golem15.user: revoke device sessions: database is not configured")
}
db = gdb
}
return classes.RevokeDeviceSessions(ctx, db, ev.UserIDs...)
})
}
// bootAPITokenScopes compiles the member token scope catalog from the
// activated plugins (pact.HasFrontendTokenScopes), checks that every phrase
// key it names exists, and publishes it. A bad declaration fails boot.
// key it names exists, and publishes it as *classes.APITokenScopeCatalog and
// as pact.MemberTokenScopeCatalog. A bad declaration fails boot.
func (p *Plugin) bootAPITokenScopes(app *backpack.App) error {
activated, _ := app.Lookup[party.Activated]()
catalog, err := classes.CompileAPITokenScopes(activated)
@@ -147,7 +174,10 @@ func (p *Plugin) bootAPITokenScopes(app *backpack.App) error {
}
}
}
return app.Publish(catalog)
if err := app.Publish(catalog); err != nil {
return err
}
return app.Publish[pact.MemberTokenScopeCatalog](catalog)
}
// bootMCPGuard registers the "user" MCP guard provider in the application's

242
reset_two_factor_test.go Normal file
View File

@@ -0,0 +1,242 @@
package user
import (
"context"
"errors"
"net/http"
"slices"
"sync"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// scriptedTwoFactorAdmin is a classes.TwoFactorAdmin whose answers a test
// sets: has is what HasTwoFactor reports; Reset renames the user through
// the transaction it is given (to prove which transaction that is) and then
// returns resetErr.
type scriptedTwoFactorAdmin struct {
mu sync.Mutex
has bool
resetErr error
resets []uint
}
func (s *scriptedTwoFactorAdmin) HasTwoFactor(_ context.Context, tx *gorm.DB, _ uint) (bool, error) {
if tx == nil {
return false, errors.New("no handle")
}
s.mu.Lock()
defer s.mu.Unlock()
return s.has, nil
}
func (s *scriptedTwoFactorAdmin) Reset(ctx context.Context, tx *gorm.DB, user *models.User) error {
s.mu.Lock()
defer s.mu.Unlock()
s.resets = append(s.resets, user.ID)
if err := tx.WithContext(ctx).Exec(`UPDATE users SET name = 'reset-marker' WHERE id = ?`, user.ID).Error; err != nil {
return err
}
return s.resetErr
}
func (s *scriptedTwoFactorAdmin) calls() []uint {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Clone(s.resets)
}
// resetAction returns the Users controller's reset_two_factor record action
// on app.
func resetAction(t *testing.T, app *backpack.App) pact.AdminRecordAction {
t.Helper()
ctl := adminControllersOn(func() *backpack.App { return app })["golem15.user.users"]
for _, action := range ctl.(pact.HasAdminRecordActions).AdminRecordActions() {
if action.Name == "reset_two_factor" {
return action
}
}
t.Fatal("the Users controller has no reset_two_factor record action")
return pact.AdminRecordAction{}
}
// TestResetTwoFactorAction pins the D-15 record action on the Users
// screen: never offered without a published classes.TwoFactorAdmin;
// offered with the danger confirmation when one is published and reports a
// second factor; hidden when it reports none; refused without
// golem15.users.reset_two_factor; Run needs the action's transaction, runs
// Reset in it, and Reset's error rolls the transaction back. A member of a
// privileged group keeps the takeover guard: without
// golem15.users.manage_privileged_groups Run answers 403 with the guard's
// message before Reset.
func TestResetTwoFactorAction(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
t.Run("not_offered_without_a_service", func(t *testing.T) {
env := newAdminEnv(t)
token := env.admin(t, permAccessUsers, permResetTwoFactor)
user := env.seedUser(t, "rta-none", nil)
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
t.Fatal("offered without a published TwoFactorAdmin")
}
if applies, err := resetAction(t, env.app).Applies(context.Background(), user); applies || err != nil {
t.Fatalf("Applies without a service = %v, %v", applies, err)
}
if rec := env.call(t, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), token, map[string]any{}); rec.Code == http.StatusOK {
t.Fatalf("ran without a service: %s", rec.Body.String())
}
if env.reload(t, user.ID).Name == nil || *env.reload(t, user.ID).Name != "rta-none" {
t.Fatal("the user changed although nothing ran")
}
})
t.Run("offered_with_danger_and_hidden_without_a_factor", func(t *testing.T) {
env := newAdminEnv(t)
svc := &scriptedTwoFactorAdmin{has: true}
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
t.Fatal(err)
}
token := env.admin(t, permAccessUsers, permResetTwoFactor)
user := env.seedUser(t, "rta-offer", nil)
action := env.recordAction(t, token, user.ID, "reset_two_factor")
if action == nil {
t.Fatalf("not offered: %v", env.offered(t, token, user.ID))
}
if action["danger"] != true || action["label"] != "Reset 2FA" || action["confirm"] == nil || action["confirm"] == "" {
t.Fatalf("offered action = %v", action)
}
svc.mu.Lock()
svc.has = false
svc.mu.Unlock()
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
t.Fatal("offered for a user without a second factor")
}
// A superuser holds every permission and sees it too.
svc.mu.Lock()
svc.has = true
svc.mu.Unlock()
if !slices.Contains(env.offered(t, env.superAdmin(t), user.ID), "reset_two_factor") {
t.Fatal("not offered to a superuser")
}
})
t.Run("permission_required", func(t *testing.T) {
env := newAdminEnv(t)
svc := &scriptedTwoFactorAdmin{has: true}
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
t.Fatal(err)
}
plain := env.admin(t, permAccessUsers)
user := env.seedUser(t, "rta-perm", nil)
if slices.Contains(env.offered(t, plain, user.ID), "reset_two_factor") {
t.Fatal("offered without golem15.users.reset_two_factor")
}
env.expect(t, http.StatusForbidden, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), plain, map[string]any{})
if len(svc.calls()) != 0 {
t.Fatal("a refused administrator reached Reset")
}
})
t.Run("run_needs_the_transaction", func(t *testing.T) {
env := newAdminEnv(t)
svc := &scriptedTwoFactorAdmin{has: true}
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
t.Fatal(err)
}
user := env.seedUser(t, "rta-notx", nil)
_, err := resetAction(t, env.app).Run(context.Background(), pact.AdminRecordActionInput{Record: user})
wantPlainError(t, "reset_two_factor without a transaction", err)
if len(svc.calls()) != 0 {
t.Fatal("Reset ran without the action's transaction")
}
})
t.Run("run_commits_with_the_action", func(t *testing.T) {
env := newAdminEnv(t)
svc := &scriptedTwoFactorAdmin{has: true}
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
t.Fatal(err)
}
token := env.admin(t, permAccessUsers, permResetTwoFactor)
user := env.seedUser(t, "rta-ok", nil)
env.expect(t, http.StatusOK, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), token, map[string]any{})
if calls := svc.calls(); len(calls) != 1 || calls[0] != user.ID {
t.Fatalf("Reset calls = %v", calls)
}
if name := env.reload(t, user.ID).Name; name == nil || *name != "reset-marker" {
t.Fatalf("Reset's write did not commit: name = %v", name)
}
})
t.Run("privileged_member_needs_the_privileged_permission", func(t *testing.T) {
code := "rta-priv-" + adminStamp()
env := newAdminEnv(t, func(cfg *compass.Config) {
if err := cfg.Set("golem15.user.privileged_groups", []string{"admin", code}); err != nil {
t.Fatal(err)
}
})
svc := &scriptedTwoFactorAdmin{has: true}
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
t.Fatal(err)
}
limited := env.admin(t, permAccessUsers, permResetTwoFactor)
manager := env.admin(t, permAccessUsers, permResetTwoFactor, permManagePrivileged)
member := env.seedUser(t, "rta-privileged", nil)
env.join(t, member, env.seedGroup(t, "Privileged "+code, code))
ordinary := env.seedUser(t, "rta-ordinary", nil)
rec := env.expect(t, http.StatusForbidden, http.MethodPost, userPath(member.ID, "/actions/reset_two_factor"), limited, map[string]any{})
answer := adminDecode(t, rec)
const want = "This user belongs to a privileged group. Resetting their two-factor authentication needs the permission to manage privileged groups. Nothing was changed."
if answer.Error.Code != "forbidden" || answer.Error.Message != want {
t.Fatalf("refusal = %s", rec.Body.String())
}
if len(svc.calls()) != 0 {
t.Fatalf("a refused reset reached Reset: %v", svc.calls())
}
if name := env.reload(t, member.ID).Name; name == nil || *name != "rta-privileged" {
t.Fatalf("the refused reset changed the member: name = %v", name)
}
// An ordinary user stays resettable with the reset permission alone.
if !slices.Contains(env.offered(t, limited, ordinary.ID), "reset_two_factor") {
t.Fatal("not offered for an ordinary user")
}
env.expect(t, http.StatusOK, http.MethodPost, userPath(ordinary.ID, "/actions/reset_two_factor"), limited, map[string]any{})
// With the privileged permission the member is offered and reset.
if !slices.Contains(env.offered(t, manager, member.ID), "reset_two_factor") {
t.Fatal("not offered to an administrator who manages privileged groups")
}
env.expect(t, http.StatusOK, http.MethodPost, userPath(member.ID, "/actions/reset_two_factor"), manager, map[string]any{})
if calls := svc.calls(); len(calls) != 2 || calls[0] != ordinary.ID || calls[1] != member.ID {
t.Fatalf("Reset calls = %v", calls)
}
})
t.Run("run_error_rolls_back", func(t *testing.T) {
env := newAdminEnv(t)
svc := &scriptedTwoFactorAdmin{has: true, resetErr: errors.New("credential delete failed")}
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
t.Fatal(err)
}
token := env.admin(t, permAccessUsers, permResetTwoFactor)
user := env.seedUser(t, "rta-rollback", nil)
rec := env.call(t, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), token, map[string]any{})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("failing Reset = %d %s", rec.Code, rec.Body.String())
}
if len(svc.calls()) != 1 {
t.Fatalf("Reset calls = %v", svc.calls())
}
if name := env.reload(t, user.ID).Name; name == nil || *name != "rta-rollback" {
t.Fatalf("Reset's write survived its error: name = %v", name)
}
})
}

View File

@@ -0,0 +1,192 @@
package updates
import (
"errors"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"github.com/go-gormigrate/gormigrate/v2"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
)
// TestDeviceAuthSessionsMigration pins 202610260001_create_device_auth_sessions
// (D-16, D-06, D-12b) on a database of its own: the PHP table and column
// names plus jwt_jti, the column types and nullability of PHP's v2.1.0 to
// v2.7.1 migrations, the unique token and short_code, the CHECK on status,
// the indexes, the cascade on user delete, the rollback and a second up.
func TestDeviceAuthSessionsMigration(t *testing.T) {
sqlDB := dedicatedDB(t, "user_device_auth_sessions")
db, err := lagoon.Use(t.Context(), sqlDB)
if err != nil {
t.Fatal(err)
}
m := gormigrate.New(db, &gormigrate.Options{TableName: "summer_migrations_golem15_user", UseTransaction: true}, All())
if err := m.Migrate(); err != nil {
t.Fatal(err)
}
const table = "golem15_user_device_auth_sessions"
if len(deviceAuthSessionMigrations) != 1 || deviceAuthSessionMigrations[0].ID != "202610260001_create_device_auth_sessions" {
t.Fatalf("device migrations = %v", deviceAuthSessionMigrations)
}
type column struct {
Name string
Type string
Length *int
Nullable string
}
var columns []column
if err := db.Raw(`SELECT column_name AS name, data_type AS type, character_maximum_length AS length, is_nullable AS nullable
FROM information_schema.columns WHERE table_name = ? ORDER BY ordinal_position`, table).Scan(&columns).Error; err != nil {
t.Fatal(err)
}
got := map[string]column{}
var names []string
for _, c := range columns {
got[c.Name] = c
names = append(names, c.Name)
}
want := []string{
// PHP's columns (v2.1.0, v2.3.1 short_code).
"id", "token", "short_code", "user_id", "status", "expires_at", "device_ip", "device_user_agent", "device_name",
"auth_ip", "confirmed_at", "used_at", "session_id", "last_activity_at",
// D-12b.
"jwt_jti",
"created_at", "updated_at",
}
if !slices.Equal(names, want) {
t.Fatalf("columns = %v, want %v", names, want)
}
for name, spec := range map[string]struct {
typ string
length int
nullable string
}{
"token": {"character varying", 64, "NO"},
"short_code": {"character varying", 9, "YES"},
"user_id": {"integer", 0, "YES"},
"status": {"character varying", 16, "NO"},
"expires_at": {"timestamp with time zone", 0, "YES"},
"device_name": {"character varying", 255, "YES"},
"device_user_agent": {"text", 0, "YES"},
"jwt_jti": {"character varying", 64, "YES"},
"session_id": {"character varying", 255, "YES"},
} {
c := got[name]
length := 0
if c.Length != nil {
length = *c.Length
}
if c.Type != spec.typ || length != spec.length || c.Nullable != spec.nullable {
t.Fatalf("%s = %s(%d) nullable %s, want %s(%d) nullable %s", name, c.Type, length, c.Nullable, spec.typ, spec.length, spec.nullable)
}
}
var indexes []string
if err := db.Raw(`SELECT indexdef FROM pg_indexes WHERE tablename = ?`, table).Scan(&indexes).Error; err != nil {
t.Fatal(err)
}
joined := strings.Join(indexes, "\n")
for _, def := range []string{
"UNIQUE INDEX " + table + "_token_key ON public." + table + " USING btree (token)",
"UNIQUE INDEX " + table + "_short_code_key ON public." + table + " USING btree (short_code)",
"INDEX " + table + "_user_id_index ON public." + table + " USING btree (user_id)",
"INDEX " + table + "_status_index ON public." + table + " USING btree (status)",
"INDEX " + table + "_expires_at_index ON public." + table + " USING btree (expires_at)",
"INDEX " + table + "_jwt_jti_index ON public." + table + " USING btree (jwt_jti)",
} {
if !strings.Contains(joined, def) {
t.Fatalf("index %q missing from:\n%s", def, joined)
}
}
pgCode := func(err error) string {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code
}
return ""
}
insert := func(token string, shortCode any, status string, userID any) error {
return db.Exec(`INSERT INTO `+table+` (token, short_code, status, user_id, expires_at) VALUES (?, ?, ?, ?, NOW())`,
token, shortCode, status, userID).Error
}
if err := insert("t1", "AAAA-AAAA", "pending", nil); err != nil {
t.Fatal(err)
}
if code := pgCode(insert("t1", "BBBB-BBBB", "pending", nil)); code != "23505" {
t.Fatalf("duplicate token: SQLSTATE %q, want 23505", code)
}
if code := pgCode(insert("t2", "AAAA-AAAA", "pending", nil)); code != "23505" {
t.Fatalf("duplicate short_code: SQLSTATE %q, want 23505", code)
}
if err := insert("t3", nil, "used", nil); err != nil {
t.Fatal(err)
}
if err := insert("t4", nil, "revoked", nil); err != nil {
t.Fatalf("a second NULL short_code: %v", err)
}
for _, status := range []string{"confirmed", "expired"} {
if err := insert("t-"+status, nil, status, nil); err != nil {
t.Fatalf("status %s refused: %v", status, err)
}
}
for _, status := range []string{"unknown", "PENDING", ""} {
if code := pgCode(insert("t-bad-"+status, nil, status, nil)); code != "23514" {
t.Fatalf("status %q: SQLSTATE %q, want 23514 (check)", status, code)
}
}
var status string
if err := db.Raw(`INSERT INTO ` + table + ` (token) VALUES ('t-default') RETURNING status`).Scan(&status).Error; err != nil || status != "pending" {
t.Fatalf("default status = %q (%v)", status, err)
}
user := &models.User{Email: "device-migration@example.test", Password: "x"}
if err := db.Omit("Groups").Create(user).Error; err != nil {
t.Fatal(err)
}
if code := pgCode(insert("t-fk", nil, "pending", 987654321)); code != "23503" {
t.Fatalf("unknown user_id: SQLSTATE %q, want 23503", code)
}
if err := insert("t-owned", nil, "used", user.ID); err != nil {
t.Fatal(err)
}
if err := db.Exec(`DELETE FROM users WHERE id = ?`, user.ID).Error; err != nil {
t.Fatal(err)
}
var owned int64
if err := db.Raw(`SELECT COUNT(*) FROM ` + table + ` WHERE token = 't-owned'`).Scan(&owned).Error; err != nil || owned != 0 {
t.Fatalf("rows of a deleted user = %d (%v), want 0 (ON DELETE CASCADE)", owned, err)
}
history := func() int64 {
var n int64
if err := db.Raw(`SELECT COUNT(*) FROM summer_migrations_golem15_user WHERE id = ?`, deviceAuthSessionMigrations[0].ID).Scan(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
if history() != 1 {
t.Fatal("the migration is not recorded")
}
if err := m.RollbackMigration(deviceAuthSessionMigrations[0]); err != nil {
t.Fatal(err)
}
if db.Migrator().HasTable(table) || history() != 0 {
t.Fatal("rollback left the table or its history row")
}
if err := m.Migrate(); err != nil {
t.Fatal(err)
}
if !db.Migrator().HasTable(table) || history() != 1 {
t.Fatal("a second up did not recreate the table")
}
var rows int64
if err := db.Session(&gorm.Session{}).Raw(`SELECT COUNT(*) FROM ` + table).Scan(&rows).Error; err != nil || rows != 0 {
t.Fatalf("the recreated table has %d rows (%v)", rows, err)
}
}

View File

@@ -3,6 +3,7 @@ package updates
import (
"context"
"database/sql"
"flag"
"fmt"
"net/url"
"os"
@@ -22,9 +23,13 @@ var (
userPGErr error
)
// TestMain starts the Postgres container only without -short. go test
// passes -short as -test.short=true, so the flag is read through
// flag.Parse and testing.Short, never by scanning os.Args.
func TestMain(m *testing.M) {
flag.Parse()
code := 1
if !testShort() {
if !testing.Short() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
userPGErr = startUserPostgres(ctx)
cancel()
@@ -39,15 +44,6 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
func testShort() bool {
for _, a := range os.Args {
if a == "-test.short" {
return true
}
}
return false
}
func startUserPostgres(ctx context.Context) error {
ctr, err := postgres.Run(ctx,
"postgres:16-alpine",