Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b22134292 | |||
| 4e3e5b05b5 | |||
| 3d4c8d2160 | |||
| 7900f47ba6 | |||
| 8501f3475a | |||
| 50da92c1fb | |||
| 0428093d33 | |||
| 98924041c9 | |||
| ebe408f542 | |||
| 9730110d55 | |||
| b929ba9f33 | |||
| 92676ba14e | |||
| 30f7a01d3d | |||
| 38551a4104 | |||
| e3c164fdf0 | |||
| 25b9f2e35c | |||
|
|
f20a8d3b98 | ||
|
|
ee5c096d0d | ||
|
|
970719ce75 | ||
|
|
32f92591d6 | ||
|
|
72de822a84 |
86
README.md
86
README.md
@@ -12,23 +12,28 @@ Module `git.golem15.com/golem15/sm-user-plugin`, package `user`, plugin ID `gole
|
||||
|
||||
The plugin is a compiled SummerCMS plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`.
|
||||
|
||||
It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_api_tokens`, `golem15_user_oauth_identities`, `golem15_user_organisations`, `user_groups`, `users_groups`, `golem15_user_frontend_permissions`, `golem15_user_backend_links` and `golem15_user_backend_link_requests`, the JSON API under `/_user/api/v1`, and the Users, User Groups and Organisations screens of the admin. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs, permission codes and config keys are a contract: change them only in a backwards-compatible way.
|
||||
It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_api_tokens`, `golem15_user_oauth_identities`, `golem15_user_organisations`, `user_groups`, `users_groups`, `golem15_user_frontend_permissions`, `golem15_user_backend_links`, `golem15_user_backend_link_requests` and `golem15_user_device_auth_sessions`, the JSON API under `/_user/api/v1`, and the Users, User Groups and Organisations screens of the admin. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs, permission codes and config keys are a contract: change them only in a backwards-compatible way.
|
||||
|
||||
## Features
|
||||
|
||||
- **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. A `/register` body sets only the `User.RegisterFillable` columns: `name`, `surname`, `email`, `password` (stored as its hash), `preferred_locale` and `marketing_consent`. It cannot set `organisation_id` or `organisation_role` (membership is the administrator's or the application's), `is_onboarded`, `must_change_password`, or the activation, reset, address proof, handle and permission columns; other keys are ignored (they still reach `RegisterEvent.Payload`). `created_ip_address` and `last_ip_address` are server-set from the client address only. With `require_activation` on (the default), `POST /login` refuses an account that is not activated with the same 401 invalid-login answer as a wrong password and counts the attempt for the login throttle. Such an account is activated by the mailed code (`POST /activate-by-code` answers with a token), by the invitation link or by an administrator. `POST /activate` serves the sessions of not-activated accounts, which exist when `require_activation` is off. `Login` checks in this order: a ban or an active suspension (`RejectIfThrottled`), the password, the activation, then the deactivation restore.
|
||||
- **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 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"}`.
|
||||
- **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. 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 and its members: the users whose `organisation_id` points at it. `organisation_role` belongs to that membership: the admin clears it whenever a user's organisation changes (see the Organisations screen), and an application that sets `organisation_id` itself sets or clears the role with it.
|
||||
- **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.
|
||||
- **User Groups screen.** Controller ID `golem15.user.usergroups`, the port of the WinterCMS `UserGroups` controller, behind `golem15.users.access_groups`: a list with the name, code, creation date and number of members, and a form with the name, the code (letters, digits, underscore and hyphen; unique), a description and the group's frontend permissions as checkboxes, stored in `user_groups.permissions` as a JSON object of code to `1`. A group can be deleted from its form; its memberships go with it and its members stay. The screen never changes who is in a group.
|
||||
- **Organisations screen.** Controller ID `golem15.user.organisations`, the port of the WinterCMS `Organisations` controller, behind `golem15.users.access_users`: a list and a form with the name, slug, description and avatar, and on an existing organisation the Members tab. Adding a member sets that user's `organisation_id` and removing one clears it; 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.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -36,13 +41,26 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
||||
- **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.
|
||||
- **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"},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
- **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.
|
||||
- **OAuth providers.** `GET /oauth-providers` lists the social sign-in providers a social-login plugin has enabled, read on every request from its published `classes.OAuthProviderSource`. Without one it answers the empty list, as before.
|
||||
- **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`) + `/link-admin?code=<code>`, which the admin opens in a new tab. The site page reads `GET /backend-link/{code}` (the administrator's login and display name and the expiry, nothing else), has the person sign in on the site as the frontend user, and posts the code to `POST /backend-link/confirm` behind `jwt.auth`. The link is written only there, in one transaction with the request row locked, and only when the session token was issued at or after the request was created (compared in whole seconds), so a browser that was already signed in must sign in again; password, social login and any login gate apply as usual. 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 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.
|
||||
- **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. 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.
|
||||
- **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.
|
||||
- **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
|
||||
@@ -109,16 +127,23 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle
|
||||
| `POST /avatar/remove` | `controllers.RemoveAvatar` |
|
||||
| `POST /tokens` | `controllers.APITokenStore` (`jwt.auth`) |
|
||||
| `GET /tokens` | `controllers.APITokenIndex` (`jwt.auth`) |
|
||||
| `GET /tokens/scopes` | `controllers.APITokenScopes` (`jwt.auth`) |
|
||||
| `DELETE /tokens/{id}` | `controllers.APITokenDestroy` (`jwt.auth`) |
|
||||
| `GET /backend-link/{code}` | `controllers.BackendLinkShow` |
|
||||
| `GET /backend-link/{code}` | `controllers.BackendLinkShow` (deprecated: the code is in the logged path; use the POST route) |
|
||||
| `POST /backend-link/show` | `controllers.BackendLinkShowPost` |
|
||||
| `POST /backend-link/confirm` | `controllers.BackendLinkConfirm` (`jwt.auth`) |
|
||||
| `GET /token/me` | `controllers.TokenMe` (`user.api_token`, `user.scope:read`) |
|
||||
| `POST /devices/initiate` | `controllers.DeviceInitiate` |
|
||||
| `GET /devices/status/{token}` | `controllers.DeviceStatus` |
|
||||
| `POST /devices/authorize` | `controllers.DeviceAuthorize` (session token checked in the handler, 401 `{"error":"Unauthorized"}`) |
|
||||
| `GET /devices` | `controllers.DeviceIndex` (session token checked in the handler) |
|
||||
| `DELETE /devices/{id}` | `controllers.DeviceDestroy` (session token checked in the handler; `id` is `[0-9]+`) |
|
||||
|
||||
`/fetch`, `/refresh`, `/update`, `/change-password`, `/activate`, `/marketing-consent`, `/avatar`, `/avatar/remove` and the `jwt.auth` routes read the session token from the bearer header, then the `token` and `auth_token` cookies.
|
||||
|
||||
With `golem15.user.password.public_routes` false the `/login`, `/forgot-password` and `/reset-password` rows are not registered.
|
||||
|
||||
`GET /backend-link/{code}` (`code` is 64 lower-case hex characters) answers `{"data":{"admin":{"login":…,"name":…},"expires_at":"<RFC 3339>"}}` for a usable request and `404 {"error":"not_found"}` for an unknown, used or expired one or when the administrator was deactivated since. `POST /backend-link/confirm` takes `{"code":…}` from the signed-in frontend user and answers `200 {"data":{"backend_user":{"login":…,"name":…}}}`; `409 {"error":"stale_session"}` when the session token was issued before the request (sign in again), `409 {"error":"already_linked"}` when the user or the administrator is already linked, and `404 {"error":"not_found"}` as the show route. `GET /token/me` answers the `{"user": …}` body of `/fetch` to a personal API token with the `read` scope (401 `{"error":true,"message":"Invalid token."}` from the guard, 403 `Missing required scope: read` from the scope check); it is the target of the MCP tool `user_me`.
|
||||
`POST /backend-link/show` takes `{"code":…}` (64 lower-case hex characters) and, like the deprecated `GET /backend-link/{code}`, answers `{"data":{"admin":{"login":…,"name":…},"expires_at":"<RFC 3339>"}}` for a usable request and `404 {"error":"not_found"}` for an unknown, used or expired one or when the administrator was deactivated since. `POST /backend-link/confirm` takes `{"code":…}` from the signed-in frontend user and answers `200 {"data":{"backend_user":{"login":…,"name":…}}}`; `409 {"error":"stale_session"}` when the session's `auth_time` is before the request or the token carries no `auth_time` (sign in again), `409 {"error":"already_linked"}` when the user or the administrator is already linked, and `404 {"error":"not_found"}` as the show route. Both show routes send `Cache-Control: no-store, private`. `GET /token/me` answers the `{"user": …}` body of `/fetch` to a personal API token with the `read` scope (401 `{"error":true,"message":"Invalid token."}` from the guard, 403 `Missing required scope: read` from the scope check); it is the target of the MCP tool `user_me`.
|
||||
|
||||
### Host-mounted OAuth identities
|
||||
|
||||
@@ -140,7 +165,7 @@ The three screens are served by the framework's generic admin API under `<admin
|
||||
| Navigation | main item `user` (order 555) with the side items `users`, `usergroups` and `organisations`, each shown to an administrator who holds its permission |
|
||||
| Relation manager | `members` on `golem15.user.organisations` (link and unlink) |
|
||||
| Bulk actions (Users) | `activate`, `deactivate`, `restore`, `ban`, `unban`, and the built-in delete; `deactivate` and `ban` also revoke the selected users' sessions and personal API tokens |
|
||||
| Record actions (Users) | `activate`, `unban`, `unsuspend` |
|
||||
| Record actions (Users) | `activate`, `unban`, `unsuspend`, and `reset_two_factor` (danger, behind `golem15.users.reset_two_factor`, offered only while a published `classes.TwoFactorAdmin` reports a second factor) |
|
||||
| Record actions (Administrators, `summercms.cabana.users`) | `link_frontend_account` (asks for the frontend user's email) and `unlink_frontend_account` (danger), both behind `golem15.users.manage_backend_links`, from `AdminActionExtensions` |
|
||||
| My account section | `frontend_account` with the actions `link` and `unlink`, from `AccountContributions` |
|
||||
|
||||
@@ -156,6 +181,7 @@ Backend permissions, all on the tab `golem15.user::lang.plugin.tab`:
|
||||
| `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_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. |
|
||||
|
||||
Migrations added for the admin, all additive:
|
||||
|
||||
@@ -189,6 +215,12 @@ Migrations added for the frontend account link, all additive:
|
||||
|----|------|
|
||||
| `202610220001_create_golem15_user_backend_links` | the tables `golem15_user_backend_links` (`user_id` unique, cascade FK to `users`; `backend_user_id` unique, cascade FK to `backend_users`; `linked_by` FK to `backend_users`, set null on delete; `created_at`) and `golem15_user_backend_link_requests` (`code_hash` `CHAR(64)` unique, `backend_user_id` cascade FK, `expires_at`, `used_at`, `created_at`). The `backend_users` keys are added when that table exists, which it does whenever the application migrates through `lagoon.Migrate`. |
|
||||
|
||||
Migration added for device authorization, additive:
|
||||
|
||||
| ID | Adds |
|
||||
|----|------|
|
||||
| `202610260001_create_device_auth_sessions` | the table `golem15_user_device_auth_sessions` with PHP's columns (`token` holding the SHA-256 hex of the device token, unique; `short_code` unique; `user_id` nullable, cascade FK to `users`; `status` checked against the five statuses; `expires_at`, `device_ip`, `device_user_agent`, `device_name`, `auth_ip`, `confirmed_at`, `used_at`, `session_id`, `last_activity_at`) plus `jwt_jti`, indexed on `user_id`, `status`, `expires_at` and `jwt_jti`; rollback drops it |
|
||||
|
||||
Packages:
|
||||
|
||||
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins:
|
||||
@@ -198,27 +230,36 @@ Packages:
|
||||
- `FireDeletingEvent(ctx, app, user, tx)` fires `DeletingEvent` (`user.Deleting`) inside the delete transaction.
|
||||
- `FireDeactivatingEvent(ctx, app, user, tx)` fires `DeactivatingEvent` (`user.Deactivating`) inside the deactivation transaction. The admin deactivate bulk action fires it after `DeactivateUsers` (so the user's sessions and API tokens are already revoked) for every selected user that was not deactivated yet; a listener error rolls the action back.
|
||||
- `IssueToken(app, user, r)` mints the session JWT as `/register` and `/login` do.
|
||||
- `BackendLinkShow`, `BackendLinkConfirm` and `TokenMe`, the handlers of the account link and `/token/me` routes.
|
||||
- `BackendLinkShow`, `BackendLinkShowPost`, `BackendLinkConfirm` and `TokenMe`, the handlers of the account link and `/token/me` routes.
|
||||
- `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners.
|
||||
- `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks.
|
||||
- `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks, consults the login gates before it and fires the login event after it minted the token.
|
||||
- `CheckLoginGates(ctx, app, attempt)` consults the published `*classes.LoginGates` (none published allows the sign-in) and returns its answer: nil allows, a `*classes.LoginGateResult` replaces the token response, an error must fail the sign-in closed. A social-login plugin calls it before it issues a token.
|
||||
- `FireLoginEvent(ctx, app, user, source)` fires `classes.LoginEvent` and returns the listeners' error; call it after the token was minted and log the error.
|
||||
- `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`, `HashAPIToken` and `RevokeAPIToken`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
|
||||
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `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:
|
||||
- `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.
|
||||
- `RevokeUserAccess(ctx, db, userIDs)` ends every session JWT issued before the current second (it stamps `users.tokens_valid_after`, deactivated rows included) and revokes every personal API token of the users. `DeactivateUsers` and `BanUsers` call it; an application may call it for its own offboarding and after writing a ban directly to `user_throttle`.
|
||||
- `RevokeUserAccess(ctx, db, userIDs)` ends every session JWT issued before the current second (it stamps `users.tokens_valid_after`, deactivated rows included) and revokes every personal API token of the users. `DeactivateUsers` and `BanUsers` call it; an application may call it for its own offboarding and after writing a ban directly to `user_throttle`. Neither fires `AccessRevokedEvent`: such an application calls `FireAccessRevoked` in the same transaction, so OAuth grants are revoked too.
|
||||
- `TouchLastSeen(ctx, db, userID)`.
|
||||
- `OAuthProvider` (`Name`, `Label`) and `OAuthProviderSource` (`EnabledOAuthProviders(ctx)`, `KnownOAuthProviders()`), the seam a social-login plugin publishes with an explicit type argument, `app.Publish[classes.OAuthProviderSource](src)`, so the lookup finds it. `GET /oauth-providers` lists its enabled providers in order; the unlink routes accept its known names.
|
||||
- `EndSession(ctx, app, w, r)` is what `POST /logout` runs: it blacklists every session JWT the request carries (bearer header, `token` and `auth_token` cookies) that verifies, an expired one too while its refresh window is open, until that window closes, writes the forget cookies described under JWT sessions, and returns the subject of the token the guard would read (`""` when there is none or it does not verify); only a failing blacklist write is an error.
|
||||
- `SessionToken(r)`, the session JWT the `jwt` guard reads from a request (bearer header, then the `token` and `auth_token` cookies; `""` when there is none); `SessionTokenCookie(r)`, the name of the session cookie that supplied that token (`""` when the bearer header did or there is none), which `POST /refresh` uses to rewrite the `auth.cookie` cookie; and `SessionCookieNames()`, a copy of that cookie list to pass to `bouncer.NewJWTGuard`, so a handler's own guard reads the same token.
|
||||
- `RequestUser(ctx, app, r)` and `EndRequestSession(ctx, app, w, r)`, for handlers that serve signed-in and anonymous browsers on one route (an OAuth provider's authorize page). `RequestUser` reads and checks the session JWT exactly as the `jwt` guard does (bearer header, then the `token` and `auth_token` cookies, the blacklist, `tokens_valid_after`) and returns the user, or `nil` for an anonymous, invalid, expired or blacklisted request and for a soft-deleted user; only an infrastructure failure is an error. `EndRequestSession` is the best-effort `EndSession`: it revokes and forgets the same way (the forget cookie of `bouncer.LoadCookieConfig`), but a blacklist failure is logged, never returned.
|
||||
- `AvatarURL(ctx, app, user, size)`, the display picture rule of the user payload at any size: the avatar thumbnail (size by size, mode auto) when the user has an avatar and a file bucket is published, the file path when the thumbnail fails, otherwise the gravatar URL. The URL may be protocol-relative or relative. The payload's `avatar_url` comes from it at 128.
|
||||
- `BackendLinks` (`CreateLinkRequest`, `LinkRequestByCode`, `ConfirmLinkRequest`, `SetLink`, `RemoveLink`, `LinkFor`, `LinkOf`, `LinkedBackendUser`, `RegisterBackendLinkListeners`), its errors `ErrLinkRequestNotFound`, `ErrLinkRequestUsed`, `ErrLinkRequestExpired`, `ErrStaleSession`, `ErrAlreadyLinked`, `ErrBackendUserUnavailable` and `ErrFrontendUserUnavailable`, the events `BackendLinkedEvent` / `BackendLinkedEventName` and `BackendUnlinkedEvent` / `BackendUnlinkedEventName`, `BackendLinkRequestTTL(app)`, `DefaultBackendLinkRequestTTL`, `FrontendURL(app)` and `BackendDisplayName(admin)`.
|
||||
- `PermissionManageBackendLinks`, `FrontendPermissionCatalog(ctx, app, db)`, `MCPUserGuard`, `FireSubjectChanged(ctx, app, guard, userID, kind)` and `FireSubjectChangedAfterCommit(ctx, app, tx, guard, userID, kind)` with the guard names `SubjectGuardUser` and `SubjectGuardBackend`.
|
||||
- `OrganisationLogoURL(ctx, app, org, size)`, `OrganisationLogoFields(ctx, app, org)` and `OrganisationLogoSize`, the organisation logo for an application's organisation payloads (see Organisations).
|
||||
- `AvatarURL(ctx, app, user, size)`, the display picture rule of the user payload at any size: the avatar thumbnail (size by size, mode auto) when the user has an avatar and a file bucket is published, the original file's public URL (`attach.File.URL`, which follows `storage.uploads.public_path_prefix` and `public_url`) when the thumbnail fails, otherwise the gravatar URL. The URL may be protocol-relative or relative. The payload's `avatar_url` comes from it at 128.
|
||||
- `BackendLinks` (`CreateLinkRequest`, `LinkRequestByCode`, `ConfirmLinkRequest`, `SetLink`, `RemoveLink`, `LinkFor`, `LinkOf`, `LinkedBackendUser`, `RegisterBackendLinkListeners`), its errors `ErrLinkRequestNotFound`, `ErrLinkRequestUsed`, `ErrLinkRequestExpired`, `ErrStaleSession`, `ErrAlreadyLinked`, `ErrBackendUserUnavailable` and `ErrFrontendUserUnavailable`, the events `BackendLinkedEvent` / `BackendLinkedEventName` and `BackendUnlinkedEvent` / `BackendUnlinkedEventName`, `BackendLinkRequestTTL(app)`, `DefaultBackendLinkRequestTTL`, `FrontendURL(app)`, `BackendLinkPagePath(app)`, `DefaultBackendLinkPagePath`, `BackendLinksFor(app)` (the published service, or one over the app) and `BackendDisplayName(admin)`.
|
||||
- `PermissionManageBackendLinks`, `FrontendPermissionCatalog(ctx, app, db)`, `MCPUserGuard`, `FireSubjectChanged(ctx, app, guard, userID, kind)` and `FireSubjectChangedAfterCommit(ctx, app, tx, guard, userID, kind)` with the guard names `SubjectGuardUser` and `SubjectGuardBackend`, and `FireSubjectEvent(ctx, app, ev)` and `FireSubjectEventAfterCommit(ctx, app, tx, ev)`, which fire a whole `pact.MCPSubjectChanged` (with `TokenID` or `ClientID` to end only one token's or one client's sessions).
|
||||
- `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`.
|
||||
- `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`.
|
||||
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `BackendLink`, `BackendLinkRequest` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code.
|
||||
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `BackendLink`, `BackendLinkRequest`, `DeviceAuthSession` (with `DeviceStatusPending`, `DeviceStatusConfirmed`, `DeviceStatusUsed`, `DeviceStatusExpired` and `DeviceStatusRevoked`) and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`) and its logo (`OrganisationLogoField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code.
|
||||
- `updates`: the gormigrate migrations, returned by `updates.All()`.
|
||||
- `console`: `RequirePasswordChange`, `BackfillHandles` and `APITokenCommands`.
|
||||
|
||||
@@ -249,7 +290,10 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
|
||||
| `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.frontend_url` | `""` | The site the plugin's own links point to: the account link page is this URL + `/link-admin?code=<code>`. Empty falls back to `app.url`; a trailing slash is dropped. |
|
||||
| `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.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. |
|
||||
| `golem15.user.turnstile` | `""` | Cloudflare Turnstile on `POST /login` and `POST /register` (only `/register` while `golem15.user.password.public_routes` is false). Empty adds nothing; `open` or `closed` appends the framework's `turnstile:open` or `turnstile:closed` middleware to those two routes only (summercms v0.3.0 and later; secret in `turnstile.secret`, token in the `turnstile_token` field). `open` lets requests pass when Cloudflare is unreachable, `closed` refuses them. Any other value fails boot. |
|
||||
|
||||
@@ -267,7 +311,7 @@ Each command uses the database the serve command published or, run from the cons
|
||||
|
||||
## Dependencies
|
||||
|
||||
- The SummerCMS framework v0.4.4, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `diary`, `pact`, `party`, `surf` and others).
|
||||
- The SummerCMS framework v0.5.0, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `diary`, `pact`, `party`, `surf` and others).
|
||||
- The organisation role cleanup of the admin (members manager and user form) uses cabana's `ClearOnChange`, added in SummerCMS v0.4.3.
|
||||
- gormigrate, GORM, golang-jwt and gocloud.dev, as pinned in `go.mod`.
|
||||
- testcontainers-go for the Postgres-backed tests.
|
||||
@@ -305,6 +349,8 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
|
||||
|
||||
`TestAdminOrganisationMembershipClearsRole` checks that the members manager's unlink and link and a Users form save that changes the organisation clear `organisation_role`, that a save keeping the organisation keeps it, and that deleting an organisation clears both columns.
|
||||
|
||||
`TestBackendLinkSmoke` runs the account link end to end: My account offers `link`, the action answers the site URL with a 64-hex code, the show route answers the administrator's login and expiry, a session older than the request gets `stale_session`, a fresh one links (the published resolver answers the administrator), the code is one-shot, a second frontend user gets `already_linked`, an expired code is 404, unlink and deleting the frontend user remove the link, and the subject events fire. `TestBackendLinkAdminSmoke` covers the Administrators screen actions: offered only as they apply and only with `golem15.users.manage_backend_links`, the case-insensitive email match, `linked_by`, `BackendLinkedEvent`, the 422 refusals and the unlink. `TestUserMCPGuardSmoke` covers the `user` guard provider (requirements, the scope and group-permission checks, the dispatch credential, personal token resolution), `GET /token/me` and the revoke, group and user subject events. `TestFrontendPermissionCatalogSmoke` boots a test plugin that declares frontend permission codes and checks both editors' options. All four need Docker.
|
||||
`TestBackendLinkSmoke` runs the account link end to end: My account offers `link`, the action answers the site URL with a 64-hex code, the show route answers the administrator's login and expiry, a session older than the request gets `stale_session`, a fresh one links (the published resolver answers the administrator), the code is one-shot, a second frontend user gets `already_linked`, an expired code is 404, unlink and deleting the frontend user remove the link, and the subject events fire. `TestBackendLinkAuthTimeSmoke` checks the `auth_time` proof: a session signed in before the request and refreshed after it gets `stale_session`, a sign-in after the request links, `POST /backend-link/show` answers for the code in the body, and My account's URL carries the code in the fragment. `TestBackendLinkAdminSmoke` covers the Administrators screen actions: offered only as they apply and only with `golem15.users.manage_backend_links`, the case-insensitive email match, `linked_by`, `BackendLinkedEvent`, the 422 refusals and the unlink. `TestUserMCPGuardSmoke` covers the `user` guard provider (requirements, the scope and group-permission checks, the dispatch credential, personal token resolution), `GET /token/me` and the revoke, group and user subject events. `TestFrontendPermissionCatalogSmoke` boots a test plugin that declares frontend permission codes and checks both editors' options. `TestMemberTokenScopesSmoke` checks the member token scope catalog (duplicate, built-in and bad names fail) and, with Docker, minting, the scope check, the list, `GET /tokens/scopes` and a second application refusing the scope. The others need Docker.
|
||||
|
||||
`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 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`.
|
||||
|
||||
234
access_revocation_test.go
Normal file
234
access_revocation_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// revokedSeen is one AccessRevokedEvent as a listener saw it, with what was
|
||||
// already visible at that moment.
|
||||
type revokedSeen struct {
|
||||
ev classes.AccessRevokedEvent
|
||||
// stored is tokens_valid_after of the first user read through ev.Tx.
|
||||
stored *time.Time
|
||||
// subjectEarly reports whether a revoked subject event for the first
|
||||
// user had already fired (it must wait for the commit).
|
||||
subjectEarly bool
|
||||
}
|
||||
|
||||
// revocationProbe records every AccessRevokedEvent of an app and can make
|
||||
// the listener fail.
|
||||
type revocationProbe struct {
|
||||
mu sync.Mutex
|
||||
seen []revokedSeen
|
||||
fail atomic.Bool
|
||||
}
|
||||
|
||||
func (p *revocationProbe) take() []revokedSeen {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := p.seen
|
||||
p.seen = nil
|
||||
return out
|
||||
}
|
||||
|
||||
func listenRevocations(env *adminEnv, spy *subjectSpy) *revocationProbe {
|
||||
probe := &revocationProbe{}
|
||||
env.app.Events.Listen("test.access_revoked", func(ctx context.Context, ev *classes.AccessRevokedEvent) error {
|
||||
if probe.fail.Load() {
|
||||
return errors.New("listener refused")
|
||||
}
|
||||
seen := revokedSeen{ev: *ev}
|
||||
if ev.Tx != nil && len(ev.UserIDs) > 0 {
|
||||
var row struct{ TokensValidAfter *time.Time }
|
||||
if err := ev.Tx.WithContext(ctx).Raw(`SELECT tokens_valid_after FROM users WHERE id = ?`, ev.UserIDs[0]).Scan(&row).Error; err == nil {
|
||||
seen.stored = row.TokensValidAfter
|
||||
}
|
||||
seen.subjectEarly = spy.has(classes.SubjectGuardUser, ev.UserIDs[0], pact.MCPSubjectRevoked)
|
||||
}
|
||||
probe.mu.Lock()
|
||||
probe.seen = append(probe.seen, seen)
|
||||
probe.mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
return probe
|
||||
}
|
||||
|
||||
// one asserts exactly one event naming id for reason, fired in a
|
||||
// transaction before the user's revoked subject event.
|
||||
func one(t *testing.T, probe *revocationProbe, spy *subjectSpy, id uint, reason string) revokedSeen {
|
||||
t.Helper()
|
||||
seen := probe.take()
|
||||
if len(seen) != 1 {
|
||||
t.Fatalf("%s fired %d access-revoked events, want 1: %+v", reason, len(seen), seen)
|
||||
}
|
||||
got := seen[0]
|
||||
if got.ev.Reason != reason || len(got.ev.UserIDs) != 1 || got.ev.UserIDs[0] != id || got.ev.Tx == nil {
|
||||
t.Fatalf("%s event = %+v, want user %d in a transaction", reason, got.ev, id)
|
||||
}
|
||||
if got.subjectEarly {
|
||||
t.Fatalf("%s: the revoked subject event fired before the commit", reason)
|
||||
}
|
||||
if !spy.has(classes.SubjectGuardUser, id, pact.MCPSubjectRevoked) {
|
||||
t.Fatalf("%s: no revoked subject event for user %d after the commit", reason, id)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// wholeSecondsUntruncated checks the stored cutoff has whole seconds and
|
||||
// Before is the untruncated instant of the same second.
|
||||
func wholeSecondsUntruncated(t *testing.T, env *adminEnv, id uint, before time.Time) {
|
||||
t.Helper()
|
||||
stored := env.reload(t, id).TokensValidAfter
|
||||
if stored == nil || stored.Nanosecond() != 0 {
|
||||
t.Fatalf("stored tokens_valid_after = %v, want whole seconds", stored)
|
||||
}
|
||||
if before.Before(*stored) || !before.Truncate(time.Second).Equal(stored.Truncate(time.Second)) {
|
||||
t.Fatalf("Before %s is not the untruncated instant of the stored cutoff %s", before, stored)
|
||||
}
|
||||
if before.Nanosecond() == 0 {
|
||||
t.Logf("the clock answered a whole second (%s); the sub-second part is not observable this run", before)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccessRevocation pins plan 08's revocation chain on the user side
|
||||
// (C-CR-01, RC-CR-01, P-35): the ban bulk action, ResetPassword,
|
||||
// ChangePassword and an administrator setting a password each fire one
|
||||
// AccessRevokedEvent in the write's transaction with the right ids, Before
|
||||
// and Reason (for ChangePassword the instant of the request, never the
|
||||
// calling session's older iat, while the session itself survives), then one
|
||||
// revoked subject event per user after the commit; the ban fires only for
|
||||
// newly banned users; a failing listener rolls a password reset back; and
|
||||
// a banned linked user loses the backend link until unbanned. Postgres
|
||||
// only: skipped under -short.
|
||||
func TestAccessRevocation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
env := newAdminEnv(t)
|
||||
spy := env.spySubjects()
|
||||
probe := listenRevocations(env, spy)
|
||||
admin := env.superAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("the ban bulk action fires for newly banned users only", func(t *testing.T) {
|
||||
already := env.seedUser(t, "rv-already", nil)
|
||||
env.seedThrottle(t, already, "203.0.113.20", true, nil)
|
||||
fresh := env.seedUser(t, "rv-ban", nil)
|
||||
probe.take()
|
||||
spy.reset()
|
||||
started := time.Now()
|
||||
env.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/ban"), admin, map[string]any{"ids": []uint{already.ID, fresh.ID}})
|
||||
got := one(t, probe, spy, fresh.ID, classes.AccessRevokedBan)
|
||||
if got.ev.Before.Before(started) || got.ev.Before.After(time.Now()) {
|
||||
t.Fatalf("ban Before %s outside the request", got.ev.Before)
|
||||
}
|
||||
if spy.has(classes.SubjectGuardUser, already.ID, pact.MCPSubjectRevoked) {
|
||||
t.Fatal("the already banned user got a revoked event")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ResetPassword fires with the untruncated instant", func(t *testing.T) {
|
||||
user := env.seedUser(t, "rv-reset", nil)
|
||||
code, err := classes.IssueResetCode(ctx, env.gdb, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe.take()
|
||||
spy.reset()
|
||||
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{"code": packed, "password": "rv-new-password", "password_confirmation": "rv-new-password"})
|
||||
got := one(t, probe, spy, user.ID, classes.AccessRevokedPasswordReset)
|
||||
if got.stored == nil {
|
||||
t.Fatal("the listener did not see the new cutoff inside the reset's transaction")
|
||||
}
|
||||
wholeSecondsUntruncated(t, env, user.ID, got.ev.Before)
|
||||
})
|
||||
|
||||
t.Run("ChangePassword revokes as of the request and keeps the calling session", func(t *testing.T) {
|
||||
user := env.seedUser(t, "rv-change", func(u *models.User) { u.HasSelfSetPassword = true })
|
||||
// A session last refreshed twenty minutes ago: an OAuth grant its
|
||||
// client rotated since then is newer than the session, and must
|
||||
// still end (RC-CR-01).
|
||||
iat := time.Now().Add(-20 * time.Minute)
|
||||
session := craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "rv-change-"+strconv.FormatInt(time.Now().UnixNano(), 10), iat, time.Now().Add(time.Hour))
|
||||
probe.take()
|
||||
spy.reset()
|
||||
started := time.Now()
|
||||
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/change-password", session, map[string]string{"current_password": "correct-horse", "password": "rv-changed-password", "password_confirmation": "rv-changed-password"})
|
||||
got := one(t, probe, spy, user.ID, classes.AccessRevokedPasswordChanged)
|
||||
if got.ev.Before.Before(started) || got.ev.Before.After(time.Now()) {
|
||||
t.Fatalf("Before %s is not the instant of the request (started %s): grants refreshed after the session's iat %s would survive", got.ev.Before, started, iat)
|
||||
}
|
||||
stored := env.reload(t, user.ID).TokensValidAfter
|
||||
if want := time.Unix(iat.Unix(), 0).Add(-time.Second); stored == nil || !stored.Equal(want) {
|
||||
t.Fatalf("stored cutoff %v, want the session's iat second minus one (%s)", stored, want)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
|
||||
})
|
||||
|
||||
t.Run("an administrator setting a password fires with the untruncated instant", func(t *testing.T) {
|
||||
user := env.seedUser(t, "rv-admin-set", nil)
|
||||
probe.take()
|
||||
spy.reset()
|
||||
const set = "rv-admin-password"
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{"name": user.Name, "email": user.Email, "password": set, "password_confirmation": set})
|
||||
got := one(t, probe, spy, user.ID, classes.AccessRevokedPasswordSetByAdmin)
|
||||
wholeSecondsUntruncated(t, env, user.ID, got.ev.Before)
|
||||
})
|
||||
|
||||
t.Run("a failing listener rolls the password reset back", func(t *testing.T) {
|
||||
user := env.seedUser(t, "rv-rollback", nil)
|
||||
code, err := classes.IssueResetCode(ctx, env.gdb, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spy.reset()
|
||||
probe.fail.Store(true)
|
||||
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||
rec := env.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{"code": packed, "password": "rv-never-set", "password_confirmation": "rv-never-set"})
|
||||
probe.fail.Store(false)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("reset with a failing listener = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if status, _ := env.userLogin(t, user.Email, "correct-horse"); status != http.StatusOK {
|
||||
t.Fatalf("the old password stopped working (%d): the reset was not rolled back", status)
|
||||
}
|
||||
if status, _ := env.userLogin(t, user.Email, "rv-never-set"); status == http.StatusOK {
|
||||
t.Fatal("the new password works: the reset was not rolled back")
|
||||
}
|
||||
if spy.has(classes.SubjectGuardUser, user.ID, pact.MCPSubjectRevoked) {
|
||||
t.Fatal("a rolled-back reset fired a revoked subject event")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a banned linked user loses the backend link until unbanned", func(t *testing.T) {
|
||||
user := env.seedUser(t, "rv-linked", nil)
|
||||
code := env.startLink(t, env.admin(t))
|
||||
if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusOK {
|
||||
t.Fatalf("link = %d %s", status, body)
|
||||
}
|
||||
links, _ := env.app.Lookup[*classes.BackendLinks]()
|
||||
if _, ok, err := links.LinkedBackendUser(ctx, user.ID); err != nil || !ok {
|
||||
t.Fatalf("linked before the ban = %v, %v", ok, err)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/ban"), admin, map[string]any{"ids": []uint{user.ID}})
|
||||
if _, ok, err := links.LinkedBackendUser(ctx, user.ID); err != nil || ok {
|
||||
t.Fatalf("linked while banned = %v, %v", ok, err)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/unban"), admin, map[string]any{"ids": []uint{user.ID}})
|
||||
if _, ok, err := links.LinkedBackendUser(ctx, user.ID); err != nil || !ok {
|
||||
t.Fatalf("linked after the unban = %v, %v", ok, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -55,12 +55,7 @@ func (p *Plugin) AccountContributions() []pact.AccountContribution {
|
||||
|
||||
// backendLinks returns the published link service, or one over the app.
|
||||
func (p *Plugin) backendLinks() *classes.BackendLinks {
|
||||
if p.app != nil {
|
||||
if links, ok := p.app.Lookup[*classes.BackendLinks](); ok && links != nil {
|
||||
return links
|
||||
}
|
||||
}
|
||||
return &classes.BackendLinks{App: p.app}
|
||||
return classes.BackendLinksFor(p.app)
|
||||
}
|
||||
|
||||
// frontendAccountStatus answers "Linked to <email>" or "Not linked" in the
|
||||
@@ -110,8 +105,11 @@ func (p *Plugin) frontendEmail(ctx context.Context, userID uint) (string, error)
|
||||
}
|
||||
|
||||
// startFrontendLink creates a link request for the signed-in administrator
|
||||
// and answers the site page that confirms it:
|
||||
// golem15.user.frontend_url (else app.url) + /link-admin?code=<code>.
|
||||
// and answers the site page that confirms it: golem15.user.frontend_url
|
||||
// (else app.url), then golem15.user.backend_links.page_path (default
|
||||
// /link-admin), then the code in the URL fragment. Browsers never send a
|
||||
// fragment to a server, so the code stays out of access logs; the page reads
|
||||
// it in the browser and posts it to /_user/api/v1/backend-link/show.
|
||||
func (p *Plugin) startFrontendLink(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) {
|
||||
plain, _, err := p.backendLinks().CreateLinkRequest(ctx, in.AdminID)
|
||||
if err != nil {
|
||||
@@ -119,7 +117,7 @@ func (p *Plugin) startFrontendLink(ctx context.Context, in pact.AccountActionInp
|
||||
}
|
||||
return pact.AccountActionResult{
|
||||
Message: backendLinkLang + "link_started",
|
||||
URL: classes.FrontendURL(p.app) + "/link-admin?code=" + plain,
|
||||
URL: classes.FrontendURL(p.app) + classes.BackendLinkPagePath(p.app) + "#code=" + plain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -156,13 +156,17 @@ func TestAdminOrganisations(t *testing.T) {
|
||||
if field.Mode != "image" || field.ImageWidth != 120 || field.ImageHeight != 120 {
|
||||
t.Fatalf("avatar field = %+v", field)
|
||||
}
|
||||
case "logo":
|
||||
if field.Mode != "image" || field.ImageWidth != 240 || field.ImageHeight != 120 {
|
||||
t.Fatalf("logo field = %+v", field)
|
||||
}
|
||||
case "members":
|
||||
if field.Context != "update" || field.Tab != "Members" {
|
||||
t.Fatalf("members field = %+v", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
if want := []string{"name:text", "slug:text", "description:textarea", "avatar:fileupload", "members:relation-manager"}; !reflect.DeepEqual(names, want) {
|
||||
if want := []string{"name:text", "slug:text", "description:textarea", "avatar:fileupload", "logo:fileupload", "members:relation-manager"}; !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("form fields = %v, want %v", names, want)
|
||||
}
|
||||
if len(form.Preview) != 0 && string(form.Preview) != "null" {
|
||||
|
||||
@@ -16,7 +16,9 @@ const userPermissionTab = "golem15.user::lang.plugin.tab"
|
||||
// or password of a privileged-group member or delete one. The sixth
|
||||
// (classes.PermissionManageBackendLinks) has no default role either: it
|
||||
// links and unlinks another administrator's frontend account on the
|
||||
// Administrators screen.
|
||||
// Administrators screen. The seventh (classes.PermissionResetTwoFactor) has
|
||||
// no default role either: it runs the Users screen's "Reset 2FA" action,
|
||||
// which a two-factor plugin backs.
|
||||
func (*Plugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{
|
||||
{
|
||||
@@ -53,5 +55,10 @@ func (*Plugin) Permissions() []pact.Permission {
|
||||
Tab: userPermissionTab,
|
||||
Label: "golem15.user::lang.plugin.manage_backend_links",
|
||||
},
|
||||
{
|
||||
Code: classes.PermissionResetTwoFactor,
|
||||
Tab: userPermissionTab,
|
||||
Label: "golem15.user::lang.plugin.reset_two_factor",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ var langKey = regexp.MustCompile(`golem15\.user::lang\.[a-z0-9_]+(\.[a-z0-9_]+)+
|
||||
func TestAdminRegistration(t *testing.T) {
|
||||
plugin := &Plugin{}
|
||||
|
||||
// The six permission codes, each on the plugin's tab.
|
||||
// The seven permission codes, each on the plugin's tab.
|
||||
var codes []string
|
||||
for _, permission := range plugin.Permissions() {
|
||||
codes = append(codes, permission.Code)
|
||||
@@ -40,15 +40,15 @@ func TestAdminRegistration(t *testing.T) {
|
||||
wantCodes := []string{
|
||||
"golem15.users.access_users", "golem15.users.access_groups", "golem15.users.access_settings",
|
||||
"golem15.users.impersonate_user", "golem15.users.manage_privileged_groups",
|
||||
"golem15.users.manage_backend_links",
|
||||
"golem15.users.manage_backend_links", "golem15.users.reset_two_factor",
|
||||
}
|
||||
if !reflect.DeepEqual(codes, wantCodes) {
|
||||
t.Fatalf("permission codes = %v, want %v", codes, wantCodes)
|
||||
}
|
||||
// The privileged-groups and backend-links permissions have no default
|
||||
// role: nobody holds them until they are granted on purpose.
|
||||
// The privileged-groups, backend-links and reset-2FA permissions have no
|
||||
// default role: nobody holds them until they are granted on purpose.
|
||||
for _, permission := range plugin.Permissions() {
|
||||
if privileged := permission.Code == permManagePrivileged || permission.Code == permManageBackendLinks; privileged != (len(permission.Roles) == 0) {
|
||||
if privileged := permission.Code == permManagePrivileged || permission.Code == permManageBackendLinks || permission.Code == permResetTwoFactor; privileged != (len(permission.Roles) == 0) {
|
||||
t.Fatalf("permission %s has roles %v", permission.Code, permission.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,14 @@ func TestAdminUsersControllerGuards(t *testing.T) {
|
||||
if action.Name == "activate" {
|
||||
continue // reads the record only
|
||||
}
|
||||
if action.Name == "reset_two_factor" {
|
||||
// No classes.TwoFactorAdmin is published, so it reads
|
||||
// nothing and is simply not offered.
|
||||
if applies, err := action.Applies(ctx, user); applies || err != nil {
|
||||
t.Fatalf("applies reset_two_factor without a service = %v, %v", applies, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, err := action.Applies(ctx, user)
|
||||
wantPlainError(t, "applies "+action.Name, err)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ func TestAdminUsersTracer(t *testing.T) {
|
||||
"golem15.users.access_users@developer", "golem15.users.access_groups@developer",
|
||||
"golem15.users.access_settings@developer", "golem15.users.impersonate_user@developer",
|
||||
classes.PermissionManagePrivilegedGroups + "@-", classes.PermissionManageBackendLinks + "@-",
|
||||
classes.PermissionResetTwoFactor + "@-",
|
||||
}; !reflect.DeepEqual(codes, want) || classes.PermissionManagePrivilegedGroups != permManagePrivileged {
|
||||
t.Fatalf("permissions = %v", codes)
|
||||
}
|
||||
@@ -610,7 +611,7 @@ func TestAdminUserActions(t *testing.T) {
|
||||
registered = append(registered, action.Name)
|
||||
}
|
||||
}
|
||||
if want := []string{"activate", "unban", "unsuspend"}; !reflect.DeepEqual(registered, want) {
|
||||
if want := []string{"activate", "unban", "unsuspend", "reset_two_factor"}; !reflect.DeepEqual(registered, want) {
|
||||
t.Fatalf("record actions = %v, want %v", registered, want)
|
||||
}
|
||||
var form struct {
|
||||
|
||||
37
api_token_events_test.go
Normal file
37
api_token_events_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// TestAPITokenRevokeEvent pins plan 08's token-specific MCP revocation
|
||||
// (C-IN-02, user side): DELETE /tokens/{id} fires exactly one revoked
|
||||
// subject event for the user carrying that TokenID, so only the sessions of
|
||||
// that token end. Postgres only: skipped under -short.
|
||||
func TestAPITokenRevokeEvent(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
env := newAdminEnv(t)
|
||||
spy := env.spySubjects()
|
||||
user := env.seedUser(t, "token-revoke", nil)
|
||||
_, kept := env.mintUserToken(t, user, "read")
|
||||
_, gone := env.mintUserToken(t, user, "read")
|
||||
spy.reset()
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/tokens/"+strconv.FormatUint(uint64(gone.ID), 10), freshUserToken(t, user), nil)
|
||||
spy.mu.Lock()
|
||||
events := append([]pact.MCPSubjectChanged(nil), spy.events...)
|
||||
spy.mu.Unlock()
|
||||
want := pact.MCPSubjectChanged{Guard: classes.SubjectGuardUser, UserID: user.ID, Kind: pact.MCPSubjectRevoked, TokenID: gone.ID}
|
||||
if len(events) != 1 || events[0] != want {
|
||||
t.Fatalf("events = %+v, want exactly %+v", events, want)
|
||||
}
|
||||
if spy.hasEvent(pact.MCPSubjectChanged{Guard: classes.SubjectGuardUser, UserID: user.ID, Kind: pact.MCPSubjectRevoked, TokenID: kept.ID}) {
|
||||
t.Fatal("the kept token was revoked too")
|
||||
}
|
||||
}
|
||||
@@ -104,8 +104,13 @@ func TestAvatarURL(t *testing.T) {
|
||||
if err := attach.Publish(failing, closed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := classes.AvatarURL(ctx, failing, withFile, 128); err != nil || got != "/"+attach.BlobKey(row.DiskName) {
|
||||
t.Fatalf("failing thumbnail = %q %v, want /%s", got, err, attach.BlobKey(row.DiskName))
|
||||
// The file's public URL follows storage.uploads.public_path_prefix
|
||||
// (RC-IN-03), never a bare /<key>.
|
||||
if want := attach.PublicURL(attach.BlobKey(row.DiskName)); want == "/"+attach.BlobKey(row.DiskName) {
|
||||
t.Fatalf("the public path prefix is empty (%q): the fallback is not observable", attach.PublicPathPrefix())
|
||||
}
|
||||
if got, err := classes.AvatarURL(ctx, failing, withFile, 128); err != nil || got != attach.PublicURL(attach.BlobKey(row.DiskName)) {
|
||||
t.Fatalf("failing thumbnail = %q %v, want %s", got, err, attach.PublicURL(attach.BlobKey(row.DiskName)))
|
||||
}
|
||||
|
||||
// A failing lookup is the only error.
|
||||
|
||||
103
backend_link_auth_time_smoke_test.go
Normal file
103
backend_link_auth_time_smoke_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// craftSignInToken is craftToken with an auth_time claim: a session token
|
||||
// issued at iat for a sign-in at authTime.
|
||||
func craftSignInToken(t *testing.T, sub, jti string, iat, authTime time.Time) string {
|
||||
t.Helper()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"iss": "http://example.com/_user/api/v1/login",
|
||||
"iat": iat.Unix(),
|
||||
"nbf": iat.Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"jti": jti,
|
||||
"prv": prvHash,
|
||||
bouncer.ClaimAuthTime: authTime.Unix(),
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(sessionSecret))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
// TestBackendLinkAuthTimeSmoke: the account link proof is the sign-in time.
|
||||
// A session signed in before the request and refreshed after it is refused
|
||||
// with stale_session, a sign-in after the request confirms, POST show
|
||||
// answers the administrator for the code in the body, and My account's URL
|
||||
// carries the code in the fragment, never in a query string. Skips under
|
||||
// -short.
|
||||
func TestBackendLinkAuthTimeSmoke(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
user := env.seedUser(t, "auth-time", nil)
|
||||
sub := strconv.FormatUint(uint64(user.ID), 10)
|
||||
admin := env.admin(t)
|
||||
login := env.adminLogin(t, adminID(t, admin))
|
||||
|
||||
// 1. The user signed in an hour ago.
|
||||
old := craftSignInToken(t, sub, "auth-time-old-"+adminStamp(), time.Now().Add(-time.Hour), time.Now().Add(-time.Hour))
|
||||
|
||||
// 2. The administrator starts a link; 6. its URL holds the code in the
|
||||
// fragment only.
|
||||
var started struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), admin, map[string]any{}), &started)
|
||||
if !strings.Contains(started.URL, "/link-admin#code=") || strings.Contains(started.URL, "?") {
|
||||
t.Fatalf("link URL = %q", started.URL)
|
||||
}
|
||||
m := linkURL.FindStringSubmatch(started.URL)
|
||||
if m == nil {
|
||||
t.Fatalf("link URL = %q", started.URL)
|
||||
}
|
||||
code := m[1]
|
||||
|
||||
// 5. POST show answers the administrator for the code in the body.
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/backend-link/show", "", map[string]string{"code": code})
|
||||
var shown struct {
|
||||
Data struct {
|
||||
Admin map[string]any `json:"admin"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &shown); err != nil || shown.Data.Admin["login"] != login {
|
||||
t.Fatalf("POST show = %s (%v)", rec.Body.String(), err)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "no-store, private" {
|
||||
t.Fatalf("POST show Cache-Control = %q", got)
|
||||
}
|
||||
env.expect(t, http.StatusNotFound, http.MethodPost, "/_user/api/v1/backend-link/show", "", map[string]string{"code": strings.Repeat("0", 64)})
|
||||
|
||||
// 3. Refreshing the old session after the request does not make it a
|
||||
// fresh sign-in.
|
||||
rec = env.refresh(t, old)
|
||||
var refreshed struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &refreshed); rec.Code != http.StatusOK || err != nil || refreshed.Token == "" {
|
||||
t.Fatalf("refresh = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if status, body := env.confirmLink(t, refreshed.Token, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
|
||||
t.Fatalf("refreshed confirm = %d %s", status, body)
|
||||
}
|
||||
|
||||
// 4. A sign-in after the request confirms.
|
||||
status, fresh := env.userLogin(t, user.Email, "correct-horse")
|
||||
if status != http.StatusOK || fresh == "" {
|
||||
t.Fatalf("login = %d", status)
|
||||
}
|
||||
if status, body := env.confirmLink(t, fresh, code); status != http.StatusOK || !strings.Contains(body, `"login":"`+login+`"`) {
|
||||
t.Fatalf("fresh confirm = %d %s", status, body)
|
||||
}
|
||||
}
|
||||
114
backend_link_auth_time_test.go
Normal file
114
backend_link_auth_time_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
)
|
||||
|
||||
// TestBackendLinkAuthTime pins the account link proof of plan 08 (C-CR-02,
|
||||
// P-34): confirming a link needs a sign-in at or after the request, read
|
||||
// from the session's auth_time, which refresh copies and never renews. A
|
||||
// session signed in before the request stays stale through a refresh, a
|
||||
// sign-in after the request confirms (also after a refresh), a token
|
||||
// without auth_time is stale, and a session token of another user than the
|
||||
// authenticated one is 401. Postgres only: skipped under -short.
|
||||
func TestBackendLinkAuthTime(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
env := newAdminEnv(t)
|
||||
login := func(t *testing.T, user *models.User) string {
|
||||
t.Helper()
|
||||
status, token := env.userLogin(t, user.Email, "correct-horse")
|
||||
if status != http.StatusOK || token == "" {
|
||||
t.Fatalf("login = %d", status)
|
||||
}
|
||||
return token
|
||||
}
|
||||
refresh := func(t *testing.T, token string) string {
|
||||
t.Helper()
|
||||
rec := env.refresh(t, token)
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); rec.Code != http.StatusOK || err != nil || body.Token == "" {
|
||||
t.Fatalf("refresh = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return body.Token
|
||||
}
|
||||
// signInEarlier waits past the second of the sign-in, so a request
|
||||
// created next is strictly later (auth_time has whole seconds).
|
||||
signInEarlier := func() { time.Sleep(1100 * time.Millisecond) }
|
||||
expect := func(t *testing.T, token, code string, status int, contains string) {
|
||||
t.Helper()
|
||||
got, body := env.confirmLink(t, token, code)
|
||||
if got != status || !strings.Contains(body, contains) {
|
||||
t.Fatalf("confirm = %d %s, want %d with %q", got, body, status, contains)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("login, request, refresh, confirm is stale", func(t *testing.T) {
|
||||
user := env.seedUser(t, "at-refresh", nil)
|
||||
session := login(t, user)
|
||||
signInEarlier()
|
||||
code := env.startLink(t, env.admin(t))
|
||||
expect(t, refresh(t, session), code, http.StatusConflict, `"stale_session"`)
|
||||
})
|
||||
|
||||
t.Run("request, login, confirm links", func(t *testing.T) {
|
||||
user := env.seedUser(t, "at-after", nil)
|
||||
code := env.startLink(t, env.admin(t))
|
||||
expect(t, login(t, user), code, http.StatusOK, `"backend_user"`)
|
||||
})
|
||||
|
||||
t.Run("login, request, login again, confirm links", func(t *testing.T) {
|
||||
user := env.seedUser(t, "at-again", nil)
|
||||
first := login(t, user)
|
||||
signInEarlier()
|
||||
code := env.startLink(t, env.admin(t))
|
||||
expect(t, first, code, http.StatusConflict, `"stale_session"`)
|
||||
expect(t, login(t, user), code, http.StatusOK, `"backend_user"`)
|
||||
})
|
||||
|
||||
t.Run("a hand-made token without auth_time is stale", func(t *testing.T) {
|
||||
user := env.seedUser(t, "at-legacy", nil)
|
||||
code := env.startLink(t, env.admin(t))
|
||||
legacy := craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "at-legacy-"+adminStamp(), time.Now(), time.Now().Add(time.Hour))
|
||||
expect(t, legacy, code, http.StatusConflict, `"stale_session"`)
|
||||
})
|
||||
|
||||
t.Run("a refreshed session of a post-request sign-in keeps working", func(t *testing.T) {
|
||||
user := env.seedUser(t, "at-carried", nil)
|
||||
code := env.startLink(t, env.admin(t))
|
||||
session := login(t, user)
|
||||
expect(t, refresh(t, refresh(t, session)), code, http.StatusOK, `"backend_user"`)
|
||||
})
|
||||
|
||||
t.Run("another user's session token is 401", func(t *testing.T) {
|
||||
user := env.seedUser(t, "at-owner", nil)
|
||||
other := env.seedUser(t, "at-other", nil)
|
||||
code := env.startLink(t, env.admin(t))
|
||||
// jwt.auth authenticated user, but the session token the handler
|
||||
// reads again for its sign-in time belongs to other.
|
||||
req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/backend-link/confirm", strings.NewReader(`{"code":"`+code+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+login(t, other))
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID}))
|
||||
rec := httptest.NewRecorder()
|
||||
controllers.BackendLinkConfirm(env.app)(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("mismatched session token = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// The code is still usable by its rightful user.
|
||||
expect(t, login(t, user), code, http.StatusOK, `"backend_user"`)
|
||||
})
|
||||
}
|
||||
141
backend_link_show_test.go
Normal file
141
backend_link_show_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// TestBackendLinkShowPost pins plan 08's link code transport (C-WR-01,
|
||||
// C-IN-06): POST /backend-link/show answers exactly like the GET route (the
|
||||
// data for a live code, 404 for an unknown, a used or an expired one), both
|
||||
// with Cache-Control no-store, private; a code in the query string of the
|
||||
// show or confirm POST is ignored (RC-IN-01); My account answers the link as
|
||||
// <frontend_url>/link-admin#code=<64 hex> with no query string;
|
||||
// golem15.user.backend_links.page_path changes the path, and a value
|
||||
// without a leading slash gets one. Postgres only: skipped under
|
||||
// -short.
|
||||
func TestBackendLinkShowPost(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
env := newAdminEnv(t)
|
||||
show := func(t *testing.T, code string) (get, post *struct {
|
||||
status int
|
||||
body string
|
||||
cache string
|
||||
}) {
|
||||
t.Helper()
|
||||
g := env.call(t, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil)
|
||||
p := env.call(t, http.MethodPost, "/_user/api/v1/backend-link/show", "", map[string]string{"code": code})
|
||||
return &struct {
|
||||
status int
|
||||
body string
|
||||
cache string
|
||||
}{g.Code, g.Body.String(), g.Header().Get("Cache-Control")}, &struct {
|
||||
status int
|
||||
body string
|
||||
cache string
|
||||
}{p.Code, p.Body.String(), p.Header().Get("Cache-Control")}
|
||||
}
|
||||
same := func(t *testing.T, code string, status int) {
|
||||
t.Helper()
|
||||
get, post := show(t, code)
|
||||
if get.status != status || post.status != status {
|
||||
t.Fatalf("show %s: GET %d, POST %d, want %d", code[:8], get.status, post.status, status)
|
||||
}
|
||||
if !bytes.Equal([]byte(get.body), []byte(post.body)) {
|
||||
t.Fatalf("POST show differs from GET:\nGET %s\nPOST %s", get.body, post.body)
|
||||
}
|
||||
if get.cache != "no-store, private" || post.cache != "no-store, private" {
|
||||
t.Fatalf("Cache-Control: GET %q, POST %q", get.cache, post.cache)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("POST show answers like GET", func(t *testing.T) {
|
||||
token := env.admin(t)
|
||||
live := env.startLink(t, token)
|
||||
same(t, live, http.StatusOK)
|
||||
if _, post := show(t, live); !strings.Contains(post.body, `"admin"`) || !strings.Contains(post.body, `"expires_at"`) {
|
||||
t.Fatalf("POST show data = %s", post.body)
|
||||
}
|
||||
same(t, strings.Repeat("a", 64), http.StatusNotFound)
|
||||
|
||||
used := env.startLink(t, env.admin(t))
|
||||
if status, body := env.confirmLink(t, freshUserToken(t, env.seedUser(t, "show-used", nil)), used); status != http.StatusOK {
|
||||
t.Fatalf("confirm = %d %s", status, body)
|
||||
}
|
||||
same(t, used, http.StatusNotFound)
|
||||
|
||||
expiring := env.admin(t)
|
||||
expired := env.startLink(t, expiring)
|
||||
if err := env.gdb.Model(&models.BackendLinkRequest{}).Where("backend_user_id = ?", adminID(t, expiring)).
|
||||
Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same(t, expired, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("a code in the query string is ignored", func(t *testing.T) {
|
||||
live := env.startLink(t, env.admin(t))
|
||||
path := "/_user/api/v1/backend-link/show?code=" + live
|
||||
for name, send := range map[string]func() *httptest.ResponseRecorder{
|
||||
"no body": func() *httptest.ResponseRecorder { return env.send(t, http.MethodPost, path, "", nil, "", nil) },
|
||||
"a JSON body without a code": func() *httptest.ResponseRecorder { return env.call(t, http.MethodPost, path, "", map[string]string{}) },
|
||||
"a form body without a code": func() *httptest.ResponseRecorder {
|
||||
return env.send(t, http.MethodPost, path, "", []byte("other=1"), "application/x-www-form-urlencoded", nil)
|
||||
},
|
||||
} {
|
||||
if got := send(); got.Code != http.StatusNotFound {
|
||||
t.Fatalf("show with ?code= and %s = %d %s, want 404", name, got.Code, got.Body.String())
|
||||
}
|
||||
}
|
||||
user := env.seedUser(t, "show-query", nil)
|
||||
rec := env.send(t, http.MethodPost, "/_user/api/v1/backend-link/confirm?code="+live, freshUserToken(t, user), []byte("other=1"), "application/x-www-form-urlencoded", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("confirm with ?code= = %d %s, want 404", rec.Code, rec.Body.String())
|
||||
}
|
||||
// A form body still carries the code, and the query string never did.
|
||||
form := env.send(t, http.MethodPost, "/_user/api/v1/backend-link/show", "", []byte("code="+live), "application/x-www-form-urlencoded", nil)
|
||||
if form.Code != http.StatusOK {
|
||||
t.Fatalf("show with the code in a form body = %d %s", form.Code, form.Body.String())
|
||||
}
|
||||
if status, body := env.confirmLink(t, freshUserToken(t, user), live); status != http.StatusOK {
|
||||
t.Fatalf("confirm with the code in the body after the refusals = %d %s", status, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the My account URL carries the code in the fragment only", func(t *testing.T) {
|
||||
var out struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), env.admin(t), map[string]any{}), &out)
|
||||
if !regexp.MustCompile(`^https://app\.example\.test/link-admin#code=[a-f0-9]{64}$`).MatchString(out.URL) || strings.Contains(out.URL, "?") {
|
||||
t.Fatalf("link URL = %q", out.URL)
|
||||
}
|
||||
})
|
||||
|
||||
for _, c := range []struct{ value, want string }{{"/pl/polacz", "/pl/polacz"}, {"pl/polacz", "/pl/polacz"}} {
|
||||
t.Run("page_path "+c.value, func(t *testing.T) {
|
||||
custom := newAdminEnv(t, func(cfg *compass.Config) {
|
||||
if err := cfg.Set("golem15.user.backend_links.page_path", c.value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
var out struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
adminInto(t, custom.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), custom.admin(t), map[string]any{}), &out)
|
||||
if !regexp.MustCompile(`^https://app\.example\.test` + regexp.QuoteMeta(c.want) + `#code=[a-f0-9]{64}$`).MatchString(out.URL) {
|
||||
t.Fatalf("link URL with page_path %q = %q", c.value, out.URL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,13 @@ func (s *subjectSpy) has(guard string, id uint, kind string) bool {
|
||||
return slices.Contains(s.events, pact.MCPSubjectChanged{Guard: guard, UserID: id, Kind: kind})
|
||||
}
|
||||
|
||||
// hasEvent reports whether ev, with its TokenID and ClientID, was fired.
|
||||
func (s *subjectSpy) hasEvent(ev pact.MCPSubjectChanged) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return slices.Contains(s.events, ev)
|
||||
}
|
||||
|
||||
func (s *subjectSpy) reset() {
|
||||
s.mu.Lock()
|
||||
s.events = nil
|
||||
@@ -113,7 +120,7 @@ func (e *adminEnv) frontendSection(t *testing.T, token string) accountSection {
|
||||
return accountSection{}
|
||||
}
|
||||
|
||||
var linkURL = regexp.MustCompile(`^https://app\.example\.test/link-admin\?code=([a-f0-9]{64})$`)
|
||||
var linkURL = regexp.MustCompile(`^https://app\.example\.test/link-admin#code=([a-f0-9]{64})$`)
|
||||
|
||||
// startLink runs My account's link action and returns the code in its URL.
|
||||
func (e *adminEnv) startLink(t *testing.T, token string) string {
|
||||
|
||||
217
backend_link_test.go
Normal file
217
backend_link_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// requestCreatedAt reads when the administrator's open link request was
|
||||
// created.
|
||||
func (e *adminEnv) requestCreatedAt(t *testing.T, adminID uint) time.Time {
|
||||
t.Helper()
|
||||
var created time.Time
|
||||
if err := e.gdb.Raw(`SELECT created_at FROM golem15_user_backend_link_requests WHERE backend_user_id = ? AND used_at IS NULL ORDER BY id DESC LIMIT 1`, adminID).Scan(&created).Error; err != nil || created.IsZero() {
|
||||
t.Fatalf("link request created_at = %v, %v", created, err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
// TestBackendLink (D-03..D-08) extends the link smokes: the fresh sign-in
|
||||
// rule at the second (an auth_time equal to the request's created_at
|
||||
// confirms, one second earlier is stale_session, and a token without
|
||||
// auth_time is stale_session whatever its iat); 1:1 on the frontend side too (a
|
||||
// linked user cannot confirm another administrator's request); the show
|
||||
// route exposes the administrator's login and name and the expiry only;
|
||||
// SetLink records linked_by and fires BackendLinkedEvent and both subject
|
||||
// events after commit, nothing on rollback; RemoveLink fires
|
||||
// BackendUnlinkedEvent and both subject events; LinkedBackendUser answers
|
||||
// false for a deactivated or soft-deleted administrator; deactivating the
|
||||
// frontend user removes the link and fires deleted. Skips under -short.
|
||||
func TestBackendLink(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
spy := env.spySubjects()
|
||||
links, _ := env.app.Lookup[*classes.BackendLinks]()
|
||||
resolver, _ := env.app.Lookup[pact.MCPLinkedBackendResolver]()
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("fresh sign-in at the second", func(t *testing.T) {
|
||||
token := env.admin(t)
|
||||
admin := adminID(t, token)
|
||||
user := env.seedUser(t, "iat", nil)
|
||||
sub := strconv.FormatUint(uint64(user.ID), 10)
|
||||
code := env.startLink(t, token)
|
||||
created := env.requestCreatedAt(t, admin).Truncate(time.Second)
|
||||
early := craftSignInToken(t, sub, "early-"+adminStamp(), time.Now(), created.Add(-time.Second))
|
||||
if status, body := env.confirmLink(t, early, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
|
||||
t.Fatalf("auth_time one second early = %d %s", status, body)
|
||||
}
|
||||
noAuthTime := craftToken(t, sub, "legacy-"+adminStamp(), time.Now(), time.Now().Add(time.Hour))
|
||||
if status, body := env.confirmLink(t, noAuthTime, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
|
||||
t.Fatalf("no auth_time = %d %s", status, body)
|
||||
}
|
||||
exact := craftSignInToken(t, sub, "exact-"+adminStamp(), created, created)
|
||||
if status, body := env.confirmLink(t, exact, code); status != http.StatusOK {
|
||||
t.Fatalf("auth_time equal to created_at = %d %s", status, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a linked frontend user cannot take a second administrator", func(t *testing.T) {
|
||||
first, second := env.admin(t), env.admin(t)
|
||||
user := env.seedUser(t, "one-to-one", nil)
|
||||
code := env.startLink(t, first)
|
||||
if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusOK {
|
||||
t.Fatalf("first link = %d %s", status, body)
|
||||
}
|
||||
code = env.startLink(t, second)
|
||||
if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusConflict || !strings.Contains(body, `"already_linked"`) {
|
||||
t.Fatalf("second administrator = %d %s", status, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("show exposes login, name and expiry only", func(t *testing.T) {
|
||||
token := env.admin(t)
|
||||
code := env.startLink(t, token)
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil)
|
||||
var shown map[string]map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &shown); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys := func(m map[string]any) []string {
|
||||
var out []string
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
admin, _ := shown["data"]["admin"].(map[string]any)
|
||||
if len(shown) != 1 || !slices.Equal(keys(shown["data"]), []string{"admin", "expires_at"}) || !slices.Equal(keys(admin), []string{"login", "name"}) {
|
||||
t.Fatalf("show = %s", rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "@") || strings.Contains(rec.Body.String(), code) {
|
||||
t.Fatalf("show leaks an email or the code: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SetLink and RemoveLink events", func(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var linked, unlinked int
|
||||
env.app.Events.Listen("test.link_events", func(context.Context, *classes.BackendLinkedEvent) error {
|
||||
mu.Lock()
|
||||
linked++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
env.app.Events.Listen("test.link_events", func(context.Context, *classes.BackendUnlinkedEvent) error {
|
||||
mu.Lock()
|
||||
unlinked++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
setter := adminID(t, env.admin(t))
|
||||
target := adminID(t, env.admin(t))
|
||||
user := env.seedUser(t, "set-link", nil)
|
||||
|
||||
// A rolled-back SetLink fires no subject event.
|
||||
spy.reset()
|
||||
_ = lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if _, err := links.SetLink(ctx, tx, user.ID, target, setter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return context.Canceled
|
||||
})
|
||||
if spy.has("user", user.ID, pact.MCPSubjectLink) || spy.has("backend", target, pact.MCPSubjectLink) {
|
||||
t.Fatalf("a rolled-back SetLink fired %+v", spy.events)
|
||||
}
|
||||
|
||||
var link *models.BackendLink
|
||||
if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error {
|
||||
var err error
|
||||
link, err = links.SetLink(ctx, tx, user.ID, target, setter)
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link.LinkedBy == nil || *link.LinkedBy != setter {
|
||||
t.Fatalf("linked_by = %v, want %d", link.LinkedBy, setter)
|
||||
}
|
||||
if !spy.has("user", user.ID, pact.MCPSubjectLink) || !spy.has("backend", target, pact.MCPSubjectLink) {
|
||||
t.Fatalf("SetLink fired %+v", spy.events)
|
||||
}
|
||||
spy.reset()
|
||||
if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error {
|
||||
return links.RemoveLink(ctx, tx, link)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !spy.has("user", user.ID, pact.MCPSubjectLink) || !spy.has("backend", target, pact.MCPSubjectLink) {
|
||||
t.Fatalf("RemoveLink fired %+v", spy.events)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
// The rolled-back SetLink fired BackendLinkedEvent inside its
|
||||
// transaction too (listeners run with the transaction).
|
||||
if linked != 2 || unlinked != 1 {
|
||||
t.Fatalf("BackendLinkedEvent %d, BackendUnlinkedEvent %d", linked, unlinked)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deactivated or deleted administrator", func(t *testing.T) {
|
||||
token := env.admin(t)
|
||||
admin := adminID(t, token)
|
||||
user := env.seedUser(t, "admin-gone", nil)
|
||||
code := env.startLink(t, token) // the request first: the sign-in must not predate it
|
||||
if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusOK {
|
||||
t.Fatalf("link = %d %s", status, body)
|
||||
}
|
||||
check := func(want bool, why string) {
|
||||
t.Helper()
|
||||
if _, found, err := resolver.LinkedBackendUser(ctx, user.ID); err != nil || found != want {
|
||||
t.Fatalf("%s: found = %v, %v", why, found, err)
|
||||
}
|
||||
}
|
||||
check(true, "active")
|
||||
env.gdb.Exec(`UPDATE backend_users SET is_activated = FALSE WHERE id = ?`, admin)
|
||||
check(false, "deactivated administrator")
|
||||
env.gdb.Exec(`UPDATE backend_users SET is_activated = TRUE, deleted_at = NOW() WHERE id = ?`, admin)
|
||||
check(false, "soft-deleted administrator")
|
||||
env.gdb.Exec(`UPDATE backend_users SET deleted_at = NULL WHERE id = ?`, admin)
|
||||
check(true, "restored administrator")
|
||||
})
|
||||
|
||||
t.Run("deactivating the frontend user removes the link", func(t *testing.T) {
|
||||
token := env.admin(t)
|
||||
admin := adminID(t, token)
|
||||
user := env.seedUser(t, "deactivated", nil)
|
||||
code := env.startLink(t, token) // the request first: the sign-in must not predate it
|
||||
if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusOK {
|
||||
t.Fatalf("link = %d %s", status, body)
|
||||
}
|
||||
spy.reset()
|
||||
if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error {
|
||||
return controllers.FireDeactivatingEvent(ctx, env.app, user, tx)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link, err := links.LinkOf(ctx, user.ID); err != nil || link != nil {
|
||||
t.Fatalf("link after deactivation = %+v, %v", link, err)
|
||||
}
|
||||
if !spy.has("user", user.ID, pact.MCPSubjectDeleted) || !spy.has("backend", admin, pact.MCPSubjectLink) {
|
||||
t.Fatalf("deactivation fired %+v", spy.events)
|
||||
}
|
||||
})
|
||||
}
|
||||
57
classes/access_revoked.go
Normal file
57
classes/access_revoked.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Reasons FireAccessRevoked is called with by this plugin.
|
||||
const (
|
||||
AccessRevokedBan = "ban"
|
||||
AccessRevokedPasswordReset = "password_reset"
|
||||
AccessRevokedPasswordSetByAdmin = "password_set_by_admin"
|
||||
AccessRevokedPasswordChanged = "password_changed"
|
||||
)
|
||||
|
||||
// FireAccessRevoked tells the application that userIDs lost every
|
||||
// credential issued before before (reason names why: a ban, a password reset
|
||||
// or change). It fires *AccessRevokedEvent in tx at once, so a listener
|
||||
// error is returned and the caller's transaction rolls back, and then one
|
||||
// "revoked" *pact.MCPSubjectChanged per user once tx commits, so MCP
|
||||
// sessions of those users end on every instance. A nil app or bus, or an
|
||||
// empty id list, does nothing. A nil tx fires the subject events at once.
|
||||
//
|
||||
// The plugin calls it from the ban action, the password reset, an
|
||||
// administrator setting a password and the self-service password change.
|
||||
// RevokeUserAccess, BanUsers and DeactivateUsers do not fire it: an
|
||||
// application that calls RevokeUserAccess or BanUsers directly calls
|
||||
// FireAccessRevoked too, in the same transaction.
|
||||
func FireAccessRevoked(ctx context.Context, app *backpack.App, tx *gorm.DB, userIDs []uint, before time.Time, reason string) error {
|
||||
if app == nil || app.Events == nil || len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ids := make([]uint, 0, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
if id != 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := app.Events.Fire(ctx, &AccessRevokedEvent{UserIDs: ids, Before: before, Reason: reason, Tx: tx}); err != nil {
|
||||
return fmt.Errorf("%s: %w", AccessRevokedEventName, err)
|
||||
}
|
||||
for _, id := range ids {
|
||||
FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardUser, id, pact.MCPSubjectRevoked)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
176
classes/api_token_scopes.go
Normal file
176
classes/api_token_scopes.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// APITokenScope is one scope a member personal API token (usr_) may carry:
|
||||
// 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.
|
||||
type APITokenScope struct {
|
||||
Name string
|
||||
Label string
|
||||
Description string
|
||||
PluginID string
|
||||
BuiltIn bool
|
||||
}
|
||||
|
||||
// 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.
|
||||
type APITokenScopeCatalog struct {
|
||||
scopes []APITokenScope
|
||||
byName map[string]int
|
||||
}
|
||||
|
||||
// Scopes returns a copy of the catalog, built-ins first.
|
||||
func (c *APITokenScopeCatalog) Scopes() []APITokenScope {
|
||||
if c == nil {
|
||||
return builtInAPITokenScopes()
|
||||
}
|
||||
return slices.Clone(c.scopes)
|
||||
}
|
||||
|
||||
// Allowed reports whether a member token may carry name in this catalog.
|
||||
func (c *APITokenScopeCatalog) Allowed(name string) bool {
|
||||
if c == nil {
|
||||
return slices.Contains(AllowedAPITokenScopes, name)
|
||||
}
|
||||
_, ok := c.byName[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// builtInAPITokenScopeKeys are the label and description phrase keys of
|
||||
// the built-in scopes, in AllowedAPITokenScopes order.
|
||||
var builtInAPITokenScopeKeys = map[string][2]string{
|
||||
"read": {"golem15.user::lang.api_tokens.scope_read", "golem15.user::lang.api_tokens.scope_read_description"},
|
||||
"write": {"golem15.user::lang.api_tokens.scope_write", "golem15.user::lang.api_tokens.scope_write_description"},
|
||||
"ai": {"golem15.user::lang.api_tokens.scope_ai", "golem15.user::lang.api_tokens.scope_ai_description"},
|
||||
}
|
||||
|
||||
// builtInAPITokenScopes are AllowedAPITokenScopes with their phrase keys.
|
||||
func builtInAPITokenScopes() []APITokenScope {
|
||||
out := make([]APITokenScope, 0, len(AllowedAPITokenScopes))
|
||||
for _, name := range AllowedAPITokenScopes {
|
||||
keys := builtInAPITokenScopeKeys[name]
|
||||
out = append(out, APITokenScope{Name: name, Label: keys[0], Description: keys[1], BuiltIn: true})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newAPITokenScopeCatalog(scopes []APITokenScope) *APITokenScopeCatalog {
|
||||
c := &APITokenScopeCatalog{scopes: scopes, byName: make(map[string]int, len(scopes))}
|
||||
for i, scope := range scopes {
|
||||
c.byName[scope.Name] = i
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// 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.
|
||||
func CompileAPITokenScopes(plugins []party.Plugin) (*APITokenScopeCatalog, error) {
|
||||
scopes := builtInAPITokenScopes()
|
||||
owner := map[string]string{}
|
||||
for _, scope := range scopes {
|
||||
owner[scope.Name] = ""
|
||||
}
|
||||
var declared []string
|
||||
for _, plugin := range plugins {
|
||||
if plugin == nil {
|
||||
continue
|
||||
}
|
||||
src, ok := plugin.(pact.HasFrontendTokenScopes)
|
||||
if !ok || src == nil {
|
||||
continue
|
||||
}
|
||||
id := plugin.ID()
|
||||
for _, scope := range src.FrontendTokenScopes() {
|
||||
name := strings.TrimSpace(scope.Name)
|
||||
if name == "" || !pact.ValidTokenScopeName(name) {
|
||||
return nil, fmt.Errorf("golem15.user: plugin %s declared invalid member token scope name %q", id, scope.Name)
|
||||
}
|
||||
if other, taken := owner[name]; taken {
|
||||
if other == "" {
|
||||
return nil, fmt.Errorf("golem15.user: plugin %s declared the built-in member token scope %q", id, name)
|
||||
}
|
||||
return nil, fmt.Errorf("golem15.user: duplicate member token scope %q declared by %s and %s", name, other, id)
|
||||
}
|
||||
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)
|
||||
}
|
||||
owner[name] = id
|
||||
scopes = append(scopes, APITokenScope{Name: name, Label: scope.Label, Description: scope.Description, PluginID: id})
|
||||
declared = append(declared, name)
|
||||
}
|
||||
}
|
||||
declaredScopes.add(declared)
|
||||
return newAPITokenScopeCatalog(scopes), nil
|
||||
}
|
||||
|
||||
// APITokenScopesFor returns the catalog Boot published on app, or the
|
||||
// built-ins alone when none is published.
|
||||
func APITokenScopesFor(app *backpack.App) *APITokenScopeCatalog {
|
||||
if app != nil {
|
||||
if catalog, ok := app.Lookup[*APITokenScopeCatalog](); ok && catalog != nil {
|
||||
return catalog
|
||||
}
|
||||
}
|
||||
return newAPITokenScopeCatalog(builtInAPITokenScopes())
|
||||
}
|
||||
|
||||
// declaredScopes is the process-wide set of every member token scope name a
|
||||
// compiled catalog declared, the legacy MintAPIToken's fallback. Production
|
||||
// runs one application per process, so it equals that application's
|
||||
// declared scopes.
|
||||
var declaredScopes = &scopeSet{names: map[string]bool{}}
|
||||
|
||||
type scopeSet struct {
|
||||
mu sync.RWMutex
|
||||
names map[string]bool
|
||||
}
|
||||
|
||||
func (s *scopeSet) add(names []string) {
|
||||
if len(names) == 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, name := range names {
|
||||
s.names[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scopeSet) has(name string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.names[name]
|
||||
}
|
||||
|
||||
// ResetDeclaredAPITokenScopes clears the process-wide set of declared member
|
||||
// token scopes that the legacy MintAPIToken accepts. It is a test seam for
|
||||
// tests outside this package that compile catalogs; production never calls
|
||||
// it.
|
||||
func ResetDeclaredAPITokenScopes() {
|
||||
declaredScopes.mu.Lock()
|
||||
defer declaredScopes.mu.Unlock()
|
||||
declaredScopes.names = map[string]bool{}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ import (
|
||||
|
||||
const DefaultAPITokenPrefix = "usr_"
|
||||
|
||||
// DefaultAPITokenScopes is what a token minted without scopes carries, and
|
||||
// AllowedAPITokenScopes are the built-in member token scopes. Plugins add
|
||||
// their own through pact.HasFrontendTokenScopes (APITokenScopeCatalog).
|
||||
var (
|
||||
DefaultAPITokenScopes = []string{"read"}
|
||||
AllowedAPITokenScopes = []string{"read", "write", "ai"}
|
||||
@@ -44,8 +47,31 @@ func APITokenPrefix(app *backpack.App) (string, error) {
|
||||
}
|
||||
|
||||
// MintAPIToken builds a token record and returns its one-time clear-text
|
||||
// secret. A nil scopes slice receives DefaultAPITokenScopes.
|
||||
// secret. A nil scopes slice receives DefaultAPITokenScopes. It accepts the
|
||||
// built-in scopes (AllowedAPITokenScopes) and, as a fallback for existing
|
||||
// callers, every member token scope a catalog compiled in this process
|
||||
// declared (CompileAPITokenScopes). New code calls MintAPITokenFor, which
|
||||
// checks the scopes against the application's own catalog.
|
||||
//
|
||||
// Deprecated: the declared-scope fallback is process-wide, so in a process
|
||||
// that boots several applications it accepts another application's scopes.
|
||||
// Use MintAPITokenFor.
|
||||
func MintAPIToken(prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.APIToken, error) {
|
||||
return mintAPIToken(prefix, userID, name, scopes, expiresAt, collectionIDs, func(scope string) bool {
|
||||
return slices.Contains(AllowedAPITokenScopes, scope) || declaredScopes.has(scope)
|
||||
})
|
||||
}
|
||||
|
||||
// MintAPITokenFor is MintAPIToken with the scopes checked against app's
|
||||
// member token scope catalog (APITokenScopesFor): the built-ins and the
|
||||
// scopes app's activated plugins declared through
|
||||
// pact.HasFrontendTokenScopes. A scope another application in the same
|
||||
// process declared is refused.
|
||||
func MintAPITokenFor(app *backpack.App, prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.APIToken, error) {
|
||||
return mintAPIToken(prefix, userID, name, scopes, expiresAt, collectionIDs, APITokenScopesFor(app).Allowed)
|
||||
}
|
||||
|
||||
func mintAPIToken(prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint, allowed func(string) bool) (string, *models.APIToken, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if userID == 0 || name == "" || len(name) > 255 {
|
||||
return "", nil, fmt.Errorf("a user and token name of 1 to 255 characters are required")
|
||||
@@ -59,7 +85,7 @@ func MintAPIToken(prefix string, userID uint, name string, scopes []string, expi
|
||||
scopes = slices.Clone(scopes)
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
if !slices.Contains(AllowedAPITokenScopes, scope) {
|
||||
if !allowed(scope) {
|
||||
return "", nil, fmt.Errorf("scope %q is not allowed", scope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ import (
|
||||
// AvatarURL returns the display picture URL of user at size pixels, the
|
||||
// rule the user API payload follows: the thumbnail (size by size, mode
|
||||
// auto) of the user's avatar attachment when there is one and a file
|
||||
// bucket is published, the attachment's file path when the thumbnail
|
||||
// fails (logged), and otherwise the gravatar URL
|
||||
// bucket is published, the original file's public URL (attach.File.URL,
|
||||
// following storage.uploads.public_path_prefix and public_url) when the
|
||||
// thumbnail fails (logged), and otherwise the gravatar URL
|
||||
// //www.gravatar.com/avatar/{md5 of the email}?s={size}&d=mm. The URL may
|
||||
// be protocol-relative or relative; a caller that needs an absolute URL
|
||||
// resolves it. Only a failing database lookup is an error.
|
||||
@@ -52,7 +53,7 @@ func AvatarURL(ctx context.Context, app *backpack.App, user *models.User, size i
|
||||
url, err := f.Thumb(ctx, bucket, size, size, "auto")
|
||||
if err != nil {
|
||||
pluginLog.Error("golem15.user: avatar thumb", "err", err)
|
||||
return "/" + attach.BlobKey(f.DiskName), nil
|
||||
return f.URL(), nil
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
@@ -52,6 +52,17 @@ type BackendLinks struct {
|
||||
App *backpack.App
|
||||
}
|
||||
|
||||
// BackendLinksFor returns the link service Boot published on app, or one
|
||||
// over app when none is published.
|
||||
func BackendLinksFor(app *backpack.App) *BackendLinks {
|
||||
if app != nil {
|
||||
if links, ok := app.Lookup[*BackendLinks](); ok && links != nil {
|
||||
return links
|
||||
}
|
||||
}
|
||||
return &BackendLinks{App: app}
|
||||
}
|
||||
|
||||
// BackendLinkRequestTTL reads golem15.user.backend_links.request_ttl, a Go
|
||||
// duration (10m by default). A value that does not parse or is not positive
|
||||
// is an error, so a typo fails the boot instead of silently changing the
|
||||
@@ -71,6 +82,27 @@ func BackendLinkRequestTTL(app *backpack.App) (time.Duration, error) {
|
||||
return ttl, nil
|
||||
}
|
||||
|
||||
// DefaultBackendLinkPagePath is the site page the account link points to when
|
||||
// golem15.user.backend_links.page_path is not set.
|
||||
const DefaultBackendLinkPagePath = "/link-admin"
|
||||
|
||||
// BackendLinkPagePath reads golem15.user.backend_links.page_path, the path of
|
||||
// the site page that confirms an account link (default /link-admin). A value
|
||||
// without a leading slash gets one.
|
||||
func BackendLinkPagePath(app *backpack.App) string {
|
||||
if app == nil || app.Config == nil {
|
||||
return DefaultBackendLinkPagePath
|
||||
}
|
||||
path := strings.TrimSpace(app.Config.String("golem15.user.backend_links.page_path"))
|
||||
if path == "" {
|
||||
return DefaultBackendLinkPagePath
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// FrontendURL is the site the plugin sends people to for pages it does not
|
||||
// serve itself (the account link page): golem15.user.frontend_url, falling
|
||||
// back to app.url, without a trailing slash. Empty when neither is set.
|
||||
@@ -190,27 +222,37 @@ func (s *BackendLinks) LinkRequestByCode(ctx context.Context, plain string) (*mo
|
||||
return req, admin, nil
|
||||
}
|
||||
|
||||
// requestUsable answers why a request cannot be used (used or expired), or
|
||||
// nil when req.IsUsableAt(now).
|
||||
func requestUsable(req *models.BackendLinkRequest, now time.Time) error {
|
||||
if req.IsUsableAt(now) {
|
||||
return nil
|
||||
}
|
||||
if req.UsedAt != nil {
|
||||
return ErrLinkRequestUsed
|
||||
}
|
||||
if !req.ExpiresAt.After(now) {
|
||||
return ErrLinkRequestExpired
|
||||
}
|
||||
return nil
|
||||
return ErrLinkRequestExpired
|
||||
}
|
||||
|
||||
// ConfirmLinkRequest stores the link between the signed-in frontend user and
|
||||
// the administrator who created the request, in one transaction: the request
|
||||
// row is locked, must be unused and unexpired, and the session must have
|
||||
// been signed in at or after the request was created (sessionIssuedAt is
|
||||
// the JWT iat, compared in whole seconds because iat has no fraction), so a
|
||||
// browser that was already signed in has to sign in again (D-05). Neither
|
||||
// been signed in at or after the request was created, so a browser that was
|
||||
// already signed in has to sign in again (D-05). sessionAuthTime is the
|
||||
// session token's auth_time claim (bouncer.VerifyAuthTime): the time of the
|
||||
// sign-in itself, which a refresh copies and never renews. It is compared in
|
||||
// whole seconds with the request's created_at because the claim has no
|
||||
// fraction, which leaves a one-second blind spot: a sign-in earlier in the
|
||||
// same second as the request, but before it, counts as fresh. Telling the
|
||||
// two apart would need a sub-second claim; refusing that whole second
|
||||
// instead would make a sign-in in the request's own second sign in once
|
||||
// more. The window is accepted (it needs a sign-in in the very second the
|
||||
// administrator starts the link). A zero time is ErrStaleSession. Neither
|
||||
// account may be linked already (ErrAlreadyLinked) and the administrator
|
||||
// must still be activated (ErrBackendUserUnavailable). The request is used
|
||||
// up with the link; BackendLinkedEvent fires inside the transaction and the
|
||||
// two pact.MCPSubjectChanged events after it commits.
|
||||
func (s *BackendLinks) ConfirmLinkRequest(ctx context.Context, plain string, frontendUserID uint, sessionIssuedAt time.Time) (*models.BackendLink, error) {
|
||||
func (s *BackendLinks) ConfirmLinkRequest(ctx context.Context, plain string, frontendUserID uint, sessionAuthTime time.Time) (*models.BackendLink, error) {
|
||||
if !validLinkCode(plain) {
|
||||
return nil, ErrLinkRequestNotFound
|
||||
}
|
||||
@@ -236,7 +278,8 @@ func (s *BackendLinks) ConfirmLinkRequest(ctx context.Context, plain string, fro
|
||||
if err := requestUsable(req, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if sessionIssuedAt.IsZero() || sessionIssuedAt.Before(req.CreatedAt.Truncate(time.Second)) {
|
||||
// Whole seconds on both sides: see the blind spot above.
|
||||
if sessionAuthTime.IsZero() || sessionAuthTime.Before(req.CreatedAt.Truncate(time.Second)) {
|
||||
return ErrStaleSession
|
||||
}
|
||||
if _, err := activeBackendUser(ctx, tx, req.BackendUserID); err != nil {
|
||||
@@ -289,7 +332,9 @@ func (s *BackendLinks) SetLink(ctx context.Context, tx *gorm.DB, frontendUserID,
|
||||
|
||||
// insertLink writes one link in tx after checking both sides are free; a
|
||||
// concurrent writer that wins the unique index also ends in
|
||||
// ErrAlreadyLinked.
|
||||
// ErrAlreadyLinked. It is the one write path of a link (ConfirmLinkRequest
|
||||
// and SetLink), so BackendLinkedEvent, with Link.UserID set, fires on every
|
||||
// link; RemoveLink is the one removal path and fires BackendUnlinkedEvent.
|
||||
func (s *BackendLinks) insertLink(ctx context.Context, tx *gorm.DB, frontendUserID, backendUserID uint, linkedBy *uint) (*models.BackendLink, error) {
|
||||
var taken int64
|
||||
if err := fresh(ctx, tx).Model(&models.BackendLink{}).
|
||||
@@ -319,7 +364,8 @@ func (s *BackendLinks) insertLink(ctx context.Context, tx *gorm.DB, frontendUser
|
||||
return link, nil
|
||||
}
|
||||
|
||||
// RemoveLink deletes link in tx, fires BackendUnlinkedEvent there and the
|
||||
// RemoveLink deletes link in tx, fires BackendUnlinkedEvent (Link.UserID
|
||||
// set) there and the
|
||||
// two pact.MCPSubjectChanged link events after tx commits. A link that is
|
||||
// already gone is not an error and fires nothing.
|
||||
func (s *BackendLinks) RemoveLink(ctx context.Context, tx *gorm.DB, link *models.BackendLink) error {
|
||||
@@ -378,7 +424,8 @@ func (s *BackendLinks) linkWhere(ctx context.Context, where string, id uint) (*m
|
||||
// LinkedBackendUser implements pact.MCPLinkedBackendResolver with one
|
||||
// indexed query per call (no cache): the administrator linked to the
|
||||
// frontend user, false when there is no link, when the frontend user is
|
||||
// deactivated, or when the administrator is deactivated or deleted.
|
||||
// deactivated or banned (user_throttle.is_banned on any of their rows), or
|
||||
// when the administrator is deactivated or deleted.
|
||||
func (s *BackendLinks) LinkedBackendUser(ctx context.Context, frontendUserID uint) (pact.LinkedBackendUser, bool, error) {
|
||||
if frontendUserID == 0 {
|
||||
return pact.LinkedBackendUser{}, false, nil
|
||||
@@ -398,6 +445,7 @@ FROM golem15_user_backend_links l
|
||||
JOIN backend_users b ON b.id = l.backend_user_id
|
||||
JOIN users u ON u.id = l.user_id
|
||||
WHERE l.user_id = ? AND b.deleted_at IS NULL AND b.is_activated = TRUE AND u.deleted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM user_throttle t WHERE t.user_id = l.user_id AND t.is_banned)
|
||||
LIMIT 1`, frontendUserID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return pact.LinkedBackendUser{}, false, err
|
||||
|
||||
144
classes/device_auth.go
Normal file
144
classes/device_auth.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DeviceShortCodeAlphabet is the alphabet of device short codes (PHP's
|
||||
// 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}"
|
||||
|
||||
// defaultDeviceAuthTTL is golem15.user.device_auth.ttl_minutes when unset.
|
||||
const defaultDeviceAuthTTL = 5 * time.Minute
|
||||
|
||||
// NewDeviceToken returns a fresh device token: 48 bytes from crypto/rand as
|
||||
// unpadded base64url, 64 characters. Store only its HashAPIToken.
|
||||
func NewDeviceToken() (string, error) {
|
||||
var b [48]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
// NewDeviceShortCode returns a fresh short code XXXX-XXXX, eight characters
|
||||
// drawn uniformly from DeviceShortCodeAlphabet with crypto/rand (rejection
|
||||
// sampling, no modulo bias).
|
||||
func NewDeviceShortCode() (string, error) {
|
||||
n := len(DeviceShortCodeAlphabet)
|
||||
limit := 256 - 256%n // largest multiple of n that fits a byte
|
||||
out := make([]byte, 0, 9)
|
||||
var buf [16]byte
|
||||
for len(out) < 9 {
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, c := range buf {
|
||||
if int(c) >= limit {
|
||||
continue
|
||||
}
|
||||
if len(out) == 4 {
|
||||
out = append(out, '-')
|
||||
}
|
||||
out = append(out, DeviceShortCodeAlphabet[int(c)%n])
|
||||
if len(out) == 9 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// NormalizeDeviceShortCode turns what a user typed into the stored form
|
||||
// XXXX-XXXX: upper case, spaces and hyphens removed, exactly eight
|
||||
// characters of DeviceShortCodeAlphabet. It reports false otherwise.
|
||||
func NormalizeDeviceShortCode(s string) (string, bool) {
|
||||
s = strings.ToUpper(s)
|
||||
s = strings.NewReplacer(" ", "", "-", "").Replace(s)
|
||||
if len(s) != 8 {
|
||||
return "", false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
if strings.IndexByte(DeviceShortCodeAlphabet, s[i]) < 0 {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return s[:4] + "-" + s[4:], true
|
||||
}
|
||||
|
||||
// DeviceAuthTTL is how long a device authorization session can be
|
||||
// authorized and handed over: golem15.user.device_auth.ttl_minutes, default
|
||||
// 5.
|
||||
func DeviceAuthTTL(app *backpack.App) time.Duration {
|
||||
if app != nil && app.Config != nil {
|
||||
if mins := app.Config.Int("golem15.user.device_auth.ttl_minutes"); mins > 0 {
|
||||
return time.Duration(mins) * time.Minute
|
||||
}
|
||||
}
|
||||
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.
|
||||
func DeviceAuthURL(app *backpack.App, token string) string {
|
||||
tpl := ""
|
||||
if app != nil && app.Config != nil {
|
||||
tpl = strings.TrimSpace(app.Config.String("golem15.user.device_auth.auth_url"))
|
||||
}
|
||||
if tpl == "" {
|
||||
tpl = defaultDeviceAuthURL
|
||||
}
|
||||
return strings.NewReplacer("{frontend_url}", FrontendURL(app), "{token}", token).Replace(tpl)
|
||||
}
|
||||
|
||||
// DeviceDisplayName is the name a device list shows (PHP's device_name
|
||||
// accessor): the stored name, else a name parsed from the user agent
|
||||
// (iPhone, iPad, Android Device, Windows PC, Mac, Linux PC), else Unknown
|
||||
// Device.
|
||||
func DeviceDisplayName(stored *string, userAgent string) string {
|
||||
if stored != nil && *stored != "" {
|
||||
return *stored
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(userAgent, "iPhone"):
|
||||
return "iPhone"
|
||||
case strings.Contains(userAgent, "iPad"):
|
||||
return "iPad"
|
||||
case strings.Contains(userAgent, "Android"):
|
||||
return "Android Device"
|
||||
case strings.Contains(userAgent, "Windows"):
|
||||
return "Windows PC"
|
||||
case strings.Contains(userAgent, "Macintosh"):
|
||||
return "Mac"
|
||||
case strings.Contains(userAgent, "Linux"):
|
||||
return "Linux PC"
|
||||
}
|
||||
return "Unknown Device"
|
||||
}
|
||||
|
||||
// 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;
|
||||
// empty values do nothing.
|
||||
func HandOverDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) error {
|
||||
if db == nil || oldJTI == "" || newJTI == "" || oldJTI == newJTI {
|
||||
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
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -20,6 +22,13 @@ const BackendLinkedEventName = "user.BackendLinked"
|
||||
// BackendUnlinkedEventName is the festival name of BackendUnlinkedEvent.
|
||||
const BackendUnlinkedEventName = "user.BackendUnlinked"
|
||||
|
||||
// AccessRevokedEventName is the festival name of AccessRevokedEvent.
|
||||
const AccessRevokedEventName = "user.AccessRevoked"
|
||||
|
||||
// LoginEventName is the festival name of LoginEvent, the PHP plugin's
|
||||
// golem15.user.login.
|
||||
const LoginEventName = "golem15.user.login"
|
||||
|
||||
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
|
||||
type GetApiArrayEvent struct {
|
||||
User *models.User
|
||||
@@ -90,3 +99,48 @@ type BackendUnlinkedEvent struct {
|
||||
Link *models.BackendLink
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
// AccessRevokedEvent fires inside the write transaction when users lose
|
||||
// every credential issued before Before: a ban, a password reset, a password
|
||||
// an administrator set, or the user's own password change (FireAccessRevoked;
|
||||
// Reason names which). Listeners revoke what such a credential still holds
|
||||
// elsewhere, for example OAuth access and refresh tokens created before
|
||||
// Before. Before is the untruncated instant of the revocation, while the
|
||||
// users' tokens_valid_after keeps its whole-second value. A listener error
|
||||
// rolls the write back. Deactivation and deletion keep their own events
|
||||
// (DeactivatingEvent, DeletingEvent).
|
||||
type AccessRevokedEvent struct {
|
||||
UserIDs []uint
|
||||
Before time.Time
|
||||
Reason string
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
// PasswordChanged reports whether the event comes from a new password the
|
||||
// user or an administrator chose: a password reset, the user's own change or
|
||||
// an administrator setting one (Reason AccessRevokedPasswordReset,
|
||||
// AccessRevokedPasswordChanged or AccessRevokedPasswordSetByAdmin). The
|
||||
// bcrypt rehash on login and a new account fire no AccessRevokedEvent. A
|
||||
// two-factor plugin listens for these events to revoke the users' trusted
|
||||
// devices inside the same transaction.
|
||||
func (e *AccessRevokedEvent) PasswordChanged() bool {
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
switch e.Reason {
|
||||
case AccessRevokedPasswordReset, AccessRevokedPasswordChanged, AccessRevokedPasswordSetByAdmin:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LoginEvent fires after a sign-in issued a session token: a password login,
|
||||
// a sign-in completed by a second factor, a passkey sign-in or a device
|
||||
// hand-over (Source is one of the LoginSource constants). It does not fire
|
||||
// on refresh, on /register or for a sign-in a login gate held back. It fires
|
||||
// after the token was minted and outside any transaction; a listener error
|
||||
// is logged and never fails the sign-in (PHP's golem15.user.login).
|
||||
type LoginEvent struct {
|
||||
User *models.User
|
||||
Source string
|
||||
}
|
||||
|
||||
124
classes/login_gate.go
Normal file
124
classes/login_gate.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
)
|
||||
|
||||
// Login sources name the first factor a LoginAttempt or a LoginEvent comes
|
||||
// from.
|
||||
const (
|
||||
// LoginSourcePassword is POST /_user/api/v1/login (email and password).
|
||||
LoginSourcePassword = "password"
|
||||
// LoginSourceSocial is a social sign-in completed by a social-login
|
||||
// plugin.
|
||||
LoginSourceSocial = "social"
|
||||
// LoginSourcePasskey is a passwordless passkey sign-in.
|
||||
LoginSourcePasskey = "passkey"
|
||||
// LoginSourceTwoFactor is a sign-in completed by a second factor after a
|
||||
// gate challenged it.
|
||||
LoginSourceTwoFactor = "two_factor"
|
||||
// LoginSourceDevice is the device authorization hand-over (the first poll
|
||||
// of devices/status after a signed-in user authorized the device).
|
||||
LoginSourceDevice = "device"
|
||||
)
|
||||
|
||||
// LoginAttempt is a first-factor sign-in that already succeeded: the
|
||||
// password (or the social identity) checked out, the throttle was reset and
|
||||
// the account may sign in. A LoginGate decides whether a token is issued
|
||||
// now.
|
||||
type LoginAttempt struct {
|
||||
// User is the account signing in. A deactivated account still has
|
||||
// DeletedAt set: Login restores it only after every gate allowed the
|
||||
// sign-in.
|
||||
User *models.User
|
||||
// Source is one of the LoginSource constants.
|
||||
Source string
|
||||
// Fields is the login body Login already read (a gate must not read the
|
||||
// request body again, it is consumed). It carries keys a gate needs,
|
||||
// such as trusted_device_token. It is nil on a social sign-in. Treat it
|
||||
// as read-only, untrusted input; it holds the password on a password
|
||||
// login.
|
||||
Fields map[string]any
|
||||
// Request is the sign-in request, for the client address and the user
|
||||
// agent.
|
||||
Request *http.Request
|
||||
}
|
||||
|
||||
// LoginGateResult replaces the token response of a sign-in a gate held
|
||||
// back.
|
||||
type LoginGateResult struct {
|
||||
// Status is the HTTP status of the answer; 0 means 200. A challenge
|
||||
// answers 200, a refusal (for example a second-factor failure cap)
|
||||
// answers its own status.
|
||||
Status int
|
||||
// Body is the JSON body of the answer, for example
|
||||
// {two_factor_required, challenge_token, available_methods, expires_at}.
|
||||
Body map[string]any
|
||||
}
|
||||
|
||||
// LoginGate inspects a first-factor sign-in that already succeeded, before
|
||||
// any token exists. A two-factor plugin implements one to challenge users
|
||||
// who enabled a second factor.
|
||||
type LoginGate interface {
|
||||
// CheckLogin returns nil, nil to allow the sign-in. A non-nil result
|
||||
// replaces the token response with its Status and Body, and no token is
|
||||
// issued. An error must fail the sign-in closed: the caller answers the
|
||||
// opaque 500 and issues no token. Error strings must not carry a token,
|
||||
// a code or a password.
|
||||
CheckLogin(ctx context.Context, a LoginAttempt) (*LoginGateResult, error)
|
||||
}
|
||||
|
||||
// LoginGates is the registry of login gates. The user plugin publishes one
|
||||
// in its Register (app.Publish(classes.NewLoginGates())), and another plugin
|
||||
// looks it up with app.Lookup[*classes.LoginGates]() and adds its gate in
|
||||
// its Boot: every Register runs before every Boot. Password Login consults
|
||||
// it after the password, activation and throttle checks and before it
|
||||
// restores a deactivated account and mints the token; JWT refresh never
|
||||
// consults it. It is safe for concurrent use.
|
||||
type LoginGates struct {
|
||||
mu sync.RWMutex
|
||||
gates []LoginGate
|
||||
}
|
||||
|
||||
// NewLoginGates returns an empty registry.
|
||||
func NewLoginGates() *LoginGates {
|
||||
return &LoginGates{}
|
||||
}
|
||||
|
||||
// Add appends gate; gates run in the order they were added. A nil gate is
|
||||
// ignored.
|
||||
func (g *LoginGates) Add(gate LoginGate) {
|
||||
if g == nil || gate == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.gates = append(g.gates, gate)
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
// Check runs the gates in order and returns the first non-nil result or the
|
||||
// first error; gates after it are not called. A nil registry and an empty
|
||||
// one return nil, nil, which allows the sign-in.
|
||||
func (g *LoginGates) Check(ctx context.Context, a LoginAttempt) (*LoginGateResult, error) {
|
||||
if g == nil {
|
||||
return nil, nil
|
||||
}
|
||||
g.mu.RLock()
|
||||
gates := slices.Clone(g.gates)
|
||||
g.mu.RUnlock()
|
||||
for _, gate := range gates {
|
||||
res, err := gate.CheckLogin(ctx, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -139,7 +140,9 @@ func (g *MCPUserGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject,
|
||||
// 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
|
||||
// whose user is gone answers pact.ErrMCPTokenInvalid. A usable token gets
|
||||
// its last_used_at stamped, as the api_token guard does.
|
||||
// its last_used_at stamped, and its last_used_ip when the context carries
|
||||
// the client address (towel.ClientIP, which the MCP endpoint sets), as the
|
||||
// api_token guard does.
|
||||
func (g *MCPUserGuard) MCPResolvePersonalToken(ctx context.Context, raw string) (pact.MCPSubject, bool, error) {
|
||||
prefix, err := APITokenPrefix(g.app())
|
||||
if err != nil || !strings.HasPrefix(raw, prefix) {
|
||||
@@ -166,7 +169,11 @@ func (g *MCPUserGuard) MCPResolvePersonalToken(ctx context.Context, raw string)
|
||||
if principal == nil {
|
||||
return pact.MCPSubject{}, true, pact.ErrMCPTokenInvalid
|
||||
}
|
||||
if err := db.Model(&models.APIToken{}).Where("id = ?", token.ID).UpdateColumn("last_used_at", now).Error; err != nil {
|
||||
used := map[string]any{"last_used_at": now}
|
||||
if ip, ok := towel.ClientIP(ctx); ok && strings.TrimSpace(ip) != "" {
|
||||
used["last_used_ip"] = strings.TrimSpace(ip)
|
||||
}
|
||||
if err := db.Model(&models.APIToken{}).Where("id = ?", token.ID).UpdateColumns(used).Error; err != nil {
|
||||
return pact.MCPSubject{}, true, err
|
||||
}
|
||||
return pact.MCPSubject{Guard: SubjectGuardUser, UserID: token.UserID, Scopes: slices.Clone(token.Scopes.Get()), TokenID: token.ID}, true, nil
|
||||
|
||||
91
classes/organisation_logo.go
Normal file
91
classes/organisation_logo.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OrganisationLogoSize is the edge in pixels of the logo thumbnail
|
||||
// OrganisationLogoFields answers.
|
||||
const OrganisationLogoSize = 256
|
||||
|
||||
// organisationLogo loads the organisation's logo attachment, or nil.
|
||||
func organisationLogo(ctx context.Context, app *backpack.App, org *models.Organisation) (*attach.File, error) {
|
||||
if app == nil || org == nil || org.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
gdb, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || gdb == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var f attach.File
|
||||
err := gdb.WithContext(ctx).
|
||||
Where("attachment_type = ? AND attachment_id = ? AND field = ?", org.MorphName(), strconv.FormatUint(uint64(org.ID), 10), models.OrganisationLogoField).
|
||||
Take(&f).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// OrganisationLogoURL returns the URL of the organisation's logo at size
|
||||
// pixels, the way AvatarURL serves a user's picture: the thumbnail (size by
|
||||
// size, mode auto) of the logo attachment, or the original file's public
|
||||
// URL (attach.File.URL, which follows storage.uploads.public_path_prefix and
|
||||
// public_url) when the thumbnail fails (logged). It answers false when the organisation
|
||||
// has no logo or no file bucket is published. The URL may be relative; a
|
||||
// caller that needs an absolute URL resolves it. Only a failing database
|
||||
// lookup is an error.
|
||||
func OrganisationLogoURL(ctx context.Context, app *backpack.App, org *models.Organisation, size int) (string, bool, error) {
|
||||
f, err := organisationLogo(ctx, app, org)
|
||||
if err != nil || f == nil {
|
||||
return "", false, err
|
||||
}
|
||||
bucket, ok := app.Lookup[*blob.Bucket]()
|
||||
if !ok || bucket == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
url, err := f.Thumb(ctx, bucket, size, size, "auto")
|
||||
if err != nil {
|
||||
pluginLog.Error("golem15.user: organisation logo thumb", "err", err)
|
||||
return f.URL(), true, nil
|
||||
}
|
||||
return url, true, nil
|
||||
}
|
||||
|
||||
// OrganisationLogoFields answers the logo keys an application adds to its
|
||||
// own organisation payloads, named like the user payload's avatar keys:
|
||||
// has_logo (bool), logo (the attachment's disk name, or nil) and logo_url
|
||||
// (the OrganisationLogoSize thumbnail URL, or nil). An organisation without
|
||||
// a logo answers false, nil and nil. Only a failing database lookup is an
|
||||
// error.
|
||||
func OrganisationLogoFields(ctx context.Context, app *backpack.App, org *models.Organisation) (map[string]any, error) {
|
||||
out := map[string]any{"has_logo": false, "logo": nil, "logo_url": nil}
|
||||
f, err := organisationLogo(ctx, app, org)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f == nil {
|
||||
return out, nil
|
||||
}
|
||||
out["has_logo"] = true
|
||||
out["logo"] = f.DiskName
|
||||
url, ok, err := OrganisationLogoURL(ctx, app, org, OrganisationLogoSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok && url != "" {
|
||||
out["logo_url"] = url
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -17,39 +17,53 @@ const (
|
||||
SubjectGuardBackend = "backend"
|
||||
)
|
||||
|
||||
// FireSubjectChanged fires one *pact.MCPSubjectChanged (guard, user id,
|
||||
// kind) through the application's event bus at once, so the MCP endpoint
|
||||
// re-lists the subject's tools or ends its sessions. A missing application
|
||||
// or bus, or a zero id, is a no-op; a listener's error is logged and never
|
||||
// returned, so the write that caused the event never fails because of it
|
||||
// (every MCP request re-checks anyway).
|
||||
func FireSubjectChanged(ctx context.Context, app *backpack.App, guard string, userID uint, kind string) {
|
||||
if app == nil || app.Events == nil || userID == 0 || kind == "" {
|
||||
// FireSubjectEvent fires ev through the application's event bus at once, so
|
||||
// the MCP endpoint re-lists the subject's tools or ends its sessions. A
|
||||
// revoked event may carry TokenID (only the sessions one personal token
|
||||
// opened end) or ClientID with UserID (only that OAuth client's sessions
|
||||
// end). A missing application or bus, a zero UserID or an empty Kind is a
|
||||
// no-op; a listener's error is logged and never returned, so the write that
|
||||
// caused the event never fails because of it (every MCP request re-checks
|
||||
// anyway).
|
||||
func FireSubjectEvent(ctx context.Context, app *backpack.App, ev pact.MCPSubjectChanged) {
|
||||
if app == nil || app.Events == nil || ev.UserID == 0 || ev.Kind == "" {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
event := &pact.MCPSubjectChanged{Guard: guard, UserID: userID, Kind: kind}
|
||||
if err := app.Events.Fire(ctx, event); err != nil {
|
||||
pluginLog.WarnContext(ctx, "golem15.user: subject changed listener failed", "guard", guard, "user_id", userID, "kind", kind, "err", err)
|
||||
event := ev
|
||||
if err := app.Events.Fire(ctx, &event); err != nil {
|
||||
pluginLog.WarnContext(ctx, "golem15.user: subject changed listener failed", "guard", ev.Guard, "user_id", ev.UserID, "kind", ev.Kind, "token_id", ev.TokenID, "client_id", ev.ClientID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// FireSubjectChangedAfterCommit is FireSubjectChanged once the write
|
||||
// FireSubjectEventAfterCommit is FireSubjectEvent once the write
|
||||
// transaction tx commits (lagoon.AfterCommit), so a listener that reads the
|
||||
// database sees the change; nothing is fired when the transaction rolls
|
||||
// back. A nil tx fires at once. ctx must be the context the transaction
|
||||
// runs under (the one cabana hands a hook or an action).
|
||||
func FireSubjectChangedAfterCommit(ctx context.Context, app *backpack.App, tx *gorm.DB, guard string, userID uint, kind string) {
|
||||
if app == nil || app.Events == nil || userID == 0 || kind == "" {
|
||||
func FireSubjectEventAfterCommit(ctx context.Context, app *backpack.App, tx *gorm.DB, ev pact.MCPSubjectChanged) {
|
||||
if app == nil || app.Events == nil || ev.UserID == 0 || ev.Kind == "" {
|
||||
return
|
||||
}
|
||||
if tx == nil {
|
||||
FireSubjectChanged(ctx, app, guard, userID, kind)
|
||||
FireSubjectEvent(ctx, app, ev)
|
||||
return
|
||||
}
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
FireSubjectChanged(ctx, app, guard, userID, kind)
|
||||
FireSubjectEvent(ctx, app, ev)
|
||||
})
|
||||
}
|
||||
|
||||
// FireSubjectChanged fires one *pact.MCPSubjectChanged (guard, user id,
|
||||
// kind) at once: FireSubjectEvent for the whole subject.
|
||||
func FireSubjectChanged(ctx context.Context, app *backpack.App, guard string, userID uint, kind string) {
|
||||
FireSubjectEvent(ctx, app, pact.MCPSubjectChanged{Guard: guard, UserID: userID, Kind: kind})
|
||||
}
|
||||
|
||||
// FireSubjectChangedAfterCommit is FireSubjectChanged once the write
|
||||
// transaction tx commits: FireSubjectEventAfterCommit for the whole subject.
|
||||
func FireSubjectChangedAfterCommit(ctx context.Context, app *backpack.App, tx *gorm.DB, guard string, userID uint, kind string) {
|
||||
FireSubjectEventAfterCommit(ctx, app, tx, pact.MCPSubjectChanged{Guard: guard, UserID: userID, Kind: kind})
|
||||
}
|
||||
|
||||
38
classes/two_factor_admin.go
Normal file
38
classes/two_factor_admin.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PermissionResetTwoFactor is the backend permission an administrator needs
|
||||
// to run the Users screen's "Reset 2FA" record action (reset_two_factor). No
|
||||
// role has it by default: only a superuser, or a role granted it
|
||||
// explicitly, may remove a user's second factors.
|
||||
const PermissionResetTwoFactor = "golem15.users.reset_two_factor"
|
||||
|
||||
// TwoFactorAdmin is what a two-factor plugin publishes so the Users screen
|
||||
// can offer its "Reset 2FA" record action. The screen looks it up on every
|
||||
// request: without a published service the action is never offered, so an
|
||||
// application that mounts no two-factor plugin never shows it.
|
||||
//
|
||||
// Publish it with an explicit type argument,
|
||||
//
|
||||
// app.Publish[classes.TwoFactorAdmin](svc)
|
||||
//
|
||||
// because a bare app.Publish(svc) keys the service on its concrete type and
|
||||
// the lookup misses it.
|
||||
type TwoFactorAdmin interface {
|
||||
// HasTwoFactor reports whether the user has any second factor (an
|
||||
// enabled method, a WebAuthn credential, recovery codes or a trusted
|
||||
// device) to reset. It only reads, through tx.
|
||||
HasTwoFactor(ctx context.Context, tx *gorm.DB, userID uint) (bool, error)
|
||||
// Reset disables every second-factor method of user, deletes the
|
||||
// WebAuthn credentials and recovery codes, revokes the trusted devices
|
||||
// and fires the plugin's disabled event, all on tx (the record action's
|
||||
// transaction): an error rolls the reset back. The user then signs in
|
||||
// with the password alone until they set up a second factor again.
|
||||
Reset(ctx context.Context, tx *gorm.DB, user *models.User) error
|
||||
}
|
||||
@@ -38,10 +38,22 @@ privileged_groups:
|
||||
# refuses them. The turnstile:* middleware comes from the framework (summercms
|
||||
# v0.3.0 and later); its secret is turnstile.secret (SUMMER_TURNSTILE__SECRET).
|
||||
turnstile: ""
|
||||
# The site this plugin's links point to (the account link page
|
||||
# /link-admin?code=...). Empty falls back to app.url.
|
||||
# The site this plugin's links point to (the account link page,
|
||||
# <frontend_url><backend_links.page_path>#code=..., and the device
|
||||
# authorization page of device_auth.auth_url). Empty falls back to app.url.
|
||||
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}"
|
||||
# Minutes a device session can be authorized and handed over.
|
||||
ttl_minutes: 5
|
||||
backend_links:
|
||||
# How long a link request an administrator starts on My account stays
|
||||
# usable (a Go duration). The code is one-shot.
|
||||
request_ttl: 10m
|
||||
# The site page that confirms a link. My account sends the administrator
|
||||
# to <frontend_url><page_path>#code=<code>; the code is in the fragment,
|
||||
# which browsers never send to a server.
|
||||
page_path: /link-admin
|
||||
|
||||
@@ -79,7 +79,7 @@ Revoke a token with user:token:revoke and list them with user:token:list.`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secret, token, err := classes.MintAPIToken(prefix, user.ID, name, scopes, expiresAt, nil)
|
||||
secret, token, err := classes.MintAPITokenFor(app, prefix, user.ID, name, scopes, expiresAt, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user:token:create: %w", err)
|
||||
}
|
||||
|
||||
@@ -47,8 +47,11 @@ const invalidLoginBody = "Invalid email or password"
|
||||
//
|
||||
// It checks in this order: a ban or an active suspension
|
||||
// (classes.RejectIfThrottled), the password (bcrypt), the activation, then
|
||||
// restores a deactivated (deleted_at) account and sends it the reactivate
|
||||
// mail, records the successful attempt and mints the token. With
|
||||
// records the successful attempt, consults the login gates
|
||||
// (CheckLoginGates: a gate answer replaces the token response, a gate error
|
||||
// answers the opaque 500), restores a deactivated (deleted_at) account and
|
||||
// sends it the reactivate mail, mints the token and fires
|
||||
// classes.LoginEvent. With
|
||||
// golem15.user.activation.require_activation on (the default) the correct
|
||||
// password of a not-activated account gets the same 401 invalid-login answer
|
||||
// as a wrong password and counts as a failed attempt for the throttle; an
|
||||
@@ -106,13 +109,6 @@ func Login(app *backpack.App) http.HandlerFunc {
|
||||
refuseLogin(ctx, w, r, app, gdb, user, ip)
|
||||
return
|
||||
}
|
||||
if user.DeletedAt.Valid {
|
||||
if err := gdb.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
||||
}
|
||||
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, true); err != nil {
|
||||
if classes.IsLoginRejected(err) {
|
||||
writeInvalidLogin(w, r, app)
|
||||
@@ -121,15 +117,51 @@ func Login(app *backpack.App) http.HandlerFunc {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
// The rehash runs before the login gates, so a user who is always
|
||||
// challenged still gets the stronger hash. It is not a password
|
||||
// change: it fires no AccessRevokedEvent (D-07). Unscoped, because a
|
||||
// deactivated account is restored only after the gates.
|
||||
cost := 10
|
||||
if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 {
|
||||
cost = app.Config.Int("golem15.user.password.bcrypt_cost")
|
||||
}
|
||||
if bouncer.NeedsRehash(user.Password, cost) {
|
||||
if next, err := bouncer.HashPassword(cost, password); err == nil {
|
||||
_ = gdb.WithContext(ctx).Model(user).Update("password", next).Error
|
||||
_ = gdb.WithContext(ctx).Unscoped().Model(user).Update("password", next).Error
|
||||
}
|
||||
}
|
||||
// Login gates (D-02) run after the password, activation and throttle
|
||||
// checks and before anything that signs the user in. A gate answer
|
||||
// replaces the token response; a gate error fails closed. The
|
||||
// restore of a deactivated account and its reactivate mail come
|
||||
// after the gates (D-18 A10), so the password alone cannot reactivate
|
||||
// an account that a second factor protects.
|
||||
gate, err := CheckLoginGates(ctx, app, classes.LoginAttempt{
|
||||
User: user,
|
||||
Source: classes.LoginSourcePassword,
|
||||
Fields: fields,
|
||||
Request: r,
|
||||
})
|
||||
if err != nil {
|
||||
pluginLog.Error("golem15.user: login gate", "user_id", user.ID, "err", err)
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if gate != nil {
|
||||
status := gate.Status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
writeJSON(w, status, gate.Body)
|
||||
return
|
||||
}
|
||||
if user.DeletedAt.Valid {
|
||||
if err := gdb.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
||||
}
|
||||
// A failed last_seen write is logged and never fails the login.
|
||||
if err := classes.TouchLastSeen(ctx, gdb, user.ID); err != nil {
|
||||
pluginLog.Warn("golem15.user: stamp last_seen on login", "user_id", user.ID, "err", err)
|
||||
@@ -144,6 +176,9 @@ func Login(app *backpack.App) http.HandlerFunc {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if err := FireLoginEvent(ctx, app, user, classes.LoginSourcePassword); err != nil {
|
||||
pluginLog.Warn("golem15.user: login event listener", "user_id", user.ID, "err", err)
|
||||
}
|
||||
writeSession(w, app, cc, token, map[string]any{"user": payload})
|
||||
}
|
||||
}
|
||||
@@ -269,10 +304,36 @@ func Refresh(app *backpack.App) http.HandlerFunc {
|
||||
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{})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
claims := jwt.MapClaims{}
|
||||
if _, _, err := jwt.NewParser().ParseUnverified(raw, claims); err != nil {
|
||||
return ""
|
||||
}
|
||||
jti, _ := claims["jti"].(string)
|
||||
return jti
|
||||
}
|
||||
|
||||
// refreshSubjectRefused reads the subject and iat of a token bouncer.Refresh
|
||||
// has already verified and reports whether its user may not slide the
|
||||
// session: not found by a scoped load (missing or deactivated), issued
|
||||
@@ -447,13 +508,21 @@ func ResetPassword(app *backpack.App) http.HandlerFunc {
|
||||
// address (D-27). A reset that fails here records nothing. The
|
||||
// session cutoff is truncated to whole seconds, as in
|
||||
// RevokeUserAccess, because JWT iat has whole-second precision, so
|
||||
// the login that follows the reset is not refused.
|
||||
// the login that follows the reset is not refused. The same
|
||||
// transaction fires the access-revoked event with the untruncated
|
||||
// instant, so OAuth grants created before the reset are revoked
|
||||
// with the password change or not at all.
|
||||
now := time.Now()
|
||||
if err := gdb.WithContext(r.Context()).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{
|
||||
"password": hashed,
|
||||
"tokens_valid_after": now.Truncate(time.Second),
|
||||
"email_verified_at": now,
|
||||
}).Error; err != nil {
|
||||
if err := lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{
|
||||
"password": hashed,
|
||||
"tokens_valid_after": now.Truncate(time.Second),
|
||||
"email_verified_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return classes.FireAccessRevoked(ctx, app, tx, []uint{user.ID}, now, classes.AccessRevokedPasswordReset)
|
||||
}); err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
@@ -707,13 +776,24 @@ func ChangePassword(app *backpack.App) http.HandlerFunc {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
// The cutoff keeps the current session (issued at iat) and every
|
||||
// session token issued since that second. OAuth grants are revoked
|
||||
// as of now, whatever their age: the request needs none, and a
|
||||
// grant its client refreshed after iat would otherwise survive the
|
||||
// change (its rotated tokens are newer than the session).
|
||||
cutoff := iat.Add(-time.Second)
|
||||
if err := gdb.WithContext(r.Context()).Model(&user).Updates(map[string]any{
|
||||
"password": hashed,
|
||||
"must_change_password": false,
|
||||
"has_self_set_password": true,
|
||||
"tokens_valid_after": cutoff,
|
||||
}).Error; err != nil {
|
||||
revokedAt := time.Now()
|
||||
if err := lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Model(&user).Updates(map[string]any{
|
||||
"password": hashed,
|
||||
"must_change_password": false,
|
||||
"has_self_set_password": true,
|
||||
"tokens_valid_after": cutoff,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return classes.FireAccessRevoked(ctx, app, tx, []uint{user.ID}, revokedAt, classes.AccessRevokedPasswordChanged)
|
||||
}); err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
@@ -1017,12 +1097,18 @@ func apiArray(ctx context.Context, app *backpack.App, user *models.User) (map[st
|
||||
}
|
||||
|
||||
func mintFor(app *backpack.App, secret string, user *models.User, issuer string) (string, error) {
|
||||
token, _, err := bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, sessionTTL(app))
|
||||
return token, err
|
||||
}
|
||||
|
||||
// sessionTTL is the lifetime of a session JWT: golem15.user.jwt.ttl
|
||||
// minutes, default 60.
|
||||
func sessionTTL(app *backpack.App) time.Duration {
|
||||
ttl := 60 * time.Minute
|
||||
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.ttl") > 0 {
|
||||
ttl = time.Duration(app.Config.Int("golem15.user.jwt.ttl")) * time.Minute
|
||||
}
|
||||
token, _, err := bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, ttl)
|
||||
return token, err
|
||||
return ttl
|
||||
}
|
||||
|
||||
// RefreshTTL is the refresh window of a session token:
|
||||
@@ -1146,6 +1232,26 @@ func activationLink(app *backpack.App, id uint, code string) string {
|
||||
return base + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// readBodyFields is readFields without the query string: a JSON body, or a
|
||||
// form-encoded body read from r.PostForm. The routes that carry a secret in
|
||||
// a field use it, so the secret is never taken from a URL that access logs
|
||||
// record.
|
||||
func readBodyFields(r *http.Request) (map[string]any, error) {
|
||||
if strings.Contains(r.Header.Get("Content-Type"), "application/json") {
|
||||
return readFields(r)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields := map[string]any{}
|
||||
for k, v := range r.PostForm {
|
||||
if len(v) > 0 {
|
||||
fields[k] = v[0]
|
||||
}
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
func readFields(r *http.Request) (map[string]any, error) {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.Contains(ct, "application/json") {
|
||||
|
||||
@@ -3,7 +3,6 @@ package controllers
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -35,7 +34,8 @@ func APITokenStore(app *backpack.App) http.HandlerFunc {
|
||||
writeValidation(w, map[string][]string{"name": {"The name field is required and may not be longer than 255 characters."}})
|
||||
return
|
||||
}
|
||||
scopes, err := apiTokenScopes(fields["scopes"])
|
||||
catalog := classes.APITokenScopesFor(app)
|
||||
scopes, err := apiTokenScopes(catalog, fields["scopes"])
|
||||
if err != nil {
|
||||
writeValidation(w, map[string][]string{"scopes": {err.Error()}})
|
||||
return
|
||||
@@ -54,7 +54,7 @@ func APITokenStore(app *backpack.App) http.HandlerFunc {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
secret, token, err := classes.MintAPIToken(prefix, user.ID, name, scopes, expiresAt, nil)
|
||||
secret, token, err := classes.MintAPITokenFor(app, prefix, user.ID, name, scopes, expiresAt, nil)
|
||||
if err != nil || db.WithContext(r.Context()).Create(token).Error != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
@@ -63,6 +63,37 @@ func APITokenStore(app *backpack.App) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// APITokenScopes serves GET /_user/api/v1/tokens/scopes behind jwt.auth:
|
||||
// the member token scopes the caller may mint, {"data":[{"name","label",
|
||||
// "description","built_in"}]}, the built-ins first, then the scopes the
|
||||
// application's plugins declared. Phrase-key labels and descriptions are
|
||||
// translated for the request locale.
|
||||
func APITokenScopes(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := bouncer.User(r.Context())
|
||||
if !ok || user == nil || user.ID == 0 {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
tr := appTranslator(app)
|
||||
text := func(s string) string {
|
||||
if tr == nil || !strings.Contains(s, "::") {
|
||||
return s
|
||||
}
|
||||
return tr.Get(r.Context(), s, nil)
|
||||
}
|
||||
scopes := classes.APITokenScopesFor(app).Scopes()
|
||||
data := make([]map[string]any, 0, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
data = append(data, map[string]any{
|
||||
"name": scope.Name, "label": text(scope.Label),
|
||||
"description": text(scope.Description), "built_in": scope.BuiltIn,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": wire.Slice(data)})
|
||||
}
|
||||
}
|
||||
|
||||
// APITokenIndex lists the caller's live personal tokens without hashes.
|
||||
func APITokenIndex(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -107,8 +138,8 @@ func APITokenDestroy(app *backpack.App) http.HandlerFunc {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
// An MCP session opened with this token ends.
|
||||
classes.FireSubjectChanged(r.Context(), app, classes.SubjectGuardUser, user.ID, pact.MCPSubjectRevoked)
|
||||
// Only the MCP sessions opened with this token end.
|
||||
classes.FireSubjectEvent(r.Context(), app, pact.MCPSubjectChanged{Guard: classes.SubjectGuardUser, UserID: user.ID, Kind: pact.MCPSubjectRevoked, TokenID: token.ID})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"revoked": true}})
|
||||
}
|
||||
}
|
||||
@@ -131,7 +162,9 @@ func apiTokenDeps(w http.ResponseWriter, r *http.Request, app *backpack.App) (*g
|
||||
return db, user, true
|
||||
}
|
||||
|
||||
func apiTokenScopes(raw any) ([]string, error) {
|
||||
// apiTokenScopes reads the scopes field: nil, or an array of names the
|
||||
// catalog allows.
|
||||
func apiTokenScopes(catalog *classes.APITokenScopeCatalog, raw any) ([]string, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -142,7 +175,7 @@ func apiTokenScopes(raw any) ([]string, error) {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, rawScope := range values {
|
||||
scope, ok := rawScope.(string)
|
||||
if !ok || !slices.Contains(classes.AllowedAPITokenScopes, scope) {
|
||||
if !ok || !catalog.Allowed(scope) {
|
||||
return nil, errors.New("The selected scope is invalid.")
|
||||
}
|
||||
out = append(out, scope)
|
||||
|
||||
@@ -9,16 +9,12 @@ import (
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
)
|
||||
|
||||
// backendLinks returns the published link service, or one over app.
|
||||
func backendLinks(app *backpack.App) *classes.BackendLinks {
|
||||
if app != nil {
|
||||
if links, ok := app.Lookup[*classes.BackendLinks](); ok && links != nil {
|
||||
return links
|
||||
}
|
||||
}
|
||||
return &classes.BackendLinks{App: app}
|
||||
return classes.BackendLinksFor(app)
|
||||
}
|
||||
|
||||
// linkNotFound is the one answer for an unknown, used or expired code and
|
||||
@@ -33,30 +29,64 @@ func linkNotFound(w http.ResponseWriter) {
|
||||
// and display name and when the code expires, nothing else. Anyone holding
|
||||
// the code may read it; a code that is unknown, used or expired, or whose
|
||||
// administrator was deactivated, answers 404.
|
||||
//
|
||||
// Deprecated: the code sits in the request path, which access logs record.
|
||||
// Pages call BackendLinkShowPost (POST /_user/api/v1/backend-link/show with
|
||||
// the code in the body); this route stays for compatibility.
|
||||
func BackendLinkShow(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, admin, err := backendLinks(app).LinkRequestByCode(r.Context(), r.PathValue("code"))
|
||||
showLinkRequest(w, r, app, r.PathValue("code"))
|
||||
}
|
||||
}
|
||||
|
||||
// BackendLinkShowPost serves POST /_user/api/v1/backend-link/show: body
|
||||
// {"code": ...}, the same answers as BackendLinkShow. The code travels in
|
||||
// the body, so it stays out of the URLs servers and proxies log; a code in
|
||||
// the query string is ignored.
|
||||
func BackendLinkShowPost(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
fields, err := readBodyFields(r)
|
||||
if err != nil {
|
||||
if isLinkGone(err) {
|
||||
linkNotFound(w)
|
||||
return
|
||||
}
|
||||
writeOpaque500(w)
|
||||
writeNoStoreJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"admin": map[string]any{"login": admin.Login, "name": classes.BackendDisplayName(admin)},
|
||||
"expires_at": req.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
}})
|
||||
showLinkRequest(w, r, app, strField(fields, "code"))
|
||||
}
|
||||
}
|
||||
|
||||
// showLinkRequest answers the link request behind code. Both show routes
|
||||
// mark every answer as not storable, since it is keyed by a secret.
|
||||
func showLinkRequest(w http.ResponseWriter, r *http.Request, app *backpack.App, code string) {
|
||||
req, admin, err := backendLinks(app).LinkRequestByCode(r.Context(), code)
|
||||
if err != nil {
|
||||
if isLinkGone(err) {
|
||||
writeNoStoreJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeNoStoreJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"admin": map[string]any{"login": admin.Login, "name": classes.BackendDisplayName(admin)},
|
||||
"expires_at": req.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
}})
|
||||
}
|
||||
|
||||
// writeNoStoreJSON is writeJSON with Cache-Control: no-store, private.
|
||||
func writeNoStoreJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Cache-Control", "no-store, private")
|
||||
wire.WriteJSON(w, status, v)
|
||||
}
|
||||
|
||||
// BackendLinkConfirm serves POST /_user/api/v1/backend-link/confirm behind
|
||||
// jwt.auth: body {"code": ...}. The signed-in frontend user is linked to the
|
||||
// administrator who created the code, only when the session token was
|
||||
// issued at or after the request (the user signed in fresh for it): an
|
||||
// older session answers 409 with the stale-session error, an account already linked on
|
||||
// either side 409 already_linked, an unknown, used or expired code 404.
|
||||
// administrator who created the code, only when the session was signed in at
|
||||
// or after the request: the token's auth_time (bouncer.VerifyAuthTime), which
|
||||
// a refresh copies and never renews, is the proof. An older sign-in, or a
|
||||
// token without auth_time (minted before auth_time existed, or refreshed from
|
||||
// one), answers 409 with the stale-session error, an account already linked
|
||||
// on either side 409 already_linked, an unknown, used or expired code 404.
|
||||
// The code is read from the body only; one in the query string is ignored.
|
||||
func BackendLinkConfirm(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
@@ -70,19 +100,23 @@ func BackendLinkConfirm(app *backpack.App) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
// The token jwt.auth accepted (bearer header, then the session
|
||||
// cookies), read again for its iat.
|
||||
sub, iat, _, _, err := bouncer.VerifyClaims(classes.SessionToken(r), secret)
|
||||
// cookies), read again for its sign-in time.
|
||||
sub, authTime, signedIn, err := bouncer.VerifyAuthTime(classes.SessionToken(r), secret, bouncer.AudienceUser)
|
||||
if err != nil || sub != strconv.FormatUint(uint64(principal.ID), 10) {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
fields, err := readFields(r)
|
||||
if !signedIn {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{"error": "stale_session"})
|
||||
return
|
||||
}
|
||||
fields, err := readBodyFields(r)
|
||||
if err != nil {
|
||||
linkNotFound(w)
|
||||
return
|
||||
}
|
||||
links := backendLinks(app)
|
||||
_, err = links.ConfirmLinkRequest(r.Context(), strField(fields, "code"), principal.ID, iat)
|
||||
_, err = links.ConfirmLinkRequest(r.Context(), strField(fields, "code"), principal.ID, authTime)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, classes.ErrStaleSession):
|
||||
|
||||
523
controllers/devices.go
Normal file
523
controllers/devices.go
Normal file
@@ -0,0 +1,523 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// deviceTable is the device authorization session table.
|
||||
const deviceTable = "golem15_user_device_auth_sessions"
|
||||
|
||||
// deviceInsertAttempts bounds the retries of an initiate whose fresh short
|
||||
// code (or token) collided with a stored one.
|
||||
const deviceInsertAttempts = 5
|
||||
|
||||
// deviceCleanupBatch bounds the stale sessions one initiate deletes.
|
||||
const deviceCleanupBatch = 500
|
||||
|
||||
// writeDeviceUnauthorized is the PHP device controller's 401 body, which
|
||||
// differs from the session routes' {"error":true,"message":"Unauthorized"}.
|
||||
func writeDeviceUnauthorized(w http.ResponseWriter) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "Unauthorized"})
|
||||
}
|
||||
|
||||
// deviceCaller resolves the signed-in user of a device route that needs one
|
||||
// (authorize, list, revoke). It reads and checks the session token as the
|
||||
// plugin's jwt guard does (bearer header, then the session cookies; the
|
||||
// blacklist and tokens_valid_after apply) and also returns the token's jti.
|
||||
// Anything else answers PHP's 401 {"error":"Unauthorized"}.
|
||||
func deviceCaller(w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, secret string) (*models.User, string, bool) {
|
||||
principal, err := sessionGuard(app, secret).Authenticate(r)
|
||||
if err != nil || principal == nil || principal.ID == 0 {
|
||||
writeDeviceUnauthorized(w)
|
||||
return nil, "", false
|
||||
}
|
||||
var user models.User
|
||||
if err := gdb.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error; err != nil {
|
||||
writeDeviceUnauthorized(w)
|
||||
return nil, "", false
|
||||
}
|
||||
jti := ""
|
||||
if raw := classes.SessionToken(r); raw != "" {
|
||||
if _, _, _, id, err := bouncer.VerifyClaims(raw, secret); err == nil {
|
||||
jti = id
|
||||
}
|
||||
}
|
||||
return &user, jti, true
|
||||
}
|
||||
|
||||
// deviceClientIP is the client address device rows record: surf.ClientIP
|
||||
// with http.trusted_proxies, as /login and /register use.
|
||||
func deviceClientIP(app *backpack.App, r *http.Request) string {
|
||||
return surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))
|
||||
}
|
||||
|
||||
// DeviceInitiate is POST /devices/initiate, called without a session by the
|
||||
// device that wants to sign in. It stores a pending session (the SHA-256 of
|
||||
// a 64-character token, a short code XXXX-XXXX, expires_at now plus
|
||||
// golem15.user.device_auth.ttl_minutes, the device's address, user agent and
|
||||
// optional device_name of at most 255 characters) and answers
|
||||
// {"success":true,"data":{"token","short_code","auth_url","expires_at"}}.
|
||||
// Each call first deletes at most 500 pending or expired sessions that
|
||||
// expired more than an hour ago.
|
||||
func DeviceInitiate(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, _, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fields, err := readFields(r)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
name := strField(fields, "device_name")
|
||||
if utf8.RuneCountInString(name) > 255 {
|
||||
writeValidation(w, map[string][]string{"device_name": {"The device name may not be greater than 255 characters."}})
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
// Stale sessions go in bounded batches, so the unauthenticated route
|
||||
// cannot grow the table without limit (a failure is only logged).
|
||||
if err := gdb.WithContext(ctx).Exec(`DELETE FROM `+deviceTable+` WHERE id IN (
|
||||
SELECT id FROM `+deviceTable+`
|
||||
WHERE status IN ('pending', 'expired') AND expires_at < NOW() - INTERVAL '1 hour'
|
||||
LIMIT ?)`, deviceCleanupBatch).Error; err != nil {
|
||||
pluginLog.Warn("golem15.user: device session cleanup", "err", err)
|
||||
}
|
||||
ip := deviceClientIP(app, r)
|
||||
ua := r.UserAgent()
|
||||
expires := time.Now().Add(classes.DeviceAuthTTL(app)).Truncate(time.Second)
|
||||
var (
|
||||
token string
|
||||
row models.DeviceAuthSession
|
||||
)
|
||||
for attempt := 0; ; attempt++ {
|
||||
token, err = classes.NewDeviceToken()
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
code, err := classes.NewDeviceShortCode()
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
row = models.DeviceAuthSession{
|
||||
TokenHash: classes.HashAPIToken(token),
|
||||
ShortCode: &code,
|
||||
Status: models.DeviceStatusPending,
|
||||
ExpiresAt: &expires,
|
||||
}
|
||||
if ip != "" {
|
||||
row.DeviceIP = &ip
|
||||
}
|
||||
if ua != "" {
|
||||
row.DeviceUserAgent = &ua
|
||||
}
|
||||
if name != "" {
|
||||
row.DeviceName = &name
|
||||
}
|
||||
err = gdb.WithContext(ctx).Create(&row).Error
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) || pgErr.Code != "23505" || attempt+1 >= deviceInsertAttempts {
|
||||
pluginLog.Error("golem15.user: store device session", "err", err)
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"data": map[string]any{
|
||||
"token": token,
|
||||
"short_code": *row.ShortCode,
|
||||
"auth_url": classes.DeviceAuthURL(app, token),
|
||||
"expires_at": wire.Time{Time: expires},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DeviceStatus is GET /devices/status/{token}, polled by the waiting
|
||||
// device. It answers 404 {"error":"Session not found"} for an unknown token
|
||||
// and PHP's flat {"success":true,"status":"expired","message":…} for a
|
||||
// pending or confirmed session past expires_at (which it marks expired).
|
||||
// The first poll after a signed-in user authorized the session receives a
|
||||
// session token: {"success":true,"data":{"status":"confirmed","user_id",
|
||||
// "message":"Device authorized - complete login","token","user"}}. One
|
||||
// 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":…}}.
|
||||
func DeviceStatus(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, secret, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, found, err := deviceByToken(r, gdb, r.PathValue("token"))
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
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 handOverDevice(w, r, app, gdb, secret, row) {
|
||||
return
|
||||
}
|
||||
// Another poll won the hand-over or the session just expired:
|
||||
// answer what the row says now, never a token.
|
||||
row, found, err = deviceByID(r, gdb, row.ID)
|
||||
if err != nil || !found {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeDeviceStatus(w, r, gdb, row)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
if token == "" {
|
||||
return row, false, nil
|
||||
}
|
||||
res := gdb.WithContext(r.Context()).Where("token = ?", classes.HashAPIToken(token)).Limit(1).Find(&row)
|
||||
return row, res.RowsAffected == 1, res.Error
|
||||
}
|
||||
|
||||
func deviceByID(r *http.Request, gdb *gorm.DB, id uint) (models.DeviceAuthSession, bool, error) {
|
||||
var row models.DeviceAuthSession
|
||||
res := gdb.WithContext(r.Context()).Where("id = ?", id).Limit(1).Find(&row)
|
||||
return row, res.RowsAffected == 1, res.Error
|
||||
}
|
||||
|
||||
// writeDeviceStatus answers a status poll that hands over no token. A
|
||||
// pending or confirmed session past expires_at is marked expired (used and
|
||||
// revoked sessions never are); an expired session answers PHP's flat
|
||||
// expired body.
|
||||
func writeDeviceStatus(w http.ResponseWriter, r *http.Request, gdb *gorm.DB, row models.DeviceAuthSession) {
|
||||
expire := row.ExpiredAt(time.Now())
|
||||
if expire || row.Status == models.DeviceStatusExpired {
|
||||
if expire {
|
||||
if err := gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+` SET status = 'expired', updated_at = NOW()
|
||||
WHERE id = ? AND status IN ('pending', 'confirmed')`, row.ID).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"status": models.DeviceStatusExpired,
|
||||
"message": "Authorization session has expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "data": map[string]any{"status": row.Status}})
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
res := gdb.WithContext(ctx).Where("id = ?", *row.UserID).Limit(1).Find(&user)
|
||||
if res.Error != nil {
|
||||
writeOpaque500(w)
|
||||
return true
|
||||
}
|
||||
refused := res.RowsAffected == 0
|
||||
if !refused {
|
||||
banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, app), gdb.WithContext(ctx), []uint{user.ID})
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return true
|
||||
}
|
||||
refused = banned[user.ID]
|
||||
}
|
||||
if refused {
|
||||
// A deactivated, deleted or banned user signs no device in.
|
||||
if err := gdb.WithContext(ctx).Exec(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW()
|
||||
WHERE id = ? AND status = 'confirmed'`, row.ID).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
token, jti, err := mintWithJTI(app, secret, &user, requestURL(r))
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return true
|
||||
}
|
||||
payload, err := apiArray(ctx, app, &user)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return true
|
||||
}
|
||||
res = gdb.WithContext(ctx).Exec(`UPDATE `+deviceTable+`
|
||||
SET status = 'used', used_at = NOW(), last_activity_at = NOW(), jwt_jti = ?, updated_at = NOW()
|
||||
WHERE id = ? AND status = 'confirmed' AND expires_at > NOW()`, jti, row.ID)
|
||||
if res.Error != nil {
|
||||
writeOpaque500(w)
|
||||
return true
|
||||
}
|
||||
if res.RowsAffected != 1 {
|
||||
return false
|
||||
}
|
||||
if err := FinishLogin(ctx, app, gdb, &user, deviceClientIP(app, r)); err != nil {
|
||||
pluginLog.Warn("golem15.user: finish device login", "user_id", user.ID, "err", err)
|
||||
}
|
||||
if err := FireLoginEvent(ctx, app, &user, classes.LoginSourceDevice); err != nil {
|
||||
pluginLog.Warn("golem15.user: login event listener", "user_id", user.ID, "err", err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"data": map[string]any{
|
||||
"status": models.DeviceStatusConfirmed,
|
||||
"user_id": user.ID,
|
||||
"message": "Device authorized - complete login",
|
||||
"token": token,
|
||||
"user": payload,
|
||||
},
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// code"}; success, and repeating it for a session the caller already
|
||||
// confirmed, answers {"success":true,"message":"Device authorized
|
||||
// successfully"}. Without a session it answers 401 {"error":"Unauthorized"}.
|
||||
// The fields are read from the body only, never the query string.
|
||||
func DeviceAuthorize(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, secret, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _, ok := deviceCaller(w, r, app, gdb, secret)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fields, err := readBodyFields(r)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
invalid := func() {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Invalid or expired code"})
|
||||
}
|
||||
var (
|
||||
row models.DeviceAuthSession
|
||||
found bool
|
||||
)
|
||||
if _, has := fields["short_code"]; has {
|
||||
raw := strings.ReplaceAll(strField(fields, "short_code"), "-", "")
|
||||
if len(raw) != 8 {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "Please enter a valid 8-character code"})
|
||||
return
|
||||
}
|
||||
code, valid := classes.NormalizeDeviceShortCode(raw)
|
||||
if !valid {
|
||||
invalid()
|
||||
return
|
||||
}
|
||||
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 if _, has := fields["token"]; has {
|
||||
row, found, err = deviceByToken(r, gdb, strField(fields, "token"))
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
invalid()
|
||||
return
|
||||
}
|
||||
res := gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+`
|
||||
SET status = 'confirmed', user_id = ?, confirmed_at = NOW(), auth_ip = ?, updated_at = NOW()
|
||||
WHERE id = ? AND status = 'pending' AND (user_id IS NULL OR user_id = ?) AND expires_at > NOW()`,
|
||||
user.ID, nullableString(deviceClientIP(app, r)), row.ID, user.ID)
|
||||
if res.Error != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected != 1 {
|
||||
now, ok, err := deviceByID(r, gdb, row.ID)
|
||||
if err != nil || !ok {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
again := now.Status == models.DeviceStatusConfirmed && now.UserID != nil && *now.UserID == user.ID && !now.ExpiredAt(time.Now())
|
||||
if !again {
|
||||
if now.ExpiredAt(time.Now()) {
|
||||
_ = gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+` SET status = 'expired', updated_at = NOW()
|
||||
WHERE id = ? AND status IN ('pending', 'confirmed')`, now.ID).Error
|
||||
}
|
||||
invalid()
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "Device authorized successfully"})
|
||||
}
|
||||
}
|
||||
|
||||
// nullableString is nil for "", else s.
|
||||
func nullableString(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// mintWithJTI mints the session JWT as mintFor does and also returns its
|
||||
// jti, which device sessions store.
|
||||
func mintWithJTI(app *backpack.App, secret string, user *models.User, issuer string) (string, string, error) {
|
||||
return bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, sessionTTL(app))
|
||||
}
|
||||
|
||||
// DeviceIndex is GET /devices: the caller's authorized devices (sessions
|
||||
// confirmed or used), most recent activity first, as {"success":true,
|
||||
// "devices":[{id, device_name, device_ip, authorized_at, last_activity,
|
||||
// is_current}]}. device_name is the stored name or one parsed from the user
|
||||
// agent (classes.DeviceDisplayName); is_current is true for the session
|
||||
// whose token is the one the request carries. Without a session it answers
|
||||
// 401 {"error":"Unauthorized"}.
|
||||
func DeviceIndex(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, secret, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, jti, ok := deviceCaller(w, r, app, gdb, secret)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var rows []models.DeviceAuthSession
|
||||
if err := gdb.WithContext(r.Context()).
|
||||
Where("user_id = ? AND status IN ?", user.ID, []string{models.DeviceStatusConfirmed, models.DeviceStatusUsed}).
|
||||
Order("last_activity_at DESC NULLS LAST, id DESC").
|
||||
Find(&rows).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
devices := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ua := ""
|
||||
if row.DeviceUserAgent != nil {
|
||||
ua = *row.DeviceUserAgent
|
||||
}
|
||||
devices = append(devices, map[string]any{
|
||||
"id": row.ID,
|
||||
"device_name": classes.DeviceDisplayName(row.DeviceName, ua),
|
||||
"device_ip": row.DeviceIP,
|
||||
"authorized_at": wireTimeOrNil(row.ConfirmedAt),
|
||||
"last_activity": wireTimeOrNil(row.LastActivityAt),
|
||||
"is_current": jti != "" && row.JWTJTI != nil && *row.JWTJTI == jti,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "devices": wire.Slice(devices)})
|
||||
}
|
||||
}
|
||||
|
||||
// DeviceDestroy is DELETE /devices/{id}: the caller revokes one of their
|
||||
// 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"}.
|
||||
func DeviceDestroy(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, secret, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _, ok := deviceCaller(w, r, app, gdb, secret)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
notFound := func() { writeJSON(w, http.StatusNotFound, map[string]any{"error": "Device not found"}) }
|
||||
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if row.JWTJTI == nil || *row.JWTJTI == "" {
|
||||
return nil
|
||||
}
|
||||
bl, ok := app.Lookup[bouncer.BlacklistStore]()
|
||||
if !ok || bl == nil {
|
||||
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)
|
||||
})
|
||||
if err != nil {
|
||||
pluginLog.Error("golem15.user: revoke device session", "user_id", user.ID, "err", err)
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "Device revoked successfully"})
|
||||
}
|
||||
}
|
||||
|
||||
// wireTimeOrNil is t as a +00:00 timestamp, or nil.
|
||||
func wireTimeOrNil(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return wire.Time{Time: *t}
|
||||
}
|
||||
26
controllers/login_gates.go
Normal file
26
controllers/login_gates.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
)
|
||||
|
||||
// CheckLoginGates consults the published *classes.LoginGates for a
|
||||
// first-factor sign-in that already succeeded and returns its answer: nil,
|
||||
// nil allows the sign-in, a result replaces the token response, and an error
|
||||
// must fail the sign-in closed (answer the opaque 500, issue no token). It
|
||||
// looks the registry up on every call and allows the sign-in when none is
|
||||
// published. Password Login calls it; a social-login plugin calls it before
|
||||
// it issues a token.
|
||||
func CheckLoginGates(ctx context.Context, app *backpack.App, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
if app == nil {
|
||||
return nil, nil
|
||||
}
|
||||
gates, ok := app.Lookup[*classes.LoginGates]()
|
||||
if !ok || gates == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return gates.Check(ctx, a)
|
||||
}
|
||||
@@ -4,9 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -119,3 +123,50 @@ func (organisationsAdminController) FormBeforeDelete(ctx context.Context, model
|
||||
Model(&models.User{}).Where("organisation_id = ?", organisation.ID).
|
||||
Updates(map[string]any{"organisation_id": nil, "organisation_role": nil}).Error
|
||||
}
|
||||
|
||||
// FormAfterDelete deletes the organisation's attachments (avatar and logo)
|
||||
// in the delete's transaction, and their blobs once it commits, so no
|
||||
// system_files row outlives the organisation row.
|
||||
func (c organisationsAdminController) FormAfterDelete(ctx context.Context, model any) error {
|
||||
organisation, ok := model.(*models.Organisation)
|
||||
if !ok || organisation == nil {
|
||||
return errors.New("organisations admin: form model is not an organisation")
|
||||
}
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return errors.New("organisations admin: a delete needs its transaction")
|
||||
}
|
||||
var keys []string
|
||||
if err := attach.DeleteForOwner(tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx), organisation, strconv.FormatUint(uint64(organisation.ID), 10), func(blobKeys []string) error {
|
||||
keys = append(keys, blobKeys...)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
bucket := c.bucket()
|
||||
if bucket == nil || len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
id := organisation.ID
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
// The rows are gone by now, so a blob that cannot be removed is
|
||||
// orphaned, not lost data: log it and carry on.
|
||||
if err := attach.DeleteKeys(ctx, bucket, keys); err != nil {
|
||||
pluginLog.Warn("golem15.user: remove attachment blobs of a deleted organisation", "organisation_id", id, "err", err)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// bucket is the application's blob bucket, or nil when none is published.
|
||||
func (c organisationsAdminController) bucket() *blob.Bucket {
|
||||
app := c.app.application()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
bucket, ok := app.Lookup[*blob.Bucket]()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return bucket
|
||||
}
|
||||
|
||||
@@ -102,8 +102,10 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
|
||||
// (a social sign-in). It restores a soft-deleted account and sends the
|
||||
// reactivate mail, records ip as last_ip_address, and touches last_seen. It
|
||||
// checks no password, throttle, activation or ban and fires no event; the
|
||||
// caller does those checks (Go has no login event). A failed last_seen
|
||||
// write is logged and does not fail it, as in Login.
|
||||
// caller does those checks, consults the login gates (CheckLoginGates)
|
||||
// before it, and fires the login event (FireLoginEvent) after it minted the
|
||||
// token. A failed last_seen write is logged and does not fail it, as in
|
||||
// Login.
|
||||
func FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *models.User, ip string) error {
|
||||
if db == nil {
|
||||
return errors.New("golem15.user: gorm handle is missing")
|
||||
@@ -179,6 +181,17 @@ func FireDeactivatingEvent(ctx context.Context, app *backpack.App, user *models.
|
||||
return app.Events.Fire(ctx, &classes.DeactivatingEvent{User: user, Tx: tx})
|
||||
}
|
||||
|
||||
// FireLoginEvent fires classes.LoginEvent (golem15.user.login) for user
|
||||
// with source, one of the classes.LoginSource constants, and returns the
|
||||
// listeners' error. Call it after the session token was minted; a caller
|
||||
// logs the error and still answers the sign-in.
|
||||
func FireLoginEvent(ctx context.Context, app *backpack.App, user *models.User, source string) error {
|
||||
if app == nil || app.Events == nil {
|
||||
return nil
|
||||
}
|
||||
return app.Events.Fire(ctx, &classes.LoginEvent{User: user, Source: source})
|
||||
}
|
||||
|
||||
// IssueToken mints the session JWT for user exactly as /register and
|
||||
// /login do: the plugin's JWT secret, golem15.user.jwt.ttl and the request
|
||||
// URL as the issuer.
|
||||
|
||||
@@ -14,7 +14,7 @@ update:
|
||||
preview:
|
||||
headerPartial: hint
|
||||
|
||||
recordActions: [activate, unban, unsuspend]
|
||||
recordActions: [activate, unban, unsuspend, reset_two_factor]
|
||||
|
||||
messages:
|
||||
create: golem15.user::lang.users.new_user
|
||||
|
||||
@@ -244,8 +244,15 @@ func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) e
|
||||
// user's own change-password does. The cutoff is truncated to whole
|
||||
// seconds, as in RevokeUserAccess, because JWT iat has whole-second
|
||||
// precision, so the user's next login in that second is not refused.
|
||||
now := time.Now().Truncate(time.Second)
|
||||
// OAuth grants created before the untruncated instant are revoked in
|
||||
// the save's transaction (the access-revoked event).
|
||||
at := time.Now()
|
||||
now := at.Truncate(time.Second)
|
||||
user.TokensValidAfter = &now
|
||||
tx, _ := cabana.TxFromContext(ctx)
|
||||
if err := classes.FireAccessRevoked(ctx, c.application(), tx, []uint{user.ID}, at, classes.AccessRevokedPasswordSetByAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -631,11 +638,50 @@ func (c usersAdminController) AdminBulkActions() []pact.AdminBulkAction {
|
||||
bulk("activate", classes.ActivateUsers),
|
||||
bulk("deactivate", c.deactivateUsers),
|
||||
bulk("restore", classes.RestoreUsers),
|
||||
bulk("ban", classes.BanUsers),
|
||||
bulk("ban", c.banUsers),
|
||||
bulk("unban", classes.UnbanUsers),
|
||||
}
|
||||
}
|
||||
|
||||
// banUsers runs classes.BanUsers unchanged in the action's transaction and
|
||||
// fires the access-revoked event there for the users it newly banned (banned
|
||||
// after, not before), so their OAuth grants are revoked with the ban and
|
||||
// their MCP sessions end once it commits. A listener error rolls the action
|
||||
// back.
|
||||
func (c usersAdminController) banUsers(ctx context.Context, tx *gorm.DB, users []*models.User) (int, error) {
|
||||
ids := make([]uint, 0, len(users))
|
||||
for _, user := range users {
|
||||
if user != nil && user.ID != 0 {
|
||||
ids = append(ids, user.ID)
|
||||
}
|
||||
}
|
||||
before, _, err := classes.ThrottleStates(ctx, tx, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
at := time.Now()
|
||||
changed, err := classes.BanUsers(ctx, tx, users)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
after, _, err := classes.ThrottleStates(ctx, tx, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
newly := make([]uint, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if after[id] && !before[id] {
|
||||
newly = append(newly, id)
|
||||
}
|
||||
}
|
||||
if len(newly) > 0 {
|
||||
if err := classes.FireAccessRevoked(ctx, c.application(), tx, newly, at, classes.AccessRevokedBan); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// deactivateUsers runs classes.DeactivateUsers unchanged and then fires
|
||||
// user.Deactivating, in the action's transaction, for every selected user
|
||||
// that was not deactivated before. A listener error rolls the action back.
|
||||
@@ -673,8 +719,15 @@ func (c usersAdminController) throttleState(ctx context.Context, userID uint) (b
|
||||
}
|
||||
|
||||
// AdminRecordActions are the PHP preview handlers onActivate, onUnban and
|
||||
// onUnsuspendUser. Each is offered only while it applies; the checks only
|
||||
// read. There is no impersonate action and no guest action.
|
||||
// onUnsuspendUser, plus reset_two_factor. Each is offered only while it
|
||||
// applies; the checks only read. There is no impersonate action and no
|
||||
// guest action.
|
||||
//
|
||||
// reset_two_factor ("Reset 2FA") needs classes.PermissionResetTwoFactor and
|
||||
// asks for a danger confirmation. It is offered only when a two-factor
|
||||
// plugin published a classes.TwoFactorAdmin (looked up per request) and the
|
||||
// user has a second factor; Run hands the record to that service's Reset in
|
||||
// the action's transaction.
|
||||
func (c usersAdminController) AdminRecordActions() []pact.AdminRecordAction {
|
||||
asUser := func(record any) (*models.User, error) {
|
||||
user, ok := record.(*models.User)
|
||||
@@ -739,9 +792,55 @@ func (c usersAdminController) AdminRecordActions() []pact.AdminRecordAction {
|
||||
func(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
||||
return classes.UnsuspendUser(ctx, tx, user.ID)
|
||||
}),
|
||||
c.resetTwoFactorAction(action),
|
||||
}
|
||||
}
|
||||
|
||||
// resetTwoFactorAction builds the reset_two_factor record action with the
|
||||
// shared action builder, behind classes.PermissionResetTwoFactor and marked
|
||||
// as dangerous.
|
||||
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,
|
||||
) pact.AdminRecordAction) pact.AdminRecordAction {
|
||||
reset := action("reset_two_factor", "reset_two_factor", "reset_two_factor_confirm", "reset_two_factor_success",
|
||||
func(ctx context.Context, user *models.User) (bool, error) {
|
||||
svc, ok := c.twoFactorAdmin()
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
db := requestDB(ctx, c.db)
|
||||
if db == nil {
|
||||
return false, errors.New("users admin: gorm handle is missing")
|
||||
}
|
||||
return svc.HasTwoFactor(ctx, db, user.ID)
|
||||
},
|
||||
func(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
||||
svc, ok := c.twoFactorAdmin()
|
||||
if !ok {
|
||||
return errors.New("users admin: no two-factor plugin publishes classes.TwoFactorAdmin")
|
||||
}
|
||||
return svc.Reset(ctx, tx, user)
|
||||
})
|
||||
reset.Permissions = []string{classes.PermissionResetTwoFactor}
|
||||
reset.Danger = true
|
||||
return reset
|
||||
}
|
||||
|
||||
// twoFactorAdmin looks up the classes.TwoFactorAdmin a two-factor plugin
|
||||
// published, on every call, so a plugin that boots later is still found.
|
||||
func (c usersAdminController) twoFactorAdmin() (classes.TwoFactorAdmin, bool) {
|
||||
app := c.application()
|
||||
if app == nil {
|
||||
return nil, false
|
||||
}
|
||||
svc, ok := app.Lookup[classes.TwoFactorAdmin]()
|
||||
if !ok || svc == nil {
|
||||
return nil, false
|
||||
}
|
||||
return svc, true
|
||||
}
|
||||
|
||||
// usersHintView is the view model of controllers/users/_hint.htm, the status
|
||||
// callout above the preview: the callout kind (danger or warning) and the
|
||||
// phrase keys of its title and text. It is empty when no state applies, and
|
||||
|
||||
320
device_flow_smoke_test.go
Normal file
320
device_flow_smoke_test.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var deviceShortCodeRE = regexp.MustCompile(`^[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$`)
|
||||
|
||||
// TestDeviceCodesSmoke pins the device token, the short code, its
|
||||
// normalisation and the auth_url template. No database.
|
||||
func TestDeviceCodesSmoke(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for range 1000 {
|
||||
tok, err := classes.NewDeviceToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tok) != 64 || strings.Trim(tok, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" {
|
||||
t.Fatalf("token %q is not 64 base64url characters", tok)
|
||||
}
|
||||
if seen[tok] {
|
||||
t.Fatalf("token %q repeated", tok)
|
||||
}
|
||||
seen[tok] = true
|
||||
code, err := classes.NewDeviceShortCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !deviceShortCodeRE.MatchString(code) {
|
||||
t.Fatalf("short code %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
for in, want := range map[string]string{
|
||||
"abcd-efgh": "ABCD-EFGH",
|
||||
"ABCDEFGH": "ABCD-EFGH",
|
||||
" ab cd ef gh ": "ABCD-EFGH",
|
||||
"2345-6789": "2345-6789",
|
||||
} {
|
||||
if got, ok := classes.NormalizeDeviceShortCode(in); !ok || got != want {
|
||||
t.Fatalf("normalize %q = %q %v, want %q", in, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"ABCDEFG", "ABCDEFGHJ", "ABCDEFGO", "ABCDEFGI", "ABCDEFGL", "ABCDEFG0", "ABCDEFG1", ""} {
|
||||
if got, ok := classes.NormalizeDeviceShortCode(in); ok {
|
||||
t.Fatalf("normalize %q accepted as %q", in, got)
|
||||
}
|
||||
}
|
||||
|
||||
app := backpack.New(sessionConfig(t))
|
||||
if got := classes.DeviceAuthURL(app, "tok"); got != "/device/scan/tok" {
|
||||
t.Fatalf("auth_url without urls = %q", got)
|
||||
}
|
||||
if err := app.Config.Set("app.url", "https://api.example.test/"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := classes.DeviceAuthURL(app, "tok"); got != "https://api.example.test/device/scan/tok" {
|
||||
t.Fatalf("auth_url from app.url = %q", got)
|
||||
}
|
||||
if err := app.Config.Set("golem15.user.frontend_url", "https://www.example.test/"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := classes.DeviceAuthURL(app, "tok"); got != "https://www.example.test/device/scan/tok" {
|
||||
t.Fatalf("auth_url from frontend_url = %q", got)
|
||||
}
|
||||
if err := app.Config.Set("golem15.user.device_auth.auth_url", "{frontend_url}/link?device={token}"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := classes.DeviceAuthURL(app, "tok"); got != "https://www.example.test/link?device=tok" {
|
||||
t.Fatalf("auth_url from a custom template = %q", got)
|
||||
}
|
||||
if got := classes.DeviceAuthTTL(app); got != 5*time.Minute {
|
||||
t.Fatalf("default ttl = %s", got)
|
||||
}
|
||||
|
||||
tv := "Living room TV"
|
||||
for _, c := range []struct {
|
||||
stored *string
|
||||
ua string
|
||||
want string
|
||||
}{
|
||||
{&tv, "Mozilla/5.0 (iPhone)", tv},
|
||||
{nil, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", "iPhone"},
|
||||
{nil, "Mozilla/5.0 (iPad; CPU OS 17_0)", "iPad"},
|
||||
{nil, "Mozilla/5.0 (Linux; Android 14)", "Android Device"},
|
||||
{nil, "Mozilla/5.0 (Windows NT 10.0)", "Windows PC"},
|
||||
{nil, "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", "Mac"},
|
||||
{nil, "Mozilla/5.0 (X11; Linux x86_64)", "Linux PC"},
|
||||
{nil, "curl/8.0", "Unknown Device"},
|
||||
} {
|
||||
if got := classes.DeviceDisplayName(c.stored, c.ua); got != c.want {
|
||||
t.Fatalf("display name of %q = %q, want %q", c.ua, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deviceCall runs one device handler with an optional bearer token and JSON
|
||||
// body; pathToken fills {token} or {id}.
|
||||
func deviceCall(t *testing.T, h http.HandlerFunc, method, path, bearerToken, body string, pathValues map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var req *http.Request
|
||||
if body != "" {
|
||||
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
req.Host = "example.com"
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) SmokeTV")
|
||||
if bearerToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
||||
}
|
||||
for k, v := range pathValues {
|
||||
req.SetPathValue(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
type deviceInitiated struct {
|
||||
Token string `json:"token"`
|
||||
ShortCode string `json:"short_code"`
|
||||
AuthURL string `json:"auth_url"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
func initiateDevice(t *testing.T, app *backpack.App, name string) deviceInitiated {
|
||||
t.Helper()
|
||||
body := `{}`
|
||||
if name != "" {
|
||||
body = `{"device_name":"` + name + `"}`
|
||||
}
|
||||
rec := deviceCall(t, controllers.DeviceInitiate(app), http.MethodPost, "/_user/api/v1/devices/initiate", "", body, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Success bool `json:"success"`
|
||||
Data deviceInitiated `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || !out.Success {
|
||||
t.Fatalf("initiate body %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
return out.Data
|
||||
}
|
||||
|
||||
func deviceStatus(t *testing.T, app *backpack.App, token string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
rec := deviceCall(t, controllers.DeviceStatus(app), http.MethodGet, "/_user/api/v1/devices/status/"+token, "", "", map[string]string{"token": token})
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
return rec.Code, body
|
||||
}
|
||||
|
||||
func authorizeDevice(t *testing.T, app *backpack.App, bearerToken, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return deviceCall(t, controllers.DeviceAuthorize(app), http.MethodPost, "/_user/api/v1/devices/authorize", bearerToken, body, nil)
|
||||
}
|
||||
|
||||
func deviceRow(t *testing.T, gdb *gorm.DB, token string) models.DeviceAuthSession {
|
||||
t.Helper()
|
||||
var row models.DeviceAuthSession
|
||||
if err := gdb.Where("token = ?", classes.HashAPIToken(token)).Take(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func statusData(body map[string]any) map[string]any {
|
||||
data, _ := body["data"].(map[string]any)
|
||||
return data
|
||||
}
|
||||
|
||||
// TestDeviceFlowSmoke drives initiate, authorize and the one-time hand-over
|
||||
// on Postgres.
|
||||
func TestDeviceFlowSmoke(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres (testcontainers)")
|
||||
}
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
email := "device-owner-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.test"
|
||||
session := loginToken(t, app, gdb, email)
|
||||
var owner models.User
|
||||
if err := gdb.Where("email = ?", email).Take(&owner).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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") {
|
||||
t.Fatalf("initiate data = %+v", dev)
|
||||
}
|
||||
row := deviceRow(t, gdb, dev.Token)
|
||||
if row.TokenHash != classes.HashAPIToken(dev.Token) || row.Status != models.DeviceStatusPending || row.UserID != nil ||
|
||||
row.DeviceName == nil || *row.DeviceName != "Living room TV" || row.DeviceUserAgent == nil {
|
||||
t.Fatalf("stored row = %+v", row)
|
||||
}
|
||||
if code, body := deviceStatus(t, app, dev.Token); code != http.StatusOK || body["success"] != true || statusData(body)["status"] != "pending" {
|
||||
t.Fatalf("pending status = %d %v", code, body)
|
||||
}
|
||||
if code, body := deviceStatus(t, app, strings.Repeat("z", 64)); code != http.StatusNotFound || body["error"] != "Session not found" {
|
||||
t.Fatalf("unknown status = %d %v", code, body)
|
||||
}
|
||||
|
||||
// Authorize needs a session, answering PHP's 401 body.
|
||||
if rec := authorizeDevice(t, app, "", `{"short_code":"`+dev.ShortCode+`"}`); rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Unauthorized"}` {
|
||||
t.Fatalf("authorize without a session = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := authorizeDevice(t, app, session, `{"short_code":"ABC"}`); rec.Code != http.StatusUnprocessableEntity || rec.Body.String() != `{"error":"Please enter a valid 8-character code"}` {
|
||||
t.Fatalf("short code of 3 = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
lower := strings.ToLower(dev.ShortCode)
|
||||
if rec := authorizeDevice(t, app, session, `{"short_code":"`+lower+`"}`); rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Device authorized successfully","success":true}` {
|
||||
t.Fatalf("authorize = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := authorizeDevice(t, app, session, `{"short_code":"`+lower+`"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("authorizing again for the same user = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// The first poll after confirmation receives the token, once.
|
||||
code, body := deviceStatus(t, app, dev.Token)
|
||||
data := statusData(body)
|
||||
if code != http.StatusOK || data["status"] != "confirmed" || data["message"] != "Device authorized - complete login" || data["user_id"] != float64(owner.ID) {
|
||||
t.Fatalf("hand-over = %d %v", code, body)
|
||||
}
|
||||
jwtToken, _ := data["token"].(string)
|
||||
sub, _, _, jti, err := bouncer.VerifyClaims(jwtToken, sessionSecret)
|
||||
if err != nil || sub != strconv.FormatUint(uint64(owner.ID), 10) {
|
||||
t.Fatalf("device token sub=%q err=%v", sub, err)
|
||||
}
|
||||
if user, _ := data["user"].(map[string]any); user["email"] != email {
|
||||
t.Fatalf("hand-over user = %v", data["user"])
|
||||
}
|
||||
row = deviceRow(t, gdb, dev.Token)
|
||||
if row.Status != models.DeviceStatusUsed || row.JWTJTI == nil || *row.JWTJTI != jti || row.UsedAt == nil {
|
||||
t.Fatalf("row after hand-over = %+v", row)
|
||||
}
|
||||
if _, body := deviceStatus(t, app, dev.Token); statusData(body)["status"] != "used" || statusData(body)["token"] != nil {
|
||||
t.Fatalf("second poll = %v", body)
|
||||
}
|
||||
// The used session is past nothing: even past expires_at it stays used.
|
||||
if err := gdb.Model(&models.DeviceAuthSession{}).Where("id = ?", row.ID).Update("expires_at", time.Now().Add(-time.Hour)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, body := deviceStatus(t, app, dev.Token); statusData(body)["status"] != "used" {
|
||||
t.Fatalf("used session past expires_at = %v", body)
|
||||
}
|
||||
// A used session cannot be authorized again.
|
||||
if rec := authorizeDevice(t, app, session, `{"token":"`+dev.Token+`"}`); rec.Code != http.StatusBadRequest || rec.Body.String() != `{"error":"Invalid or expired code"}` {
|
||||
t.Fatalf("authorize a used session = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Ten concurrent polls of a confirmed session yield exactly one token.
|
||||
race := initiateDevice(t, app, "")
|
||||
if rec := authorizeDevice(t, app, session, `{"token":"`+race.Token+`"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("authorize by token = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
tokens int
|
||||
used int
|
||||
)
|
||||
for range 10 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, body := deviceStatus(t, app, race.Token)
|
||||
d := statusData(body)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if tok, _ := d["token"].(string); tok != "" {
|
||||
tokens++
|
||||
} else if d["status"] == "used" {
|
||||
used++
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if tokens != 1 || used != 9 {
|
||||
t.Fatalf("concurrent polls: %d tokens, %d used", tokens, used)
|
||||
}
|
||||
|
||||
// A pending session past expires_at answers PHP's flat expired body.
|
||||
stale := initiateDevice(t, app, "")
|
||||
if err := gdb.Model(&models.DeviceAuthSession{}).Where("token = ?", classes.HashAPIToken(stale.Token)).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := authorizeDevice(t, app, session, `{"short_code":"`+stale.ShortCode+`"}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("authorize an expired session = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec := deviceCall(t, controllers.DeviceStatus(app), http.MethodGet, "/_user/api/v1/devices/status/"+stale.Token, "", "", map[string]string{"token": stale.Token})
|
||||
if rec.Body.String() != `{"message":"Authorization session has expired","status":"expired","success":true}` {
|
||||
t.Fatalf("expired status = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if row := deviceRow(t, gdb, stale.Token); row.Status != models.DeviceStatusExpired {
|
||||
t.Fatalf("expired row status = %s", row.Status)
|
||||
}
|
||||
|
||||
// device_name is bounded.
|
||||
long := strings.Repeat("n", 256)
|
||||
if rec := deviceCall(t, controllers.DeviceInitiate(app), http.MethodPost, "/_user/api/v1/devices/initiate", "", `{"device_name":"`+long+`"}`, nil); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("device_name of 256 = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
183
device_revoke_smoke_test.go
Normal file
183
device_revoke_smoke_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
)
|
||||
|
||||
// envDevice signs a device in through the assembled routes: initiate,
|
||||
// authorize by session, first poll. It returns the device's session token,
|
||||
// its jti and the session row id.
|
||||
func envDevice(t *testing.T, env *adminEnv, session, name string) (string, string, uint) {
|
||||
t.Helper()
|
||||
rec := env.call(t, http.MethodPost, "/_user/api/v1/devices/initiate", "", map[string]string{"device_name": name})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var started struct {
|
||||
Data deviceInitiated `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &started); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/devices/authorize", session, map[string]string{"token": started.Data.Token})
|
||||
rec = env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/devices/status/"+started.Data.Token, "", nil)
|
||||
var polled struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &polled); err != nil || polled.Data.Token == "" {
|
||||
t.Fatalf("hand-over %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
_, _, _, jti, err := bouncer.VerifyClaims(polled.Data.Token, sessionSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var row models.DeviceAuthSession
|
||||
if err := env.gdb.Where("jwt_jti = ?", jti).Take(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return polled.Data.Token, jti, row.ID
|
||||
}
|
||||
|
||||
type deviceListBody struct {
|
||||
Success bool `json:"success"`
|
||||
Devices []struct {
|
||||
ID uint `json:"id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceIP *string `json:"device_ip"`
|
||||
AuthorizedAt *string `json:"authorized_at"`
|
||||
LastActivity *string `json:"last_activity"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
} `json:"devices"`
|
||||
}
|
||||
|
||||
func envDevices(t *testing.T, env *adminEnv, token string) deviceListBody {
|
||||
t.Helper()
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/devices", token, nil)
|
||||
var body deviceListBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// TestDeviceRevokeSmoke lists and revokes device sessions through the
|
||||
// assembled routes, including a device that refreshed its token first.
|
||||
func TestDeviceRevokeSmoke(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
env := newAdminEnv(t)
|
||||
owner := env.seedUser(t, "dev-owner", nil)
|
||||
other := env.seedUser(t, "dev-other", nil)
|
||||
status, session := env.userLogin(t, owner.Email, "correct-horse")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("owner login = %d", status)
|
||||
}
|
||||
otherStatus, otherSession := env.userLogin(t, other.Email, "correct-horse")
|
||||
if otherStatus != http.StatusOK {
|
||||
t.Fatalf("other login = %d", otherStatus)
|
||||
}
|
||||
|
||||
tvToken, _, tvID := envDevice(t, env, session, "Living room TV")
|
||||
// A second used session with no name and an older activity, a pending
|
||||
// session of the owner and a device of another user.
|
||||
hourAgo := time.Now().Add(-time.Hour)
|
||||
ua := "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)"
|
||||
oldJTI := "dev-old-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
ownerID := owner.ID
|
||||
phone := models.DeviceAuthSession{TokenHash: "a" + strconv.FormatInt(time.Now().UnixNano(), 10), UserID: &ownerID, Status: models.DeviceStatusUsed,
|
||||
ExpiresAt: &hourAgo, DeviceUserAgent: &ua, ConfirmedAt: &hourAgo, LastActivityAt: &hourAgo, JWTJTI: &oldJTI}
|
||||
pendingRow := models.DeviceAuthSession{TokenHash: "b" + strconv.FormatInt(time.Now().UnixNano(), 10), UserID: &ownerID, Status: models.DeviceStatusPending, ExpiresAt: &hourAgo}
|
||||
if err := env.gdb.Create(&phone).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.gdb.Create(&pendingRow).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, foreignID := envDevice(t, env, otherSession, "Other TV")
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
if rec := env.call(t, http.MethodGet, "/_user/api/v1/devices", "", nil); rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Unauthorized"}` {
|
||||
t.Fatalf("list without a session = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
list := envDevices(t, env, tvToken)
|
||||
if !list.Success || len(list.Devices) != 2 {
|
||||
t.Fatalf("list = %+v", list)
|
||||
}
|
||||
first, second := list.Devices[0], list.Devices[1]
|
||||
if first.ID != tvID || first.DeviceName != "Living room TV" || !first.IsCurrent || first.AuthorizedAt == nil || first.LastActivity == nil {
|
||||
t.Fatalf("first device = %+v", first)
|
||||
}
|
||||
if second.ID != phone.ID || second.DeviceName != "iPhone" || second.IsCurrent {
|
||||
t.Fatalf("second device = %+v", second)
|
||||
}
|
||||
for _, d := range envDevices(t, env, session).Devices {
|
||||
if d.IsCurrent {
|
||||
t.Fatalf("device %d is current for the owner's own session", d.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("revoke", func(t *testing.T) {
|
||||
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", tvToken, nil)
|
||||
rec := env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(tvID), 10), session, nil)
|
||||
if rec.Body.String() != `{"message":"Device revoked successfully","success":true}` {
|
||||
t.Fatalf("revoke body = %s", rec.Body.String())
|
||||
}
|
||||
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", tvToken, nil)
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(tvID), 10), session, nil)
|
||||
rec = env.expect(t, http.StatusNotFound, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(foreignID), 10), session, nil)
|
||||
if rec.Body.String() != `{"error":"Device not found"}` {
|
||||
t.Fatalf("foreign revoke body = %s", rec.Body.String())
|
||||
}
|
||||
// The id constraint keeps a non-numeric id away from the handler,
|
||||
// which would answer 401 without a session.
|
||||
if rec := env.call(t, http.MethodDelete, "/_user/api/v1/devices/abc", "", nil); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("DELETE /devices/abc = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var row models.DeviceAuthSession
|
||||
if err := env.gdb.Where("id = ?", tvID).Take(&row).Error; err != nil || row.Status != models.DeviceStatusRevoked {
|
||||
t.Fatalf("revoked row = %+v %v", row, err)
|
||||
}
|
||||
for _, d := range envDevices(t, env, session).Devices {
|
||||
if d.ID == tvID {
|
||||
t.Fatal("a revoked device is still listed")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("revoke_after_refresh", func(t *testing.T) {
|
||||
deskToken, deskJTI, deskID := envDevice(t, env, session, "Desktop")
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/refresh", deskToken, nil)
|
||||
var refreshed struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &refreshed); err != nil || refreshed.Token == "" {
|
||||
t.Fatalf("refresh %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
_, _, _, newJTI, err := bouncer.VerifyClaims(refreshed.Token, sessionSecret)
|
||||
if err != nil || newJTI == deskJTI {
|
||||
t.Fatalf("refreshed jti %q err %v", newJTI, err)
|
||||
}
|
||||
var row models.DeviceAuthSession
|
||||
if err := env.gdb.Where("id = ?", deskID).Take(&row).Error; err != nil || row.JWTJTI == nil || *row.JWTJTI != newJTI {
|
||||
t.Fatalf("row after refresh = %+v %v, want jti %s", row, err, newJTI)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", refreshed.Token, nil)
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(deskID), 10), session, nil)
|
||||
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", deskToken, nil)
|
||||
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", refreshed.Token, nil)
|
||||
env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_user/api/v1/refresh", refreshed.Token, nil)
|
||||
// The owner's own session is untouched.
|
||||
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
|
||||
})
|
||||
}
|
||||
95
frontend_permission_catalog_test.go
Normal file
95
frontend_permission_catalog_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"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/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
const acmeShopOrders = "acme.shop.orders"
|
||||
|
||||
// acmeShopPermissions declares a code of its own and acme.blog's manage code
|
||||
// again, under another label: activated after acme.blog, its duplicate is
|
||||
// dropped.
|
||||
type acmeShopPermissions struct{}
|
||||
|
||||
func (acmeShopPermissions) ID() string { return "acme.shop" }
|
||||
func (acmeShopPermissions) Requires() []string { return nil }
|
||||
func (acmeShopPermissions) Register(*backpack.App) error { return nil }
|
||||
func (acmeShopPermissions) Boot(*backpack.App) error { return nil }
|
||||
func (acmeShopPermissions) FrontendPermissions() []pact.FrontendPermission {
|
||||
return []pact.FrontendPermission{
|
||||
{Code: acmeBlogManage, Label: "Shop's copy", Tab: "Shop"},
|
||||
{Code: acmeShopOrders, Label: "Manage orders", Tab: "Shop"},
|
||||
{Code: " ", Label: "Empty code", Tab: "Shop"},
|
||||
}
|
||||
}
|
||||
|
||||
func init() { party.Register(acmeShopPermissions{}) }
|
||||
|
||||
// TestFrontendPermissionCatalog (D-09) extends the smoke: with two declaring
|
||||
// plugins the editors offer the seeded rows, then the first plugin's codes,
|
||||
// then the second's, a code the first already declared and an empty code
|
||||
// left out; a group saved with a declared code keeps it, and the member's
|
||||
// grants (what user.permission checks) include it. Skips under -short.
|
||||
func TestFrontendPermissionCatalog(t *testing.T) {
|
||||
env := newAdminEnvWith(t, []string{"acme.blog", "acme.shop"})
|
||||
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'Seeded label', 'Blog') ON CONFLICT (code) DO NOTHING`, acmeBlogSeeded).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var seeded int64
|
||||
if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_frontend_permissions`).Scan(&seeded).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := env.admin(t, permAccessUsers, permAccessGroups)
|
||||
|
||||
t.Run("second plugin's duplicate dropped", func(t *testing.T) {
|
||||
for _, path := range []string{adminUsersPath, adminGroupsPath} {
|
||||
options := env.permissionOptions(t, token, path)
|
||||
declared := options[seeded:]
|
||||
var codes []string
|
||||
for _, o := range declared {
|
||||
codes = append(codes, o.Code)
|
||||
}
|
||||
if len(declared) != 2 || declared[len(declared)-2].Code != acmeBlogManage || declared[len(declared)-2].Label != "Manage blog posts" ||
|
||||
declared[len(declared)-1] != (offeredPermission{Code: acmeShopOrders, Label: "Manage orders", Tab: "Shop"}) {
|
||||
t.Fatalf("%s declared options = %+v", path, declared)
|
||||
}
|
||||
seen := map[string]int{}
|
||||
for _, o := range options {
|
||||
seen[o.Code]++
|
||||
if o.Code == "" || o.Code == " " {
|
||||
t.Fatalf("%s offers an empty code", path)
|
||||
}
|
||||
}
|
||||
if seen[acmeBlogManage] != 1 || seen[acmeShopOrders] != 1 {
|
||||
t.Fatalf("%s codes offered %v (declared %v)", path, seen, codes)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("group saved with a declared code grants it", func(t *testing.T) {
|
||||
stamp := adminStamp()
|
||||
group := env.seedGroup(t, "Orders "+stamp, "orders-"+stamp)
|
||||
member := env.seedUser(t, "orders", nil)
|
||||
env.join(t, member, group)
|
||||
env.expect(t, http.StatusOK, http.MethodPut, groupPath(group.ID), token, map[string]any{
|
||||
"name": group.Name, "code": *group.Code, "permissions": map[string]int{acmeShopOrders: 1},
|
||||
})
|
||||
var saved models.UserGroup
|
||||
if err := env.gdb.First(&saved, group.ID).Error; err != nil || saved.Permissions == nil || !strings.Contains(*saved.Permissions, `"`+acmeShopOrders+`"`) {
|
||||
t.Fatalf("saved permissions = %v, %v", saved.Permissions, err)
|
||||
}
|
||||
grants, err := classes.UserPermissionGrants(context.Background(), env.gdb, member.ID)
|
||||
if err != nil || !grants[acmeShopOrders] {
|
||||
t.Fatalf("member grants = %v, %v", grants, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -3,7 +3,7 @@ module git.golem15.com/golem15/sm-user-plugin
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
git.golem15.com/golem15/summercms v0.4.4
|
||||
git.golem15.com/golem15/summercms v0.5.0
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@ plugin:
|
||||
impersonate_user: Impersonate Users
|
||||
manage_privileged_groups: Manage privileged group membership
|
||||
manage_backend_links: Link frontend accounts to administrators
|
||||
reset_two_factor: Reset two-factor authentication of users
|
||||
users:
|
||||
menu_label: Users
|
||||
new_user: New User
|
||||
@@ -52,6 +53,9 @@ users:
|
||||
unsuspend_user: Unsuspend user
|
||||
unsuspend_confirm: Unsuspend this user?
|
||||
unsuspend_success: User has been unsuspended.
|
||||
reset_two_factor: Reset 2FA
|
||||
reset_two_factor_confirm: Remove every second factor of this user? Their authenticator apps, security keys, recovery codes and trusted devices stop working, and they sign in with the password alone until they set up two-factor authentication again.
|
||||
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_group_forbidden: You do not have permission to change membership of privileged groups. Nothing was saved.
|
||||
@@ -106,6 +110,8 @@ organisation:
|
||||
slug_invalid: The slug may only contain letters, numbers, dashes and underscores.
|
||||
description: Description
|
||||
avatar: Avatar
|
||||
logo: Logo
|
||||
logo_comment: Shown wherever the site displays the organisation.
|
||||
members: Members
|
||||
new: New Organisation
|
||||
list_empty: No organisations yet. Create the first one with New Organisation.
|
||||
@@ -136,3 +142,10 @@ backend_link:
|
||||
remove_success: The frontend account is unlinked.
|
||||
unknown_user: No active frontend user has this email.
|
||||
already_linked: This administrator or this frontend user is already linked.
|
||||
api_tokens:
|
||||
scope_read: Read
|
||||
scope_read_description: Read your account and the data the site lets you see.
|
||||
scope_write: Write
|
||||
scope_write_description: Change your account and the data the site lets you edit.
|
||||
scope_ai: AI
|
||||
scope_ai_description: Use the site's AI features on your behalf.
|
||||
|
||||
@@ -9,6 +9,7 @@ plugin:
|
||||
impersonate_user: Wcielaj się w użytkowników
|
||||
manage_privileged_groups: Zarządzaj członkostwem w grupach uprzywilejowanych
|
||||
manage_backend_links: Łącz konta na stronie z administratorami
|
||||
reset_two_factor: Resetowanie uwierzytelniania dwuskładnikowego użytkowników
|
||||
users:
|
||||
menu_label: Użytkownicy
|
||||
new_user: Nowy użytkownik
|
||||
@@ -52,6 +53,9 @@ users:
|
||||
unsuspend_user: Odblokuj użytkownika
|
||||
unsuspend_confirm: Odblokować tego użytkownika?
|
||||
unsuspend_success: Udało się odblokować użytkownika.
|
||||
reset_two_factor: Resetuj 2FA
|
||||
reset_two_factor_confirm: Usunąć wszystkie drugie składniki tego użytkownika? Jego aplikacje uwierzytelniające, klucze bezpieczeństwa, kody odzyskiwania i zaufane urządzenia przestaną działać, a do czasu ponownego włączenia uwierzytelniania dwuskładnikowego będzie logować się samym hasłem.
|
||||
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_group_forbidden: Nie masz uprawnień do zmiany członkostwa w grupach uprzywilejowanych. Nic nie zostało zapisane.
|
||||
@@ -106,6 +110,8 @@ organisation:
|
||||
slug_invalid: Identyfikator może zawierać tylko litery, cyfry, myślniki i podkreślenia.
|
||||
description: Opis
|
||||
avatar: Awatar
|
||||
logo: Logo
|
||||
logo_comment: Wyświetlane wszędzie, gdzie strona pokazuje organizację.
|
||||
members: Członkowie
|
||||
new: Nowa organizacja
|
||||
list_empty: Brak organizacji. Utwórz pierwszą przyciskiem Nowa organizacja.
|
||||
@@ -136,3 +142,10 @@ backend_link:
|
||||
remove_success: Konto na stronie zostało odłączone.
|
||||
unknown_user: Żaden aktywny użytkownik strony nie ma tego emaila.
|
||||
already_linked: Ten administrator albo ten użytkownik strony jest już połączony.
|
||||
api_tokens:
|
||||
scope_read: Odczyt
|
||||
scope_read_description: Odczyt Twojego konta i danych, które strona pozwala Ci oglądać.
|
||||
scope_write: Zapis
|
||||
scope_write_description: Zmiany na Twoim koncie i w danych, które strona pozwala Ci edytować.
|
||||
scope_ai: AI
|
||||
scope_ai_description: Korzystanie w Twoim imieniu z funkcji AI strony.
|
||||
|
||||
273
login_gate_smoke_test.go
Normal file
273
login_gate_smoke_test.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/postcard"
|
||||
)
|
||||
|
||||
// gateFunc adapts a function to classes.LoginGate.
|
||||
type gateFunc func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error)
|
||||
|
||||
func (f gateFunc) CheckLogin(ctx context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
return f(ctx, a)
|
||||
}
|
||||
|
||||
// TestLoginGatesSmoke pins the registry: nil and empty allow, the first
|
||||
// non-nil result or error wins and stops the chain.
|
||||
func TestLoginGatesSmoke(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var nilGates *classes.LoginGates
|
||||
if res, err := nilGates.Check(ctx, classes.LoginAttempt{}); res != nil || err != nil {
|
||||
t.Fatalf("nil registry = %v, %v", res, err)
|
||||
}
|
||||
empty := classes.NewLoginGates()
|
||||
if res, err := empty.Check(ctx, classes.LoginAttempt{}); res != nil || err != nil {
|
||||
t.Fatalf("empty registry = %v, %v", res, err)
|
||||
}
|
||||
|
||||
want := &classes.LoginGateResult{Body: map[string]any{"two_factor_required": true}}
|
||||
calls := 0
|
||||
g := classes.NewLoginGates()
|
||||
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
calls++
|
||||
return nil, nil
|
||||
}))
|
||||
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
calls++
|
||||
return want, nil
|
||||
}))
|
||||
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
calls++
|
||||
return nil, nil
|
||||
}))
|
||||
res, err := g.Check(ctx, classes.LoginAttempt{})
|
||||
if err != nil || res != want {
|
||||
t.Fatalf("A(nil) then B(result) = %v, %v", res, err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("gates called %d times, want 2 (the chain stops at the result)", calls)
|
||||
}
|
||||
|
||||
boom := errors.New("gate failed")
|
||||
later := 0
|
||||
e := classes.NewLoginGates()
|
||||
e.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return nil, boom }))
|
||||
e.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
later++
|
||||
return nil, nil
|
||||
}))
|
||||
if res, err := e.Check(ctx, classes.LoginAttempt{}); res != nil || !errors.Is(err, boom) {
|
||||
t.Fatalf("erroring gate = %v, %v", res, err)
|
||||
}
|
||||
if later != 0 {
|
||||
t.Fatal("a gate after the erroring one was called")
|
||||
}
|
||||
}
|
||||
|
||||
// loginEvents records every classes.LoginEvent fired on app.
|
||||
type loginEvents struct {
|
||||
mu sync.Mutex
|
||||
got []classes.LoginEvent
|
||||
}
|
||||
|
||||
func listenLoginEvents(app *backpack.App, owner string) *loginEvents {
|
||||
rec := &loginEvents{}
|
||||
app.Events.Listen[*classes.LoginEvent](owner, func(_ context.Context, e *classes.LoginEvent) error {
|
||||
rec.mu.Lock()
|
||||
rec.got = append(rec.got, *e)
|
||||
rec.mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
return rec
|
||||
}
|
||||
|
||||
func (l *loginEvents) count() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.got)
|
||||
}
|
||||
|
||||
// gateApp is sessionApp with a published login-gate registry (bootPlugin
|
||||
// runs Boot only, so the test publishes what Register would).
|
||||
func gateApp(t *testing.T) (*backpack.App, *classes.LoginGates) {
|
||||
t.Helper()
|
||||
app, _ := sessionApp(t, "0s")
|
||||
gates := classes.NewLoginGates()
|
||||
if err := app.Publish(gates); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app, gates
|
||||
}
|
||||
|
||||
func decodeBody(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("decode %s: %v", raw, err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// TestLoginGateSmoke drives POST /login through a registered gate.
|
||||
func TestLoginGateSmoke(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres (testcontainers)")
|
||||
}
|
||||
_, gdb := sessionApp(t, "0s")
|
||||
|
||||
t.Run("no_gate", func(t *testing.T) {
|
||||
app, _ := gateApp(t)
|
||||
events := listenLoginEvents(app, "test.login-gate.no-gate")
|
||||
user := insertUser(t, gdb, "gate-none@example.test", "correct-horse")
|
||||
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-none@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeBody(t, rec.Body.Bytes())
|
||||
if tok, _ := body["token"].(string); tok == "" {
|
||||
t.Fatalf("no token: %s", rec.Body.String())
|
||||
}
|
||||
if _, ok := body["user"].(map[string]any); !ok {
|
||||
t.Fatalf("no user: %s", rec.Body.String())
|
||||
}
|
||||
if events.count() != 1 {
|
||||
t.Fatalf("login events = %d, want 1", events.count())
|
||||
}
|
||||
if e := events.got[0]; e.Source != classes.LoginSourcePassword || e.User == nil || e.User.ID != user.ID {
|
||||
t.Fatalf("event = %+v", e)
|
||||
}
|
||||
})
|
||||
|
||||
challengeBody := map[string]any{
|
||||
"two_factor_required": true,
|
||||
"challenge_token": strings.Repeat("x", 64),
|
||||
"available_methods": []any{"totp"},
|
||||
"expires_at": "2026-10-21T12:00:00+00:00",
|
||||
}
|
||||
|
||||
t.Run("challenge", func(t *testing.T) {
|
||||
app, gates := gateApp(t)
|
||||
events := listenLoginEvents(app, "test.login-gate.challenge")
|
||||
var seen classes.LoginAttempt
|
||||
gates.Add(gateFunc(func(_ context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
seen = a
|
||||
return &classes.LoginGateResult{Status: http.StatusOK, Body: challengeBody}, nil
|
||||
}))
|
||||
insertUser(t, gdb, "gate-challenge@example.test", "correct-horse")
|
||||
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login",
|
||||
`{"email":"gate-challenge@example.test","password":"correct-horse","trusted_device_token":"td-123"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeBody(t, rec.Body.Bytes())
|
||||
if !reflect.DeepEqual(body, challengeBody) {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
if _, ok := body["token"]; ok {
|
||||
t.Fatal("a challenged login answered a token")
|
||||
}
|
||||
if seen.Source != classes.LoginSourcePassword || seen.User == nil || seen.Request == nil {
|
||||
t.Fatalf("attempt = %+v", seen)
|
||||
}
|
||||
if seen.Fields["trusted_device_token"] != "td-123" {
|
||||
t.Fatalf("fields = %#v", seen.Fields)
|
||||
}
|
||||
if events.count() != 0 {
|
||||
t.Fatalf("a challenged login fired %d login events", events.count())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refusal", func(t *testing.T) {
|
||||
app, gates := gateApp(t)
|
||||
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
return &classes.LoginGateResult{Status: http.StatusUnauthorized, Body: map[string]any{"error": "Invalid or expired challenge."}}, nil
|
||||
}))
|
||||
insertUser(t, gdb, "gate-refusal@example.test", "correct-horse")
|
||||
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-refusal@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Invalid or expired challenge."}` {
|
||||
t.Fatalf("refusal = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gate_error", func(t *testing.T) {
|
||||
app, gates := gateApp(t)
|
||||
events := listenLoginEvents(app, "test.login-gate.error")
|
||||
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
return nil, errors.New("challenge store down")
|
||||
}))
|
||||
insertUser(t, gdb, "gate-error@example.test", "correct-horse")
|
||||
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-error@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("gate error = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), `"token"`) {
|
||||
t.Fatalf("gate error answered a token: %s", rec.Body.String())
|
||||
}
|
||||
if events.count() != 0 {
|
||||
t.Fatal("a failed gate fired a login event")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("soft_deleted_stays", func(t *testing.T) {
|
||||
app, gates := gateApp(t)
|
||||
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
return &classes.LoginGateResult{Body: challengeBody}, nil
|
||||
}))
|
||||
mail := &captureMail{}
|
||||
if err := app.Publish[postcard.Mailer](mail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := insertUser(t, gdb, "gate-soft@example.test", "correct-horse")
|
||||
if err := gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-soft@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusOK || decodeBody(t, rec.Body.Bytes())["two_factor_required"] != true {
|
||||
t.Fatalf("challenge = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var still models.User
|
||||
if err := gdb.Unscoped().First(&still, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !still.DeletedAt.Valid {
|
||||
t.Fatal("a challenged login restored the deactivated account")
|
||||
}
|
||||
if len(mail.got) != 0 {
|
||||
t.Fatalf("a challenged login sent %v", mail.got)
|
||||
}
|
||||
|
||||
// Without a gate the same login restores the account, as before.
|
||||
plain, _ := gateApp(t)
|
||||
plainMail := &captureMail{}
|
||||
if err := plain.Publish[postcard.Mailer](plainMail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = postJSON(t, controllers.Login(plain), "/_user/api/v1/login", `{"email":"gate-soft@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("restore = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var restored models.User
|
||||
if err := gdb.Unscoped().First(&restored, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.DeletedAt.Valid {
|
||||
t.Fatal("login without a gate did not restore the account")
|
||||
}
|
||||
if len(plainMail.got) != 1 || !strings.HasPrefix(plainMail.got[0], "golem15.user::mail.reactivate") {
|
||||
t.Fatalf("reactivate mails = %v", plainMail.got)
|
||||
}
|
||||
})
|
||||
}
|
||||
61
mcp_guard_client_ip_test.go
Normal file
61
mcp_guard_client_ip_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
// TestUserMCPGuardClientIP pins plan 08's client address on the MCP
|
||||
// personal-token path (C-IN-04): MCPResolvePersonalToken stamps
|
||||
// last_used_ip from towel.ClientIP, as the api_token guard does, and leaves
|
||||
// the column as it was when the context carries no address. Postgres only:
|
||||
// skipped under -short.
|
||||
func TestUserMCPGuardClientIP(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
env := newAdminEnv(t)
|
||||
resolver, ok := env.app.Lookup[pact.MCPPersonalTokenResolver]()
|
||||
if !ok {
|
||||
t.Fatal("no personal token resolver published")
|
||||
}
|
||||
user := env.seedUser(t, "mcp-ip", nil)
|
||||
secret, token := env.mintUserToken(t, user, "read")
|
||||
lastIP := func(t *testing.T) *string {
|
||||
t.Helper()
|
||||
var row models.APIToken
|
||||
if err := env.gdb.Where("id = ?", token.ID).First(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row.LastUsedIP
|
||||
}
|
||||
|
||||
t.Run("an address on the context is stamped", func(t *testing.T) {
|
||||
subject, matched, err := resolver.MCPResolvePersonalToken(towel.WithClientIP(context.Background(), "203.0.113.44"), secret)
|
||||
if err != nil || !matched || subject.TokenID != token.ID {
|
||||
t.Fatalf("resolve = %+v, %v, %v", subject, matched, err)
|
||||
}
|
||||
if ip := lastIP(t); ip == nil || *ip != "203.0.113.44" {
|
||||
t.Fatalf("last_used_ip = %v, want 203.0.113.44", ip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no address leaves the column as it was", func(t *testing.T) {
|
||||
if _, matched, err := resolver.MCPResolvePersonalToken(context.Background(), secret); err != nil || !matched {
|
||||
t.Fatalf("resolve = %v, %v", matched, err)
|
||||
}
|
||||
if ip := lastIP(t); ip == nil || *ip != "203.0.113.44" {
|
||||
t.Fatalf("last_used_ip after a resolve without an address = %v, want it unchanged", ip)
|
||||
}
|
||||
if _, _, err := resolver.MCPResolvePersonalToken(towel.WithClientIP(context.Background(), " "), secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ip := lastIP(t); ip == nil || *ip != "203.0.113.44" {
|
||||
t.Fatalf("last_used_ip after a blank address = %v, want it unchanged", ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -153,11 +153,11 @@ func TestUserMCPGuardSmoke(t *testing.T) {
|
||||
}
|
||||
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/token/me", freshUserToken(t, user), nil)
|
||||
|
||||
// Revoking the token through the API fires revoked; the resolver then
|
||||
// refuses it.
|
||||
// Revoking the token through the API fires revoked for that token only
|
||||
// (TokenID); the resolver then refuses it.
|
||||
spy.reset()
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/tokens/"+strconv.FormatUint(uint64(readToken.ID), 10), freshUserToken(t, user), nil)
|
||||
if !spy.has("user", user.ID, pact.MCPSubjectRevoked) {
|
||||
if !spy.hasEvent(pact.MCPSubjectChanged{Guard: "user", UserID: user.ID, Kind: pact.MCPSubjectRevoked, TokenID: readToken.ID}) {
|
||||
t.Fatalf("revoke fired %+v", spy.events)
|
||||
}
|
||||
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, readSecret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {
|
||||
|
||||
117
mcp_guard_test.go
Normal file
117
mcp_guard_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// TestUserMCPGuard (D-15, D-16, D-35) extends TestUserMCPGuardSmoke: the
|
||||
// requirement parser refuses a user.* middleware it does not know and a
|
||||
// scope or permission middleware without its name; a soft-deleted user is
|
||||
// neither allowed nor dispatched and its personal tokens stop resolving, as
|
||||
// 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.
|
||||
func TestUserMCPGuard(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
ctx := context.Background()
|
||||
guards, _ := env.app.Lookup[*pact.MCPGuards]()
|
||||
guard, ok := guards.Lookup("user")
|
||||
if !ok {
|
||||
t.Fatal("no user guard")
|
||||
}
|
||||
resolver, _ := env.app.Lookup[pact.MCPPersonalTokenResolver]()
|
||||
|
||||
t.Run("requirement parser refusals", func(t *testing.T) {
|
||||
for _, mw := range [][]string{{"user.api_token", "user.unknown"}, {"user.api_token", "user.scope:"}, {"user.api_token", "user.permission:"}} {
|
||||
if _, err := guard.MCPRequirements(mw); err == nil {
|
||||
t.Fatalf("%v accepted", mw)
|
||||
}
|
||||
}
|
||||
if req, err := guard.MCPRequirements([]string{"throttle:user-api", "user.api_token", "user.scope:read", "user.scope:read"}); err != nil || len(req.Scopes) != 1 {
|
||||
t.Fatalf("duplicate scope = %+v, %v", req, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("soft-deleted user", func(t *testing.T) {
|
||||
user := env.seedUser(t, "mcp-gone", nil)
|
||||
secret, _ := env.mintUserToken(t, user, "read")
|
||||
subject := pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: []string{"read"}}
|
||||
if ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{}, nil); err != nil || !ok {
|
||||
t.Fatalf("before deletion = %v, %v", ok, err)
|
||||
}
|
||||
if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{}, nil); err != nil || ok {
|
||||
t.Fatalf("after deletion Allows = %v, %v", ok, err)
|
||||
}
|
||||
if _, err := guard.MCPDispatch(ctx, subject, http.MethodGet); err == nil {
|
||||
t.Fatal("a deleted user was dispatched")
|
||||
}
|
||||
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, secret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {
|
||||
t.Fatalf("a deleted user's token = %v, %v", matched, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired token", func(t *testing.T) {
|
||||
user := env.seedUser(t, "mcp-expired", nil)
|
||||
secret, token := env.mintUserToken(t, user, "read")
|
||||
if err := env.gdb.Model(&models.APIToken{}).Where("id = ?", token.ID).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, secret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {
|
||||
t.Fatalf("an expired token = %v, %v", matched, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dispatch credential against user.scope", func(t *testing.T) {
|
||||
user := env.seedUser(t, "mcp-scope", nil)
|
||||
me := func(scopes ...string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
d, err := guard.MCPDispatch(ctx, pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: scopes}, http.MethodGet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
return rec
|
||||
}
|
||||
if rec := me("read"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"email":"`+user.Email+`"`) {
|
||||
t.Fatalf("read subject = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := me("write"); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Missing required scope: read") {
|
||||
t.Fatalf("write-only subject = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, isToken := mustDispatchCredential(t, guard, user.ID).(*models.APIToken); !isToken {
|
||||
t.Fatal("the dispatch credential is not an in-memory API token")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// mustDispatchCredential returns the credential MCPDispatch builds for a
|
||||
// read subject; it is never saved (ID 0).
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok, ok := d.Credential.(*models.APIToken); !ok || tok.ID != 0 {
|
||||
t.Fatalf("credential = %+v", d.Credential)
|
||||
}
|
||||
return d.Credential
|
||||
}
|
||||
166
member_token_scopes_smoke_test.go
Normal file
166
member_token_scopes_smoke_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
)
|
||||
|
||||
// scopeDeclarer is a plugin value that only declares member token scopes,
|
||||
// for CompileAPITokenScopes; it is never registered.
|
||||
type scopeDeclarer struct {
|
||||
id string
|
||||
scopes []pact.FrontendTokenScope
|
||||
}
|
||||
|
||||
func (d scopeDeclarer) ID() string { return d.id }
|
||||
func (d scopeDeclarer) Requires() []string { return nil }
|
||||
func (d scopeDeclarer) Register(*backpack.App) error { return nil }
|
||||
func (d scopeDeclarer) Boot(*backpack.App) error { return nil }
|
||||
func (d scopeDeclarer) FrontendTokenScopes() []pact.FrontendTokenScope { return d.scopes }
|
||||
|
||||
// acmeServers declares the member token scope servers:write and serves a
|
||||
// route behind user.api_token and user.scope:servers:write. It is
|
||||
// registered for the test binary and activated only by the tests that name
|
||||
// it.
|
||||
type acmeServers struct{}
|
||||
|
||||
func (acmeServers) ID() string { return "acme.servers" }
|
||||
func (acmeServers) Requires() []string { return []string{"golem15.user"} }
|
||||
func (acmeServers) Register(*backpack.App) error { return nil }
|
||||
func (acmeServers) Boot(*backpack.App) error { return nil }
|
||||
|
||||
func (acmeServers) FrontendTokenScopes() []pact.FrontendTokenScope {
|
||||
return []pact.FrontendTokenScope{{Name: "servers:write", Label: "Write servers", Description: "Report server components."}}
|
||||
}
|
||||
|
||||
func (acmeServers) Routes(r pact.Router) error {
|
||||
r.Get("/_servers/api/v1/ping", func(w http.ResponseWriter, _ *http.Request) {
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}, "user.api_token", "user.scope:servers:write")
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { party.Register(acmeServers{}) }
|
||||
|
||||
// TestMemberTokenScopesSmoke (E-2): a duplicate, a redeclared built-in and a
|
||||
// bad name fail the catalog; with a plugin declaring servers:write, POST
|
||||
// /tokens mints it, a route behind user.scope:servers:write accepts it and
|
||||
// refuses a read token, an undeclared scope answers 422, GET /tokens/scopes
|
||||
// lists the built-ins and the declared scope with labels, and a second
|
||||
// application without the plugin refuses the scope over HTTP and through
|
||||
// MintAPITokenFor. The Postgres part skips under -short.
|
||||
func TestMemberTokenScopesSmoke(t *testing.T) {
|
||||
t.Cleanup(classes.ResetDeclaredAPITokenScopes)
|
||||
|
||||
t.Run("catalog validation", func(t *testing.T) {
|
||||
scope := func(name string) []pact.FrontendTokenScope {
|
||||
return []pact.FrontendTokenScope{{Name: name, Label: "Label", Description: "Description"}}
|
||||
}
|
||||
for name, plugins := range map[string][]party.Plugin{
|
||||
"duplicate": {scopeDeclarer{"acme.a", scope("acme:dup")}, scopeDeclarer{"acme.b", scope("acme:dup")}},
|
||||
"built-in": {scopeDeclarer{"acme.a", scope("write")}},
|
||||
"bad name": {scopeDeclarer{"acme.a", scope("Bad Name")}},
|
||||
"empty label": {scopeDeclarer{"acme.a", []pact.FrontendTokenScope{{Name: "acme:x", Description: "Description"}}}},
|
||||
} {
|
||||
if _, err := classes.CompileAPITokenScopes(plugins); err == nil {
|
||||
t.Fatalf("%s: CompileAPITokenScopes succeeded", name)
|
||||
}
|
||||
}
|
||||
catalog, err := classes.CompileAPITokenScopes([]party.Plugin{scopeDeclarer{"acme.a", scope("acme:ok")}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range catalog.Scopes() {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if !slices.Equal(names, []string{"read", "write", "ai", "acme:ok"}) || !catalog.Allowed("acme:ok") || catalog.Allowed("acme:dup") {
|
||||
t.Fatalf("catalog = %v", names)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mint, check, list and discovery", func(t *testing.T) {
|
||||
env := newAdminEnvWith(t, []string{"acme.servers"})
|
||||
user := env.seedUser(t, "member-scopes", nil)
|
||||
session := freshUserToken(t, user)
|
||||
|
||||
mint := func(e *adminEnv, scopes ...string) *http.Response {
|
||||
t.Helper()
|
||||
rec := e.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "agent " + adminStamp(), "scopes": scopes})
|
||||
return rec.Result()
|
||||
}
|
||||
secretOf := func(res *http.Response) string {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if res.StatusCode != http.StatusCreated || json.NewDecoder(res.Body).Decode(&body) != nil || body.Token == "" {
|
||||
t.Fatalf("mint = %d", res.StatusCode)
|
||||
}
|
||||
return body.Token
|
||||
}
|
||||
|
||||
agent := secretOf(mint(env, "servers:write"))
|
||||
env.expect(t, http.StatusOK, http.MethodGet, "/_servers/api/v1/ping", agent, nil)
|
||||
reader := secretOf(mint(env, "read"))
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodGet, "/_servers/api/v1/ping", reader, nil)
|
||||
if !strings.Contains(rec.Body.String(), "Missing required scope: servers:write") {
|
||||
t.Fatalf("read token at the servers route = %s", rec.Body.String())
|
||||
}
|
||||
rec = env.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "nope", "scopes": []string{"servers:nope"}})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The selected scope is invalid.") {
|
||||
t.Fatalf("undeclared scope = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/tokens", session, nil)
|
||||
if !strings.Contains(rec.Body.String(), `"servers:write"`) {
|
||||
t.Fatalf("token list = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
rec = env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/tokens/scopes", session, nil)
|
||||
var listed struct {
|
||||
Data []struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
BuiltIn bool `json:"built_in"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil || len(listed.Data) != 4 {
|
||||
t.Fatalf("scopes = %s (%v)", rec.Body.String(), err)
|
||||
}
|
||||
want := []struct {
|
||||
name, label string
|
||||
builtIn bool
|
||||
}{{"read", "Read", true}, {"write", "Write", true}, {"ai", "AI", true}, {"servers:write", "Write servers", false}}
|
||||
for i, w := range want {
|
||||
got := listed.Data[i]
|
||||
if got.Name != w.name || got.Label != w.label || got.BuiltIn != w.builtIn || got.Description == "" || strings.Contains(got.Description, "::") {
|
||||
t.Fatalf("scope %d = %+v, want %+v", i, got, w)
|
||||
}
|
||||
}
|
||||
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/tokens/scopes", "", nil)
|
||||
|
||||
// A second application without the declaring plugin refuses it.
|
||||
other := newAdminEnv(t)
|
||||
rec = other.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "other", "scopes": []string{"servers:write"}})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The selected scope is invalid.") {
|
||||
t.Fatalf("second application = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, _, err := classes.MintAPITokenFor(other.app, "", user.ID, "other", []string{"servers:write"}, nil, nil); err == nil {
|
||||
t.Fatal("MintAPITokenFor accepted another application's scope")
|
||||
}
|
||||
if _, _, err := classes.MintAPITokenFor(env.app, "", user.ID, "declaring", []string{"servers:write"}, nil, nil); err != nil {
|
||||
t.Fatalf("MintAPITokenFor in the declaring application: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
280
member_token_scopes_test.go
Normal file
280
member_token_scopes_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
usermiddleware "git.golem15.com/golem15/sm-user-plugin/middleware"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
)
|
||||
|
||||
// acmeInventory declares the member token scopes servers:read and
|
||||
// servers:write (E-2's acceptance values) and serves the agent's report
|
||||
// route behind the api token guard and middleware.Scope("servers:write").
|
||||
// It is registered for the test binary and activated only by
|
||||
// TestMemberTokenScopes.
|
||||
type acmeInventory struct{}
|
||||
|
||||
func (acmeInventory) ID() string { return "acme.inventory" }
|
||||
func (acmeInventory) Requires() []string { return []string{"golem15.user"} }
|
||||
func (acmeInventory) Register(*backpack.App) error { return nil }
|
||||
func (acmeInventory) Boot(*backpack.App) error { return nil }
|
||||
|
||||
func (acmeInventory) FrontendTokenScopes() []pact.FrontendTokenScope {
|
||||
return []pact.FrontendTokenScope{
|
||||
{Name: "servers:read", Label: "Read servers", Description: "Look servers up by host and port."},
|
||||
{Name: "servers:write", Label: "Report servers", Description: "Report the components of a server."},
|
||||
}
|
||||
}
|
||||
|
||||
func (acmeInventory) Routes(r pact.Router) error {
|
||||
report := usermiddleware.Scope("servers:write")(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"server": req.PathValue("id"), "reported": true})
|
||||
}))
|
||||
r.Put("/_test/servers/{id}/components/report", report.ServeHTTP, "user.api_token")
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { party.Register(acmeInventory{}) }
|
||||
|
||||
// scopeList is the GET /_user/api/v1/tokens/scopes answer.
|
||||
type scopeList struct {
|
||||
Data []struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
BuiltIn bool `json:"built_in"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// TestMemberTokenScopes pins E-2's acceptance list item by item (each
|
||||
// subtest quotes it) with a plugin declaring servers:read and servers:write,
|
||||
// plus the per-application catalog and the legacy MintAPIToken fallback.
|
||||
// Postgres only: skipped under -short.
|
||||
func TestMemberTokenScopes(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
t.Cleanup(classes.ResetDeclaredAPITokenScopes)
|
||||
env := newAdminEnvWith(t, []string{"acme.inventory"})
|
||||
member := env.seedUser(t, "e2-member", nil)
|
||||
session := freshUserToken(t, member)
|
||||
mint := func(t *testing.T, scopes ...string) *http.Response {
|
||||
t.Helper()
|
||||
return env.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "agent " + adminStamp(), "scopes": scopes}).Result()
|
||||
}
|
||||
secretOf := func(t *testing.T, res *http.Response) string {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if res.StatusCode != http.StatusCreated || json.NewDecoder(res.Body).Decode(&body) != nil || !strings.HasPrefix(body.Token, "usr_") {
|
||||
t.Fatalf("mint = %d, token %q", res.StatusCode, body.Token)
|
||||
}
|
||||
return body.Token
|
||||
}
|
||||
const report = "/_test/servers/1/components/report"
|
||||
|
||||
t.Run(`A plugin that declares servers:write can mint a usr_ token with that scope through POST /tokens, and a route with middleware.Scope("servers:write") accepts it`, func(t *testing.T) {
|
||||
agent := secretOf(t, mint(t, "servers:write"))
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPut, report, agent, map[string]any{"components": []string{"cpu"}})
|
||||
if !strings.Contains(rec.Body.String(), `"reported":true`) {
|
||||
t.Fatalf("report = %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(`A token without the scope gets 403 "Missing required scope: servers:write"`, func(t *testing.T) {
|
||||
reader := secretOf(t, mint(t, "read"))
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodPut, report, reader, map[string]any{})
|
||||
var body struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Message != "Missing required scope: servers:write" {
|
||||
t.Fatalf("403 body = %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An undeclared scope still answers 422 at mint time", func(t *testing.T) {
|
||||
rec := env.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "nope", "scopes": []string{"servers:delete"}})
|
||||
var body struct {
|
||||
Errors map[string][]string `json:"errors"`
|
||||
}
|
||||
if rec.Code != http.StatusUnprocessableEntity || json.Unmarshal(rec.Body.Bytes(), &body) != nil {
|
||||
t.Fatalf("undeclared scope = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := body.Errors["scopes"]; len(got) != 1 || got[0] != "The selected scope is invalid." {
|
||||
t.Fatalf("scopes errors = %v (%s)", got, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Listing returns them in scopes", func(t *testing.T) {
|
||||
name := "listed " + adminStamp()
|
||||
rec := env.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": name, "scopes": []string{"servers:write"}})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("mint = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/tokens", session, nil)
|
||||
var listed struct {
|
||||
Data []struct {
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
|
||||
t.Fatalf("list = %s (%v)", rec.Body.String(), err)
|
||||
}
|
||||
found := false
|
||||
for _, tok := range listed.Data {
|
||||
if tok.Name == name {
|
||||
found = true
|
||||
if len(tok.Scopes) != 1 || tok.Scopes[0] != "servers:write" {
|
||||
t.Fatalf("listed scopes = %v", tok.Scopes)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("token %q not listed: %s", name, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The discovery answer lists the built-ins and the declared scopes with labels", func(t *testing.T) {
|
||||
for locale, builtIn := range map[string][3]string{"en": {"Read", "Write", "AI"}, "pl": {"Odczyt", "Zapis", "AI"}} {
|
||||
rec := env.send(t, http.MethodGet, "/_user/api/v1/tokens/scopes", session, nil, "application/json", map[string]string{"Accept-Language": locale})
|
||||
var got scopeList
|
||||
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &got) != nil || len(got.Data) != 5 {
|
||||
t.Fatalf("%s: scopes = %d %s", locale, rec.Code, rec.Body.String())
|
||||
}
|
||||
want := []struct {
|
||||
name, label string
|
||||
builtIn bool
|
||||
}{
|
||||
{"read", builtIn[0], true}, {"write", builtIn[1], true}, {"ai", builtIn[2], true},
|
||||
{"servers:read", "Read servers", false}, {"servers:write", "Report servers", false},
|
||||
}
|
||||
for i, w := range want {
|
||||
s := got.Data[i]
|
||||
if s.Name != w.name || s.Label != w.label || s.BuiltIn != w.builtIn || s.Description == "" || strings.Contains(s.Description, "::") {
|
||||
t.Fatalf("%s: scope %d = %+v, want %+v", locale, i, s, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/tokens/scopes", "", nil)
|
||||
})
|
||||
|
||||
t.Run("Scopes are scoped to the application", func(t *testing.T) {
|
||||
appB := newAdminEnv(t)
|
||||
rec := appB.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "app b", "scopes": []string{"servers:write"}})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The selected scope is invalid.") {
|
||||
t.Fatalf("app B POST /tokens = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, _, err := classes.MintAPITokenFor(appB.app, "", member.ID, "app b", []string{"servers:write"}, nil, nil); err == nil {
|
||||
t.Fatal("MintAPITokenFor(appB) accepted a scope only app A declares")
|
||||
}
|
||||
if _, _, err := classes.MintAPITokenFor(env.app, "", member.ID, "app a", []string{"servers:write"}, nil, nil); err != nil {
|
||||
t.Fatalf("MintAPITokenFor(appA): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The legacy MintAPIToken uses the process-wide fallback", func(t *testing.T) {
|
||||
if _, _, err := classes.MintAPIToken("", member.ID, "legacy", []string{"servers:write"}, nil, nil); err != nil {
|
||||
t.Fatalf("MintAPIToken(servers:write) after a boot declared it: %v", err)
|
||||
}
|
||||
if _, _, err := classes.MintAPIToken("", member.ID, "legacy", []string{"servers:never"}, nil, nil); err == nil {
|
||||
t.Fatal("MintAPIToken accepted a never-declared scope")
|
||||
}
|
||||
classes.ResetDeclaredAPITokenScopes()
|
||||
if _, _, err := classes.MintAPIToken("", member.ID, "legacy", []string{"servers:write"}, nil, nil); err == nil {
|
||||
t.Fatal("MintAPIToken accepted servers:write after ResetDeclaredAPITokenScopes")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// scopeBootApp is an application with the user plugin's phrase catalog and
|
||||
// the given plugins as the activated list, for the scope Boot step alone.
|
||||
func scopeBootApp(t *testing.T, plugins ...party.Plugin) *backpack.App {
|
||||
t.Helper()
|
||||
app := backpack.New(nil)
|
||||
all := append([]party.Plugin{&Plugin{}}, plugins...)
|
||||
if err := phrasebook.Activate(app, all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Publish(party.Activated(all)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// TestMemberTokenScopesBoot pins the boot refusals of E-2 without a
|
||||
// database: the scope Boot step (bootAPITokenScopes, which Plugin.Boot
|
||||
// runs) fails for a name two plugins declare, naming both, for a plugin
|
||||
// redeclaring a built-in, for a bad name or an empty label, and for a
|
||||
// label naming a missing phrase key; a good declaration publishes the
|
||||
// catalog.
|
||||
func TestMemberTokenScopesBoot(t *testing.T) {
|
||||
t.Cleanup(classes.ResetDeclaredAPITokenScopes)
|
||||
scope := func(name string) []pact.FrontendTokenScope {
|
||||
return []pact.FrontendTokenScope{{Name: name, Label: "Label", Description: "Description"}}
|
||||
}
|
||||
boot := func(plugins ...party.Plugin) error {
|
||||
return (&Plugin{}).bootAPITokenScopes(scopeBootApp(t, plugins...))
|
||||
}
|
||||
|
||||
t.Run("Two plugins declaring the same scope name fails boot", func(t *testing.T) {
|
||||
err := boot(scopeDeclarer{"acme.first", scope("servers:write")}, scopeDeclarer{"acme.second", scope("servers:write")})
|
||||
if err == nil || !strings.Contains(err.Error(), "acme.first") || !strings.Contains(err.Error(), "acme.second") || !strings.Contains(err.Error(), "servers:write") {
|
||||
t.Fatalf("err = %v, want both plugin ids and the name", err)
|
||||
}
|
||||
})
|
||||
for _, builtIn := range []string{"read", "write", "ai"} {
|
||||
t.Run("a plugin redeclaring "+builtIn+" fails boot", func(t *testing.T) {
|
||||
err := boot(scopeDeclarer{"acme.redeclare", scope(builtIn)})
|
||||
if err == nil || !strings.Contains(err.Error(), "built-in") || !strings.Contains(err.Error(), `"`+builtIn+`"`) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("a bad name fails boot", func(t *testing.T) {
|
||||
for _, name := range []string{"Servers", "servers:", "a:b:c", "with space", ""} {
|
||||
if err := boot(scopeDeclarer{"acme.bad", scope(name)}); err == nil {
|
||||
t.Fatalf("%q booted", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("an empty label fails boot", func(t *testing.T) {
|
||||
err := boot(scopeDeclarer{"acme.bad", []pact.FrontendTokenScope{{Name: "servers:write", Label: " ", Description: "Description"}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty label") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("a label naming a missing phrase key fails at Boot", func(t *testing.T) {
|
||||
err := boot(scopeDeclarer{"acme.keys", []pact.FrontendTokenScope{{Name: "servers:write", Label: "acme.keys::lang.missing", Description: "Description"}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing phrase key acme.keys::lang.missing") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("a declared phrase key and a good declaration boot", func(t *testing.T) {
|
||||
app := scopeBootApp(t, langDeclarer{scopeDeclarer{"acme.keys", []pact.FrontendTokenScope{{Name: "servers:write", Label: "acme.keys::lang.servers_write", Description: "Description"}}}})
|
||||
if err := (&Plugin{}).bootAPITokenScopes(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !classes.APITokenScopesFor(app).Allowed("servers:write") {
|
||||
t.Fatal("the published catalog does not allow the declared scope")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// langDeclarer is a scopeDeclarer that also ships a phrase catalog.
|
||||
type langDeclarer struct{ scopeDeclarer }
|
||||
|
||||
func (langDeclarer) LangFS() fs.FS {
|
||||
return fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("servers_write: Report servers\n")}}
|
||||
}
|
||||
@@ -53,9 +53,9 @@ func TestAdminModelContracts(t *testing.T) {
|
||||
if (User{}).MorphName() != `Golem15\User\Models\User` || (Organisation{}).MorphName() != `Golem15\User\Models\Organisation` {
|
||||
t.Fatalf("morph names = %q / %q", (User{}).MorphName(), (Organisation{}).MorphName())
|
||||
}
|
||||
for name, relations := range map[string]int{"user": len((User{}).AttachRelations()), "organisation": len((Organisation{}).AttachRelations())} {
|
||||
if relations != 1 {
|
||||
t.Fatalf("%s attach relations = %d", name, relations)
|
||||
for name, want := range map[string][2]int{"user": {len((User{}).AttachRelations()), 1}, "organisation": {len((Organisation{}).AttachRelations()), 2}} {
|
||||
if want[0] != want[1] {
|
||||
t.Fatalf("%s attach relations = %d, want %d", name, want[0], want[1])
|
||||
}
|
||||
}
|
||||
if rel := (User{}).AttachRelations()[0]; AvatarField != "avatar" || rel.Name != AvatarField || !rel.Public {
|
||||
@@ -64,6 +64,9 @@ func TestAdminModelContracts(t *testing.T) {
|
||||
if rel := (Organisation{}).AttachRelations()[0]; rel.Name != OrganisationAvatarField || !rel.Public {
|
||||
t.Fatalf("organisation avatar relation = %+v", rel)
|
||||
}
|
||||
if rel := (Organisation{}).AttachRelations()[1]; OrganisationLogoField != "logo" || rel.Name != OrganisationLogoField || !rel.Public {
|
||||
t.Fatalf("organisation logo relation = %+v", rel)
|
||||
}
|
||||
|
||||
if got := (User{}).FilterScopes(); !reflect.DeepEqual(got, []string{FilterByGroup}) || FilterByGroup != "filterByGroup" {
|
||||
t.Fatalf("User.FilterScopes = %v", got)
|
||||
|
||||
66
models/device_auth_session.go
Normal file
66
models/device_auth_session.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Device authorization session statuses, PHP's DeviceAuthSession constants.
|
||||
// pending becomes confirmed when a signed-in user authorizes the device,
|
||||
// confirmed becomes used on the device's first poll (which receives the
|
||||
// session token), used becomes revoked when the user revokes the device,
|
||||
// and pending or confirmed become expired once expires_at passes. used and
|
||||
// revoked never become expired.
|
||||
const (
|
||||
DeviceStatusPending = "pending"
|
||||
DeviceStatusConfirmed = "confirmed"
|
||||
DeviceStatusUsed = "used"
|
||||
DeviceStatusExpired = "expired"
|
||||
DeviceStatusRevoked = "revoked"
|
||||
)
|
||||
|
||||
// DeviceAuthSession is one QR or short-code device authorization
|
||||
// (golem15_user_device_auth_sessions). TokenHash is the SHA-256 hex of the
|
||||
// device token (column token, PHP's name); the token itself is never
|
||||
// stored. JWTJTI is the jti of the session token the device received,
|
||||
// followed through refresh.
|
||||
type DeviceAuthSession struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
TokenHash string `gorm:"column:token" json:"-"`
|
||||
ShortCode *string `gorm:"column:short_code" json:"-"`
|
||||
UserID *uint `gorm:"column:user_id"`
|
||||
Status string `gorm:"column:status"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at"`
|
||||
DeviceIP *string `gorm:"column:device_ip"`
|
||||
DeviceUserAgent *string `gorm:"column:device_user_agent"`
|
||||
DeviceName *string `gorm:"column:device_name"`
|
||||
AuthIP *string `gorm:"column:auth_ip"`
|
||||
ConfirmedAt *time.Time `gorm:"column:confirmed_at"`
|
||||
UsedAt *time.Time `gorm:"column:used_at"`
|
||||
SessionID *string `gorm:"column:session_id"`
|
||||
LastActivityAt *time.Time `gorm:"column:last_activity_at"`
|
||||
JWTJTI *string `gorm:"column:jwt_jti" json:"-"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (DeviceAuthSession) TableName() string { return "golem15_user_device_auth_sessions" }
|
||||
|
||||
func (DeviceAuthSession) Fillable() []string {
|
||||
return []string{
|
||||
"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", "jwt_jti",
|
||||
}
|
||||
}
|
||||
|
||||
func (DeviceAuthSession) Hidden() []string { return []string{"token", "short_code", "jwt_jti"} }
|
||||
|
||||
// ExpiredAt reports whether a pending or confirmed session is past
|
||||
// expires_at at now. A used, revoked or expired session, and one without
|
||||
// expires_at, is never newly expired.
|
||||
func (s DeviceAuthSession) ExpiredAt(now time.Time) bool {
|
||||
if s.Status != DeviceStatusPending && s.Status != DeviceStatusConfirmed {
|
||||
return false
|
||||
}
|
||||
return s.ExpiresAt != nil && !s.ExpiresAt.After(now)
|
||||
}
|
||||
|
||||
func init() { Register(DeviceAuthSession{}) }
|
||||
@@ -34,9 +34,15 @@ func (Organisation) MorphName() string { return `Golem15\User\Models\Organisatio
|
||||
// organisation's picture.
|
||||
const OrganisationAvatarField = "avatar"
|
||||
|
||||
// AttachRelations declares the public attachOne avatar (PHP $attachOne).
|
||||
// OrganisationLogoField is the attachOne relation name of the
|
||||
// organisation's logo, the image a site shows wherever it displays the
|
||||
// organisation.
|
||||
const OrganisationLogoField = "logo"
|
||||
|
||||
// AttachRelations declares the public attachOne avatar (PHP $attachOne) and
|
||||
// the public attachOne logo.
|
||||
func (Organisation) AttachRelations() []attach.Relation {
|
||||
return []attach.Relation{{Name: OrganisationAvatarField, Public: true}}
|
||||
return []attach.Relation{{Name: OrganisationAvatarField, Public: true}, {Name: OrganisationLogoField, Public: true}}
|
||||
}
|
||||
|
||||
// Fillable is the PHP $fillable list.
|
||||
|
||||
@@ -22,6 +22,13 @@ fields:
|
||||
mode: image
|
||||
imageWidth: 120
|
||||
imageHeight: 120
|
||||
logo:
|
||||
label: golem15.user::lang.organisation.logo
|
||||
comment: golem15.user::lang.organisation.logo_comment
|
||||
type: fileupload
|
||||
mode: image
|
||||
imageWidth: 240
|
||||
imageHeight: 120
|
||||
members:
|
||||
type: relation-manager
|
||||
relation: members
|
||||
|
||||
211
organisation_logo_test.go
Normal file
211
organisation_logo_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"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/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"gocloud.dev/blob"
|
||||
"gocloud.dev/blob/memblob"
|
||||
)
|
||||
|
||||
// uploadOrganisationFile posts an image to an organisation form's file
|
||||
// field under a form session key.
|
||||
func (e *adminEnv) uploadOrganisationFile(t *testing.T, token string, id uint, field, name, sessionKey string) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, err := mw.CreateFormFile("file_data", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(jpegBytes(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if up := e.send(t, http.MethodPost, organisationPath(id, "/files/"+field), token, buf.Bytes(), mw.FormDataContentType(), map[string]string{cabana.SessionKeyHeader: sessionKey}); up.Code != http.StatusCreated && up.Code != http.StatusOK {
|
||||
t.Fatalf("%s upload = %d %s", field, up.Code, up.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// blobKeysUnder lists the bucket's keys under prefix.
|
||||
func (e *adminEnv) blobKeysUnder(t *testing.T, prefix string) []string {
|
||||
t.Helper()
|
||||
var keys []string
|
||||
it := e.bucket.List(&blob.ListOptions{Prefix: prefix})
|
||||
for {
|
||||
obj, err := it.Next(t.Context())
|
||||
if errors.Is(err, io.EOF) {
|
||||
return keys
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys = append(keys, obj.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// organisationFiles counts an organisation's system_files rows of field.
|
||||
func (e *adminEnv) organisationFiles(t *testing.T, id uint, field string) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := e.gdb.Raw(`SELECT COUNT(*) FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = ?`,
|
||||
(models.Organisation{}).MorphName(), strconv.FormatUint(uint64(id), 10), field).Scan(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestOrganisationLogo pins E-1 (P-38): the Organisations form has a logo
|
||||
// fileupload right after the avatar; a logo uploaded through the admin form
|
||||
// API is stored as a system_files row with field logo; OrganisationLogoFields
|
||||
// answers has_logo, the disk name and a URL, or false and nils without a
|
||||
// logo, falling back to the original's public URL when the thumbnail fails;
|
||||
// the avatar keeps working beside it; and deleting the organisation
|
||||
// removes the logo attachment as it does the avatar, rows and blobs.
|
||||
// Postgres only.
|
||||
func TestOrganisationLogo(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
env := newAdminEnv(t)
|
||||
token := env.admin(t, permAccessUsers)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("the form has a logo fileupload next to the avatar", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/form"), token, nil)
|
||||
var schema struct {
|
||||
Data struct {
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
} `json:"fields"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := -1
|
||||
for i, f := range schema.Data.Fields {
|
||||
if f.Name == "avatar" {
|
||||
at = i
|
||||
}
|
||||
}
|
||||
if at < 0 || at+1 >= len(schema.Data.Fields) || schema.Data.Fields[at+1].Name != models.OrganisationLogoField || schema.Data.Fields[at+1].Type != "fileupload" {
|
||||
t.Fatalf("form fields = %+v, want logo:fileupload right after avatar", schema.Data.Fields)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without a logo the fields answer false and nils", func(t *testing.T) {
|
||||
org := env.seedOrganisation(t, "No Logo "+adminStamp())
|
||||
fields, err := classes.OrganisationLogoFields(ctx, env.app, org)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fields["has_logo"] != false || fields["logo"] != nil || fields["logo_url"] != nil {
|
||||
t.Fatalf("fields = %+v", fields)
|
||||
}
|
||||
if _, ok, err := classes.OrganisationLogoURL(ctx, env.app, org, 64); err != nil || ok {
|
||||
t.Fatalf("OrganisationLogoURL = %v, %v", ok, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload, payload fields, the avatar beside it and the delete", func(t *testing.T) {
|
||||
name := "Logo Org " + adminStamp()
|
||||
org := env.seedOrganisation(t, name)
|
||||
key := adminSessionKey(t)
|
||||
env.uploadOrganisationFile(t, token, org.ID, "avatar", "avatar.jpg", key)
|
||||
env.uploadOrganisationFile(t, token, org.ID, models.OrganisationLogoField, "logo.jpg", key)
|
||||
body, err := json.Marshal(map[string]any{"name": name, "slug": org.Slug})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved := env.send(t, http.MethodPut, organisationPath(org.ID, ""), token, body, "application/json", map[string]string{cabana.SessionKeyHeader: key}); saved.Code != http.StatusOK {
|
||||
t.Fatalf("save = %d %s", saved.Code, saved.Body.String())
|
||||
}
|
||||
if logos, avatars := env.organisationFiles(t, org.ID, "logo"), env.organisationFiles(t, org.ID, "avatar"); logos != 1 || avatars != 1 {
|
||||
t.Fatalf("system_files rows: logo %d, avatar %d, want 1 and 1", logos, avatars)
|
||||
}
|
||||
|
||||
fields, err := classes.OrganisationLogoFields(ctx, env.app, env.organisation(t, org.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disk, _ := fields["logo"].(string)
|
||||
url, _ := fields["logo_url"].(string)
|
||||
if fields["has_logo"] != true || disk == "" || url == "" {
|
||||
t.Fatalf("fields = %+v", fields)
|
||||
}
|
||||
var logoDisk, avatarDisk string
|
||||
if err := env.gdb.Raw(`SELECT disk_name FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = 'logo'`,
|
||||
(models.Organisation{}).MorphName(), strconv.FormatUint(uint64(org.ID), 10)).Scan(&logoDisk).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.gdb.Raw(`SELECT disk_name FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = 'avatar'`,
|
||||
(models.Organisation{}).MorphName(), strconv.FormatUint(uint64(org.ID), 10)).Scan(&avatarDisk).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if disk != logoDisk || disk == avatarDisk {
|
||||
t.Fatalf("logo disk name %q, stored logo %q, avatar %q", disk, logoDisk, avatarDisk)
|
||||
}
|
||||
if strings.Contains(url, avatarDisk) {
|
||||
t.Fatalf("logo URL %q points at the avatar", url)
|
||||
}
|
||||
|
||||
// A thumbnail that cannot be made (the storage fails) falls back to
|
||||
// the original's public URL, under the public path prefix
|
||||
// (RC-IN-03).
|
||||
closed := memblob.OpenBucket(nil)
|
||||
_ = closed.Close()
|
||||
failing := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
||||
if err := failing.Publish(env.gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := attach.Publish(failing, closed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok, err := classes.OrganisationLogoURL(ctx, failing, env.organisation(t, org.ID), 64); err != nil || !ok || got != attach.PublicURL(attach.BlobKey(logoDisk)) {
|
||||
t.Fatalf("failing logo thumbnail = %q %v %v, want %s", got, ok, err, attach.PublicURL(attach.BlobKey(logoDisk)))
|
||||
}
|
||||
|
||||
for _, disk := range []string{logoDisk, avatarDisk} {
|
||||
if keys := env.blobKeysUnder(t, attach.PartitionDirectory(disk)); len(keys) == 0 {
|
||||
t.Fatalf("no blob stored for %s before the delete", disk)
|
||||
}
|
||||
}
|
||||
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, organisationPath(org.ID, ""), token, nil)
|
||||
if logos, avatars := env.organisationFiles(t, org.ID, "logo"), env.organisationFiles(t, org.ID, "avatar"); logos != 0 || avatars != 0 {
|
||||
t.Fatalf("after the delete: logo rows %d, avatar rows %d, want none", logos, avatars)
|
||||
}
|
||||
// The blobs (originals and thumbnails) go once the delete commits
|
||||
// (RC-IN-05).
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for _, disk := range []string{logoDisk, avatarDisk} {
|
||||
for {
|
||||
keys := env.blobKeysUnder(t, attach.PartitionDirectory(disk))
|
||||
if len(keys) == 0 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("blobs left after the delete: %v", keys)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
153
password_changed_smoke_test.go
Normal file
153
password_changed_smoke_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"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"
|
||||
)
|
||||
|
||||
// passwordProbe records the AccessRevokedEvents of an app (the D-07
|
||||
// password-change hook) and can make the listener fail.
|
||||
type passwordProbe struct {
|
||||
mu sync.Mutex
|
||||
seen []classes.AccessRevokedEvent
|
||||
fail atomic.Bool
|
||||
}
|
||||
|
||||
func (p *passwordProbe) take() []classes.AccessRevokedEvent {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := p.seen
|
||||
p.seen = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// onePasswordChange asserts exactly one event for id with reason, fired in
|
||||
// a transaction and reporting PasswordChanged.
|
||||
func onePasswordChange(t *testing.T, probe *passwordProbe, id uint, reason string) {
|
||||
t.Helper()
|
||||
seen := probe.take()
|
||||
if len(seen) != 1 {
|
||||
t.Fatalf("%s fired %d events, want 1: %+v", reason, len(seen), seen)
|
||||
}
|
||||
ev := seen[0]
|
||||
if ev.Reason != reason || len(ev.UserIDs) != 1 || ev.UserIDs[0] != id || ev.Tx == nil || !ev.PasswordChanged() {
|
||||
t.Fatalf("%s event = %+v (PasswordChanged %v)", reason, ev, ev.PasswordChanged())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasswordChangedEventSmoke pins the D-07 hook a two-factor plugin
|
||||
// listens to: a password reset, the user's own change and an administrator
|
||||
// setting a password each fire one AccessRevokedEvent that reports
|
||||
// PasswordChanged, inside the write's transaction; the bcrypt rehash on
|
||||
// login fires none; a failing listener rolls the change back. The event
|
||||
// itself predates this test (TestAccessRevocation pins its other fields).
|
||||
func TestPasswordChangedEventSmoke(t *testing.T) {
|
||||
for reason, want := range map[string]bool{
|
||||
classes.AccessRevokedPasswordReset: true,
|
||||
classes.AccessRevokedPasswordChanged: true,
|
||||
classes.AccessRevokedPasswordSetByAdmin: true,
|
||||
classes.AccessRevokedBan: false,
|
||||
"": false,
|
||||
} {
|
||||
if got := (&classes.AccessRevokedEvent{Reason: reason}).PasswordChanged(); got != want {
|
||||
t.Fatalf("PasswordChanged(%q) = %v, want %v", reason, got, want)
|
||||
}
|
||||
}
|
||||
var nilEvent *classes.AccessRevokedEvent
|
||||
if nilEvent.PasswordChanged() {
|
||||
t.Fatal("a nil event reports a password change")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
|
||||
env := newAdminEnv(t)
|
||||
probe := &passwordProbe{}
|
||||
env.app.Events.Listen("test.password_changed", func(_ context.Context, ev *classes.AccessRevokedEvent) error {
|
||||
if probe.fail.Load() {
|
||||
return errors.New("listener refused")
|
||||
}
|
||||
probe.mu.Lock()
|
||||
probe.seen = append(probe.seen, *ev)
|
||||
probe.mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
admin := env.superAdmin(t)
|
||||
ctx := context.Background()
|
||||
session := func(user *models.User) string {
|
||||
return craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "pc-"+strconv.FormatInt(time.Now().UnixNano(), 10), time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
|
||||
}
|
||||
|
||||
t.Run("reset", func(t *testing.T) {
|
||||
user := env.seedUser(t, "pc-reset", nil)
|
||||
code, err := classes.IssueResetCode(ctx, env.gdb, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe.take()
|
||||
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{"code": packed, "password": "pc-new-password", "password_confirmation": "pc-new-password"})
|
||||
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordReset)
|
||||
})
|
||||
|
||||
t.Run("change", func(t *testing.T) {
|
||||
user := env.seedUser(t, "pc-change", func(u *models.User) { u.HasSelfSetPassword = true })
|
||||
probe.take()
|
||||
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{"current_password": "correct-horse", "password": "pc-changed-password", "password_confirmation": "pc-changed-password"})
|
||||
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordChanged)
|
||||
})
|
||||
|
||||
t.Run("admin_set", func(t *testing.T) {
|
||||
user := env.seedUser(t, "pc-admin", nil)
|
||||
probe.take()
|
||||
const set = "pc-admin-password"
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{"name": user.Name, "email": user.Email, "password": set, "password_confirmation": set})
|
||||
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordSetByAdmin)
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{"name": user.Name, "email": user.Email})
|
||||
if seen := probe.take(); len(seen) != 0 {
|
||||
t.Fatalf("an update without a password fired %+v", seen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rehash_fires_none", func(t *testing.T) {
|
||||
user := env.seedUser(t, "pc-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) })
|
||||
probe.take()
|
||||
if status, _ := env.userLogin(t, user.Email, "correct-horse"); status != http.StatusOK {
|
||||
t.Fatalf("login = %d", status)
|
||||
}
|
||||
stored := env.reload(t, user.ID).Password
|
||||
if stored == user.Password || bouncer.NeedsRehash(stored, 11) {
|
||||
t.Fatal("the 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("listener_error_rolls_back", func(t *testing.T) {
|
||||
user := env.seedUser(t, "pc-rollback", func(u *models.User) { u.HasSelfSetPassword = true })
|
||||
probe.fail.Store(true)
|
||||
rec := env.call(t, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{"current_password": "correct-horse", "password": "pc-never-set", "password_confirmation": "pc-never-set"})
|
||||
probe.fail.Store(false)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("change with a failing listener = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if stored := env.reload(t, user.ID).Password; stored != user.Password {
|
||||
t.Fatal("the stored hash changed although the listener failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -95,10 +95,13 @@ func TestPasswordPublicRoutes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unset: Routes error %v", err)
|
||||
}
|
||||
// 18 routes up to v0.1.9, plus GET /backend-link/{code} and POST
|
||||
// /backend-link/confirm (the account link) and GET /token/me.
|
||||
if len(base) != 21 {
|
||||
t.Fatalf("unset: %d routes, want 21", len(base))
|
||||
// 18 routes up to v0.1.9, plus GET /backend-link/{code}, POST
|
||||
// /backend-link/show and POST /backend-link/confirm (the account link),
|
||||
// GET /token/me and GET /tokens/scopes, plus POST /devices/initiate,
|
||||
// GET /devices/status/{token}, POST /devices/authorize, GET /devices and
|
||||
// DELETE /devices/{id} (device authorization).
|
||||
if len(base) != 28 {
|
||||
t.Fatalf("unset: %d routes, want 28", len(base))
|
||||
}
|
||||
seen := 0
|
||||
for _, route := range base {
|
||||
@@ -110,8 +113,8 @@ func TestPasswordPublicRoutes(t *testing.T) {
|
||||
t.Fatalf("unset: found %d of the password routes, want %d", seen, len(passwordRouteKeys))
|
||||
}
|
||||
off := withoutPasswordRoutes(base)
|
||||
if len(off) != 18 {
|
||||
t.Fatalf("base without the password routes has %d routes, want 18", len(off))
|
||||
if len(off) != 25 {
|
||||
t.Fatalf("base without the password routes has %d routes, want 25", len(off))
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
|
||||
36
plugin.go
36
plugin.go
@@ -21,6 +21,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
@@ -59,9 +60,14 @@ type Plugin struct {
|
||||
func (p *Plugin) ID() string { return "golem15.user" }
|
||||
func (p *Plugin) Requires() []string { return nil }
|
||||
|
||||
// Register publishes the login-gate registry (*classes.LoginGates), so a
|
||||
// plugin that requires this one can add its gate in its Boot.
|
||||
func (p *Plugin) Register(app *backpack.App) error {
|
||||
p.app = app
|
||||
return nil
|
||||
if gates, ok := app.Lookup[*classes.LoginGates](); ok && gates != nil {
|
||||
return nil
|
||||
}
|
||||
return app.Publish(classes.NewLoginGates())
|
||||
}
|
||||
|
||||
func (p *Plugin) Boot(app *backpack.App) error {
|
||||
@@ -110,12 +116,40 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
||||
if err := p.bootBackendLinks(app); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.bootAPITokenScopes(app); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.bootMCPGuard(app); err != nil {
|
||||
return err
|
||||
}
|
||||
return classes.RegisterUserAuthorizer(app)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (p *Plugin) bootAPITokenScopes(app *backpack.App) error {
|
||||
activated, _ := app.Lookup[party.Activated]()
|
||||
catalog, err := classes.CompileAPITokenScopes(activated)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tr, ok := app.Lookup[*phrasebook.Translator](); ok && tr != nil {
|
||||
for _, scope := range catalog.Scopes() {
|
||||
for _, text := range []string{scope.Label, scope.Description} {
|
||||
if strings.Contains(text, "::") && !tr.Has(text) {
|
||||
who := scope.PluginID
|
||||
if who == "" {
|
||||
who = "golem15.user"
|
||||
}
|
||||
return fmt.Errorf("golem15.user: member token scope %s (%s) names missing phrase key %s", scope.Name, who, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return app.Publish(catalog)
|
||||
}
|
||||
|
||||
// bootMCPGuard registers the "user" MCP guard provider in the application's
|
||||
// *pact.MCPGuards (publishing the registry when no one did yet) and
|
||||
// publishes the same value as pact.MCPPersonalTokenResolver for the
|
||||
|
||||
117
reset_two_factor_smoke_test.go
Normal file
117
reset_two_factor_smoke_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const permResetTwoFactor = "golem15.users.reset_two_factor"
|
||||
|
||||
// fakeTwoFactorAdmin stands in for a two-factor plugin's service.
|
||||
type fakeTwoFactorAdmin struct {
|
||||
mu sync.Mutex
|
||||
has bool
|
||||
resets []uint
|
||||
txs []*gorm.DB
|
||||
}
|
||||
|
||||
func (f *fakeTwoFactorAdmin) HasTwoFactor(_ context.Context, tx *gorm.DB, _ uint) (bool, error) {
|
||||
if tx == nil {
|
||||
return false, nil
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.has, nil
|
||||
}
|
||||
|
||||
func (f *fakeTwoFactorAdmin) Reset(_ context.Context, tx *gorm.DB, user *models.User) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.resets = append(f.resets, user.ID)
|
||||
f.txs = append(f.txs, tx)
|
||||
f.has = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordAction returns the offered record action named name from the show
|
||||
// response of user id, or nil.
|
||||
func (e *adminEnv) recordAction(t *testing.T, token string, id uint, name string) map[string]any {
|
||||
t.Helper()
|
||||
rec := e.expect(t, http.StatusOK, http.MethodGet, userPath(id, ""), token, nil)
|
||||
var meta struct {
|
||||
Actions []map[string]any `json:"actions"`
|
||||
}
|
||||
if err := json.Unmarshal(adminDecode(t, rec).Meta, &meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, action := range meta.Actions {
|
||||
if action["name"] == name {
|
||||
return action
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestResetTwoFactorSlotSmoke pins the D-15 slot: reset_two_factor is
|
||||
// offered only when a classes.TwoFactorAdmin is published and the user has
|
||||
// a second factor, only to an administrator holding
|
||||
// golem15.users.reset_two_factor, with the danger confirmation, and running
|
||||
// it calls Reset once inside the action's transaction.
|
||||
func TestResetTwoFactorSlotSmoke(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
env := newAdminEnv(t)
|
||||
token := env.admin(t, permAccessUsers, permResetTwoFactor)
|
||||
plain := env.admin(t, permAccessUsers)
|
||||
user := env.seedUser(t, "r2fa", nil)
|
||||
actionPath := userPath(user.ID, "/actions/reset_two_factor")
|
||||
|
||||
// No two-factor plugin: never offered, never runs.
|
||||
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
|
||||
t.Fatal("reset_two_factor offered without a published TwoFactorAdmin")
|
||||
}
|
||||
if rec := env.call(t, http.MethodPost, actionPath, token, map[string]any{}); rec.Code == http.StatusOK {
|
||||
t.Fatalf("reset_two_factor ran without a TwoFactorAdmin: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
svc := &fakeTwoFactorAdmin{has: true}
|
||||
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
action := env.recordAction(t, token, user.ID, "reset_two_factor")
|
||||
if action == nil {
|
||||
t.Fatalf("reset_two_factor not offered: %v", env.offered(t, token, user.ID))
|
||||
}
|
||||
if action["danger"] != true || action["label"] != "Reset 2FA" {
|
||||
t.Fatalf("offered action = %v", action)
|
||||
}
|
||||
|
||||
// An administrator with only access_users neither sees nor runs it.
|
||||
if slices.Contains(env.offered(t, plain, user.ID), "reset_two_factor") {
|
||||
t.Fatal("reset_two_factor offered without its permission")
|
||||
}
|
||||
env.expect(t, http.StatusForbidden, http.MethodPost, actionPath, plain, map[string]any{})
|
||||
if len(svc.resets) != 0 {
|
||||
t.Fatal("a refused administrator reached Reset")
|
||||
}
|
||||
|
||||
env.expect(t, http.StatusOK, http.MethodPost, actionPath, token, map[string]any{})
|
||||
if len(svc.resets) != 1 || svc.resets[0] != user.ID || svc.txs[0] == nil {
|
||||
t.Fatalf("Reset calls = %v (tx %v)", svc.resets, svc.txs)
|
||||
}
|
||||
|
||||
// Nothing left to reset: no longer offered.
|
||||
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
|
||||
t.Fatal("reset_two_factor offered for a user without a second factor")
|
||||
}
|
||||
}
|
||||
10
routes.go
10
routes.go
@@ -40,12 +40,22 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
g.Post("/avatar/remove", controllers.RemoveAvatar(p.app))
|
||||
g.Post("/tokens", controllers.APITokenStore(p.app), "jwt.auth")
|
||||
g.Get("/tokens", controllers.APITokenIndex(p.app), "jwt.auth")
|
||||
g.Get("/tokens/scopes", controllers.APITokenScopes(p.app), "jwt.auth")
|
||||
g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth")
|
||||
g.Where("id", "[0-9]+")
|
||||
g.Get("/backend-link/{code}", controllers.BackendLinkShow(p.app))
|
||||
g.Where("code", "[a-f0-9]{64}")
|
||||
g.Post("/backend-link/show", controllers.BackendLinkShowPost(p.app))
|
||||
g.Post("/backend-link/confirm", controllers.BackendLinkConfirm(p.app), "jwt.auth")
|
||||
g.Get("/token/me", controllers.TokenMe(p.app), "user.api_token", "user.scope:read")
|
||||
// Device authorization (QR and short code). The routes authenticate
|
||||
// inside the handlers, as PHP's controller does.
|
||||
g.Post("/devices/initiate", controllers.DeviceInitiate(p.app))
|
||||
g.Get("/devices/status/{token}", controllers.DeviceStatus(p.app))
|
||||
g.Post("/devices/authorize", controllers.DeviceAuthorize(p.app))
|
||||
g.Get("/devices", controllers.DeviceIndex(p.app))
|
||||
g.Delete("/devices/{id}", controllers.DeviceDestroy(p.app))
|
||||
g.Where("id", "[0-9]+")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
50
updates/202610260001_create_device_auth_sessions.go
Normal file
50
updates/202610260001_create_device_auth_sessions.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// deviceAuthSessionMigrations create golem15_user_device_auth_sessions, the
|
||||
// QR and short-code device authorization sessions, with the PHP plugin's
|
||||
// table and column names (the v2.1.0 to v2.7.1 migrations: short_code, a
|
||||
// nullable user_id and expires_at). The token column holds the SHA-256 hex
|
||||
// of the 64-character device token, never the token itself; jwt_jti is
|
||||
// added: the jti of the session token the device received, so a revoke can
|
||||
// blacklist it. session_id is kept for a Winter import and stays NULL.
|
||||
var deviceAuthSessionMigrations = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610260001_create_device_auth_sessions",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`CREATE TABLE golem15_user_device_auth_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
short_code VARCHAR(9) NULL UNIQUE,
|
||||
user_id INTEGER NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'confirmed', 'used', 'expired', 'revoked')),
|
||||
expires_at TIMESTAMPTZ NULL,
|
||||
device_ip VARCHAR(255) NULL,
|
||||
device_user_agent TEXT NULL,
|
||||
device_name VARCHAR(255) NULL,
|
||||
auth_ip VARCHAR(255) NULL,
|
||||
confirmed_at TIMESTAMPTZ NULL,
|
||||
used_at TIMESTAMPTZ NULL,
|
||||
session_id VARCHAR(255) NULL,
|
||||
last_activity_at TIMESTAMPTZ NULL,
|
||||
jwt_jti VARCHAR(64) NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX golem15_user_device_auth_sessions_user_id_index ON golem15_user_device_auth_sessions (user_id);
|
||||
CREATE INDEX golem15_user_device_auth_sessions_status_index ON golem15_user_device_auth_sessions (status);
|
||||
CREATE INDEX golem15_user_device_auth_sessions_expires_at_index ON golem15_user_device_auth_sessions (expires_at);
|
||||
CREATE INDEX golem15_user_device_auth_sessions_jwt_jti_index ON golem15_user_device_auth_sessions (jwt_jti)`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec("DROP TABLE IF EXISTS golem15_user_device_auth_sessions").Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() { Register(deviceAuthSessionMigrations...) }
|
||||
@@ -53,6 +53,7 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
||||
"202610160002_add_handle_to_users",
|
||||
"202610210001_add_users_email_verified_at",
|
||||
"202610220001_create_golem15_user_backend_links",
|
||||
"202610260001_create_device_auth_sessions",
|
||||
}
|
||||
if len(ids) != len(want) {
|
||||
t.Fatalf("history = %v", ids)
|
||||
@@ -63,9 +64,10 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Backend links, email proof, handle, OAuth identities, the three admin migrations, API tokens, user groups,
|
||||
// jwt blacklist, throttle, extend-users, then organisations.
|
||||
for range 13 {
|
||||
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email proof, handle, OAuth
|
||||
// identities, the three admin migrations, API tokens, user groups, jwt blacklist, throttle, extend-users,
|
||||
// then organisations.
|
||||
for range 14 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -54,8 +54,9 @@ func TestUserGroupsMigration(t *testing.T) {
|
||||
t.Fatalf("users_groups primary key = %q", pk)
|
||||
}
|
||||
|
||||
// Backend links, email verified at, handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
|
||||
for range 9 {
|
||||
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
|
||||
// identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
|
||||
for range 10 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ func TestExtendUsersMigration(t *testing.T) {
|
||||
if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded {
|
||||
t.Fatalf("defaults = %+v", row)
|
||||
}
|
||||
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups,
|
||||
// jwt blacklist, throttle, then extend-users.
|
||||
for range 12 {
|
||||
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
|
||||
// identities, the three admin migrations, API tokens, user groups, jwt blacklist, throttle, then extend-users.
|
||||
for range 13 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -88,8 +88,9 @@ func TestCreateUserThrottleMigration(t *testing.T) {
|
||||
t.Fatalf("missing column %s", col)
|
||||
}
|
||||
}
|
||||
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
|
||||
for range 11 {
|
||||
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
|
||||
// identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
|
||||
for range 12 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -120,8 +121,9 @@ func TestCreateJwtBlacklistMigration(t *testing.T) {
|
||||
t.Fatalf("missing column %s", col)
|
||||
}
|
||||
}
|
||||
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
|
||||
for range 10 {
|
||||
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
|
||||
// identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
|
||||
for range 11 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user