Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfcfea4ed7 | ||
|
|
59cc3bde2e | ||
|
|
dbb3d33b48 | ||
|
|
6f1cb1a774 | ||
|
|
0f545f5c81 | ||
|
|
7ac80c9aea | ||
|
|
3828a65481 | ||
|
|
8bdf2c7d1a | ||
|
|
846087db45 | ||
|
|
a484d0e9a2 | ||
|
|
b6f8055c56 | ||
|
|
4bf44fc6ea | ||
|
|
8e4b863a3f | ||
|
|
15ff9793ba | ||
|
|
930b1587a0 | ||
|
|
358ee5d308 | ||
|
|
aa8396916e | ||
|
|
c3712dc0d4 | ||
|
|
e0b5ffe20d | ||
|
|
b6b7ac7534 | ||
|
|
499b6dc408 | ||
|
|
9986342c23 | ||
|
|
35cf16689d | ||
|
|
a99cb467b7 | ||
|
|
add1f7a806 | ||
|
|
c7635c56e3 | ||
|
|
d6d8f117ea | ||
|
|
c43ba87371 | ||
|
|
174161895e | ||
|
|
78a867bccb | ||
|
|
1fc0ce7ed0 | ||
|
|
7b9faa3419 | ||
| 4c5d3b8935 | |||
| 9335e80342 | |||
| 80b436d3d9 | |||
| ee474401cb | |||
| b82d85f440 | |||
|
|
c0770294a5 | ||
|
|
15b19c7021 | ||
| 569768e657 |
143
README.md
143
README.md
@@ -12,30 +12,38 @@ 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`.
|
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` and `golem15_user_frontend_permissions`, 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` 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.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`.
|
- **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. A `/register` body sets only the `User.RegisterFillable` columns: `name`, `surname`, `email`, `password` (stored as its hash), `preferred_locale` and `marketing_consent`. It cannot set `organisation_id` or `organisation_role` (membership is the administrator's or the application's), `is_onboarded`, `must_change_password`, or the activation, reset, address proof, handle and permission columns; other keys are ignored (they still reach `RegisterEvent.Payload`). `created_ip_address` and `last_ip_address` are server-set from the client address only. 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. Logged-out tokens go to the Postgres `jwt_blacklist`, which a background sweep prunes.
|
- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. 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.
|
||||||
- **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password.
|
- **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password. 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.
|
- **Must-change-password lock.** The `user:require-password-change` console command flags an account. The flag rides on the authenticated principal (`bouncer.Principal.MustChangePassword`) for guards and other plugins to act on, and a successful `POST /change-password` clears it.
|
||||||
- **Login throttling.** Failed logins are counted per user and IP in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time.
|
- **Login throttling.** Failed logins are counted per user and 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.
|
||||||
- **Rate limiting.** Every route runs behind the `user-api` bucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls.
|
- **Rate limiting.** Every route runs behind the `user-api` bucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls.
|
||||||
- **Profile.** `POST /update`, `POST /marketing-consent`, `POST /avatar` and `POST /avatar/remove` (the avatar is stored through the framework's attachment storage).
|
- **Profile.** `POST /update`, `POST /marketing-consent`, `POST /avatar` and `POST /avatar/remove` (the avatar is stored through the framework's attachment storage).
|
||||||
- **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins. An organisation has a name, a slug (filled from the name by `Slugify` when it is left empty), a description, an avatar and its members: the users whose `organisation_id` points at it.
|
- **Deactivation and bans.** Deactivation is self-service and reversible: a password login or a `golem15.sociallogin` sign-in restores a deactivated account and sends the reactivate mail, and an administrator can restore it too. It ends every earlier session JWT and personal API token of the user (`RevokeUserAccess` stamps `tokens_valid_after` and revokes the `user_api_tokens` rows), so the user signs in again and gets a fresh token, while the old API tokens stay revoked. A ban is the lock: password login, social sign-in and `POST /refresh` refuse a banned user, and banning revokes the user's sessions and API tokens. Neither unban nor restore revives a token. 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.
|
||||||
- **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No user API route reads or writes groups, and the user payload still answers `"groups":[]`. Membership is written in one place only: the `groups` field of the admin user form. Apart from that, rows leave `users_groups` only when a user or a group is deleted for good. The admin Users list can filter by group.
|
- **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No user API route reads or writes groups, and the user payload still answers `"groups":[]`. Membership is written in one place only: the `groups` field of the admin user form. Apart from that, rows leave `users_groups` only when a user or a group is deleted for good. The admin Users list can filter by group.
|
||||||
- **Admin screens.** The plugin registers the Users screen with the framework's admin (`pact.HasAdminControllers`, controller ID `golem15.user.users`), the port of the WinterCMS `Users` controller: a list with search, the group, registration date and activated filters and row states (deactivated, banned, not activated); a read-only preview with a status hint; and a form with name, email, a password with confirmation, an optional invitation mail, the user's groups, the organisation, the avatar and the user's frontend permissions. Bulk actions activate, deactivate, restore, ban and unban users; the preview offers activate, unban and unsuspend while they apply. Deactivating is the soft delete; the delete button and the bulk delete remove a user for good, together with the throttle rows, group memberships and attachments. A user created in the admin starts not activated. There is no impersonation and no guest account.
|
- **Admin screens.** The plugin registers the Users screen with the framework's admin (`pact.HasAdminControllers`, controller ID `golem15.user.users`), the port of the WinterCMS `Users` controller: a list with search, the group, registration date and activated filters and row states (deactivated, banned, not activated); a read-only preview with a status hint; and a form with name, email, a password with confirmation, an optional invitation mail, the user's groups, the organisation, the avatar and the user's frontend permissions. Bulk actions activate, deactivate, restore, ban and unban users; the preview offers activate, unban and unsuspend while they apply. Deactivating is the soft delete, and deactivate and ban both end the user's earlier sessions and personal API tokens (see Deactivation and bans); the delete button and the bulk delete remove a user for good, together with the throttle rows, group memberships and attachments. A user created in the admin starts not activated. There is no impersonation and no guest account.
|
||||||
- **User Groups screen.** Controller ID `golem15.user.usergroups`, the port of the WinterCMS `UserGroups` controller, behind `golem15.users.access_groups`: a list with the name, code, creation date and number of members, and a form with the name, the code (letters, digits, underscore and hyphen; unique), a description and the group's frontend permissions as checkboxes, stored in `user_groups.permissions` as a JSON object of code to `1`. A group can be deleted from its form; its memberships go with it and its members stay. The screen never changes who is in a group.
|
- **User Groups screen.** Controller ID `golem15.user.usergroups`, the port of the WinterCMS `UserGroups` controller, behind `golem15.users.access_groups`: a list with the name, code, creation date and number of members, and a form with the name, the code (letters, digits, underscore and hyphen; unique), a description and the group's frontend permissions as checkboxes, stored in `user_groups.permissions` as a JSON object of code to `1`. A group can be deleted from its form; its memberships go with it and its members stay. The screen never changes who is in a group.
|
||||||
- **Organisations screen.** Controller ID `golem15.user.organisations`, the port of the WinterCMS `Organisations` controller, behind `golem15.users.access_users`: a list and a form with the name, slug, description and avatar, and on an existing organisation the Members tab. Adding a member sets that user's `organisation_id` and removing one clears it; a user is never deleted. Only users without an organisation can be added, so a user moves by being removed from one organisation and added to the other (or through the organisation field of the user form). Deleting an organisation clears `organisation_id` and `organisation_role` of its members first.
|
- **Organisations screen.** Controller ID `golem15.user.organisations`, the port of the WinterCMS `Organisations` controller, behind `golem15.users.access_users`: a list and a form with the name, slug, description and avatar, and on an existing organisation the Members tab. Adding a member sets that user's `organisation_id` and removing one clears it; both also clear the user's `organisation_role`, so a role never moves to another organisation or outlives the membership. A user is never deleted. Only users without an organisation can be added, so a user moves by being removed from one organisation and added to the other (or through the organisation field of the user form, where a save that changes the organisation, to another one or to none, clears `organisation_role` unless the same save writes it). Deleting an organisation clears `organisation_id` and `organisation_role` of its members first.
|
||||||
- **Privileged groups.** The groups whose code is listed in `golem15.user.privileged_groups` (default `admin`) are privileged: an application may treat their members as site administrators. Three things therefore need the backend permission `golem15.users.manage_privileged_groups` on top of the screen's own permission, and answer 403 without it, saving nothing. First, membership: adding a privileged group to a user or removing it, on create and on update of the user form. An administrator without the permission sees those groups locked in the field and can still change the user's other groups. Second, the code: creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group. Its name, description and frontend permissions need only `golem15.users.access_groups`. Codes compare case-sensitively, and a group without a code is never privileged. Third, the member's account, which is protected against takeover: changing that user's email or password, and deleting them for good, needs the backend permission `golem15.users.manage_privileged_groups` on top of `golem15.users.access_users`. Without it the request answers 403 and nothing is saved; a bulk delete that contains such a user deletes none of the selected users. Everything else on such a user (name, organisation, avatar, frontend permissions, activate, deactivate, restore, ban, unban, unsuspend) needs only `golem15.users.access_users`. `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs` and `IsPrivilegedMember` expose the rule to other code.
|
- **Privileged groups.** The groups whose code is listed in `golem15.user.privileged_groups` (default `admin`) are privileged: an application may treat their members as site administrators. Three things therefore need the backend permission `golem15.users.manage_privileged_groups` on top of the screen's own permission, and answer 403 without it, saving nothing. First, membership: adding a privileged group to a user or removing it, on create and on update of the user form. An administrator without the permission sees those groups locked in the field and can still change the user's other groups. Second, the code: creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group. Its name, description and frontend permissions need only `golem15.users.access_groups`. Codes compare case-sensitively, and a group without a code is never privileged. Third, the member's account, which is protected against takeover: changing that user's email or password, and deleting them for good, needs the backend permission `golem15.users.manage_privileged_groups` on top of `golem15.users.access_users`. Without it the request answers 403 and nothing is saved; a bulk delete that contains such a user deletes none of the selected users. Everything else on such a user (name, organisation, avatar, frontend permissions, activate, deactivate, restore, ban, unban, unsuspend) needs only `golem15.users.access_users`. `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs` and `IsPrivilegedMember` expose the rule to other code.
|
||||||
- **Frontend permissions.** `golem15_user_frontend_permissions` is the catalogue of permissions a site defines for its users (rows come from the application's migrations or seeds). A user's own choices are stored in `users.permissions` and a group's in `user_groups.permissions`, as a JSON object of code to `1` (allow) or `-1` (deny). `MergedPermissions` combines them as PHP `getMergedPermissions` does: among a user's groups the most positive value wins, then the user's own value replaces the groups'. `HasPermission` and `UserHasPermission` answer whether a code is held: only at exactly `1`, with Winter's `*` wildcards. Neither value appears in the user payload, whose `permissions` key stays `null`.
|
- **Frontend permissions.** `golem15_user_frontend_permissions` is the catalogue of permissions a site defines for its users (rows come from the application's migrations or seeds). A user's own choices are stored in `users.permissions` and a group's in `user_groups.permissions`, as a JSON object of code to `1` (allow) or `-1` (deny). `MergedPermissions` combines them as PHP `getMergedPermissions` does: among a user's groups the most positive value wins, then the user's own value replaces the groups'. `HasPermission` and `UserHasPermission` answer whether a code is held: only at exactly `1`, with Winter's `*` wildcards. Neither value appears in the user payload, whose `permissions` key stays `null`.
|
||||||
- **Last seen.** `users.last_seen` is stamped by `POST /login` and `POST /refresh` through `TouchLastSeen`, at most once per five minutes per user. A failed write is logged and never fails authentication. It is shown in the admin list and is not part of any user payload.
|
- **Last seen.** `users.last_seen` is stamped by `POST /login` and a successful `POST /refresh` through `TouchLastSeen`, at most once per five minutes per user. A failed write is logged and never fails authentication. It is shown in the admin list and is not part of any user payload.
|
||||||
|
- **Address proof.** `users.email_verified_at` records when the account's owner proved control of the address (`User.EmailVerifiedAt`). Exactly two flows write it: the mailed activation code (`POST /activate`, `POST /activate-by-code` and the invitation link, through `VerifyActivationCode`) and a completed reset-code password reset (`POST /reset-password`). `POST /register` in `auto` or `admin` mode, `RegisterUser`, an administrator's create and the admin Activate action, `POST /change-password` and social sign-ins record none. The proof follows the address: an email change through `POST /update` or the admin Users form clears it, while a change of letter case only keeps it. The column is never mass-assigned and is in no user API payload, admin form or list. No migration or code path backfills it: accounts from before v0.1.5 have no proof. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not. A social-login plugin links a verified provider's identity onto an existing account only when it is set.
|
||||||
|
- **Handle.** `users.handle` is a unique nullable `VARCHAR(255)` generated on create (`classes.HandleGenerator`, charset `[a-z0-9._-]`, length 2–255). It is omitted from the user JSON payload (`json:"-"`). `user:backfill-handles` fills existing rows that still have none.
|
||||||
|
- **User lighthouse authorizer.** Boot calls `classes.RegisterUserAuthorizer`: `lighthouse.From` then `Registry.Register("user", …)` if the namespace is free. `classes.AuthorizeUser` allows only `user:{id}` for that user (`lighthouse.ChannelID` on the two-segment name).
|
||||||
- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:<scope>` limits delegated access and `user.permission:<code>` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created.
|
- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:<scope>` limits delegated access and `user.permission:<code>` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created.
|
||||||
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
|
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
|
||||||
- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never). `GetApiArrayEvent` lets other plugins merge keys into the user payload.
|
- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never), so it is untrusted client input. `CreatedEvent` (`classes.CreatedEventName` `user.Created`) fires from API register and admin create. `/register` logs a failing `RegisterEvent` or `CreatedEvent` listener at Error level and continues: a committed registration is never undone or failed by a listener. `DeletingEvent` (`classes.DeletingEventName` `user.Deleting`) fires from `FormAfterDelete` before `ForceDeleteCleanup` / unscoped delete, inside the same transaction. `GetApiArrayEvent` lets other plugins merge keys into the user payload.
|
||||||
- **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload.
|
- **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload.
|
||||||
- **OAuth providers.** `GET /oauth-providers` lists configured social login providers (none yet: it returns an empty list).
|
- **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 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.
|
||||||
|
- **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
|
## Usage
|
||||||
|
|
||||||
@@ -102,15 +110,24 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle
|
|||||||
| `POST /tokens` | `controllers.APITokenStore` (`jwt.auth`) |
|
| `POST /tokens` | `controllers.APITokenStore` (`jwt.auth`) |
|
||||||
| `GET /tokens` | `controllers.APITokenIndex` (`jwt.auth`) |
|
| `GET /tokens` | `controllers.APITokenIndex` (`jwt.auth`) |
|
||||||
| `DELETE /tokens/{id}` | `controllers.APITokenDestroy` (`jwt.auth`) |
|
| `DELETE /tokens/{id}` | `controllers.APITokenDestroy` (`jwt.auth`) |
|
||||||
|
| `GET /backend-link/{code}` | `controllers.BackendLinkShow` |
|
||||||
|
| `POST /backend-link/confirm` | `controllers.BackendLinkConfirm` (`jwt.auth`) |
|
||||||
|
| `GET /token/me` | `controllers.TokenMe` (`user.api_token`, `user.scope:read`) |
|
||||||
|
|
||||||
|
`/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`.
|
||||||
|
|
||||||
### Host-mounted OAuth identities
|
### Host-mounted OAuth identities
|
||||||
|
|
||||||
The host application mounts these constructors on a path it chooses. They are not registered under `/_user/api/v1`. `OAuthIdentitiesOptions.WriteNotFound` is the host's 404 writer so missing, foreign and unknown providers share one body. `OAuthIdentitiesOptions.Providers` defaults to `google`, `facebook` and `github` when empty. The 409 last-method phrase is `golem15.user::lang.oauth.last_method_blocked`.
|
The host application mounts these constructors on a path it chooses. They are not registered under `/_user/api/v1`. `OAuthIdentitiesOptions.WriteNotFound` is the host's 404 writer so missing, foreign and unknown providers share one body. `OAuthIdentitiesOptions.Providers` is the unlink allow-list; when empty it is read on every request from a published `classes.OAuthProviderSource` (its `KnownOAuthProviders`), else it is `google`, `facebook`, `github` and `microsoft`. Unlinking follows the PHP rule: it succeeds when the user has a self-set password (`has_self_set_password`) or another identity remains, and otherwise answers 409 with the phrase `golem15.user::lang.oauth.last_method_blocked` (body unchanged). The guarded delete runs in a transaction that first locks the user's `users` row, so two concurrent unlinks never remove the last sign-in method.
|
||||||
|
|
||||||
| Constructor | Role |
|
| Constructor | Role |
|
||||||
|-------------|------|
|
|-------------|------|
|
||||||
| `controllers.OAuthIdentitiesIndex` | Lists the caller's linked providers as `{provider, linked_at}` only. |
|
| `controllers.OAuthIdentitiesIndex` | Lists the caller's linked providers as `{provider, linked_at}` only. |
|
||||||
| `controllers.OAuthIdentitiesDestroy` | Unlinks one provider for the caller. |
|
| `controllers.OAuthIdentitiesDestroy` | Unlinks one provider for the caller (204), or answers 409 when it is the last sign-in method of a user without a self-set password. |
|
||||||
| `controllers.OAuthIdentitiesOptions` | Host injects `Providers` and `WriteNotFound`. |
|
| `controllers.OAuthIdentitiesOptions` | Host injects `Providers` and `WriteNotFound`. |
|
||||||
|
|
||||||
### Admin
|
### Admin
|
||||||
@@ -122,8 +139,12 @@ The three screens are served by the framework's generic admin API under `<admin
|
|||||||
| Admin controllers | `golem15.user.users`, `golem15.user.usergroups` and `golem15.user.organisations`, from `controllers.AdminControllers` |
|
| Admin controllers | `golem15.user.users`, `golem15.user.usergroups` and `golem15.user.organisations`, from `controllers.AdminControllers` |
|
||||||
| Navigation | main item `user` (order 555) with the side items `users`, `usergroups` and `organisations`, each shown to an administrator who holds its permission |
|
| Navigation | main item `user` (order 555) with the side items `users`, `usergroups` and `organisations`, each shown to an administrator who holds its permission |
|
||||||
| Relation manager | `members` on `golem15.user.organisations` (link and unlink) |
|
| Relation manager | `members` on `golem15.user.organisations` (link and unlink) |
|
||||||
| Bulk actions (Users) | `activate`, `deactivate`, `restore`, `ban`, `unban`, and the built-in delete |
|
| Bulk actions (Users) | `activate`, `deactivate`, `restore`, `ban`, `unban`, and the built-in delete; `deactivate` and `ban` also revoke the selected users' sessions and personal API tokens |
|
||||||
| Record actions (Users) | `activate`, `unban`, `unsuspend` |
|
| Record actions (Users) | `activate`, `unban`, `unsuspend` |
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
On the Administrators screen, `link_frontend_account` links the activated, not deactivated frontend user whose email equals the typed value (letter case and surrounding spaces ignored; no search and no list) to the administrator shown, and records the caller in `linked_by`. An unknown email or an account that is already linked answers 422 on the input. It is offered only while the administrator has no link, and `unlink_frontend_account` only while it has one. The admin's own rule that an administrator cannot act on one whose grants exceed theirs runs first. These two actions skip the frontend user's sign-in proof, which is why their permission has no default role; the My account path always requires it.
|
||||||
|
|
||||||
Backend permissions, all on the tab `golem15.user::lang.plugin.tab`:
|
Backend permissions, all on the tab `golem15.user::lang.plugin.tab`:
|
||||||
|
|
||||||
@@ -134,6 +155,7 @@ Backend permissions, all on the tab `golem15.user::lang.plugin.tab`:
|
|||||||
| `golem15.users.access_settings` | `developer` | Registered for roles that already name it; no screen uses it yet. |
|
| `golem15.users.access_settings` | `developer` | Registered for roles that already name it; no screen uses it yet. |
|
||||||
| `golem15.users.impersonate_user` | `developer` | Registered for roles that already name it; impersonation is not available. |
|
| `golem15.users.impersonate_user` | `developer` | Registered for roles that already name it; impersonation is not available. |
|
||||||
| `golem15.users.manage_privileged_groups` | none | Adding a privileged group to a user or removing it; creating, re-coding or deleting a privileged group; changing the email or password of a privileged-group member, and deleting one for good. Only a superuser or a role that was granted it holds it. |
|
| `golem15.users.manage_privileged_groups` | none | Adding a privileged group to a user or removing it; creating, re-coding or deleting a privileged group; changing the email or password of a privileged-group member, 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. |
|
||||||
|
|
||||||
Migrations added for the admin, all additive:
|
Migrations added for the admin, all additive:
|
||||||
|
|
||||||
@@ -149,22 +171,56 @@ Migrations added for linked OAuth identities, all additive:
|
|||||||
|----|------|
|
|----|------|
|
||||||
| `202610050001_create_oauth_identities` | the table `golem15_user_oauth_identities` (`user_id` cascade FK to `users`, unique `(user_id, provider)` and `(provider, provider_id)`, jsonb `profile_data`) |
|
| `202610050001_create_oauth_identities` | the table `golem15_user_oauth_identities` (`user_id` cascade FK to `users`, unique `(user_id, provider)` and `(provider, provider_id)`, jsonb `profile_data`) |
|
||||||
|
|
||||||
|
Migrations added for @mention handles, all additive:
|
||||||
|
|
||||||
|
| ID | Adds |
|
||||||
|
|----|------|
|
||||||
|
| `202610160002_add_handle_to_users` | `users.handle` (`VARCHAR(255)` NULL, unique index `users_handle_unique`, `ADD COLUMN IF NOT EXISTS`; rollback is a no-op) |
|
||||||
|
|
||||||
|
Migrations added for address proof, all additive:
|
||||||
|
|
||||||
|
| ID | Adds |
|
||||||
|
|----|------|
|
||||||
|
| `202610210001_add_users_email_verified_at` | `users.email_verified_at` (`TIMESTAMPTZ` NULL, `ADD COLUMN IF NOT EXISTS`, no backfill; rollback is a no-op) |
|
||||||
|
|
||||||
|
Migrations added for the frontend account link, all additive:
|
||||||
|
|
||||||
|
| ID | Adds |
|
||||||
|
|----|------|
|
||||||
|
| `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`. |
|
||||||
|
|
||||||
Packages:
|
Packages:
|
||||||
|
|
||||||
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins:
|
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins:
|
||||||
- `RegisterUser(ctx, app, db, fields, RegisterOptions)` validates `fields` against the user model's rules, hashes the password with `golem15.user.password.bcrypt_cost` and creates the user on `db` (which may be a transaction). It returns the validation errors instead of writing a response and never modifies `fields`. `RegisterOptions.Activate` creates the account activated; `RegisterOptions.IP` records the client IP.
|
- `RegisterUser(ctx, app, db, fields, RegisterOptions)` validates `fields` against the user model's rules, hashes the password with `golem15.user.password.bcrypt_cost` and creates the user on `db` (which may be a transaction). It fills only the `User.RegisterFillable` columns and ignores every other key of `fields`: an organisation membership and role are set with an explicit update after creation, as an application's onboarding does. It returns the validation errors instead of writing a response and never modifies `fields`. `RegisterOptions.Activate` creates the account activated; `RegisterOptions.IP` records the client IP and is the only source of `created_ip_address` and `last_ip_address` (both stay NULL without it, whatever `fields` says); `RegisterOptions.PasswordLess` stores `has_self_set_password=false` for an account whose random password the user never knew (a social sign-in), while the zero value keeps `true`.
|
||||||
- `FireRegisterEvent(ctx, app, user, fields)` fires `RegisterEvent` with `Payload` set to a copy of `fields` without `password` and `password_confirmation`, and returns the listeners' error (`/register` ignores it). Fire it after the user row is committed.
|
- `FireRegisterEvent(ctx, app, user, fields)` fires `RegisterEvent` with `Payload` set to a copy of `fields` without `password` and `password_confirmation`, and returns the listeners' error (`/register` logs it and continues). `Payload` carries every extra key the client sent, so treat it as untrusted. Fire it after the user row is committed.
|
||||||
|
- `FireCreatedEvent(ctx, app, user)` fires `CreatedEvent` (`user.Created`) and returns the listeners' error; `/register` logs it and continues.
|
||||||
|
- `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.
|
- `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.
|
||||||
- `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners.
|
- `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners.
|
||||||
|
- `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks.
|
||||||
|
- `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`, `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:
|
||||||
- `MergedPermissions(ctx, db, userID)`, `HasPermission(merged, code)` and `UserHasPermission(ctx, db, userID, code)`, the frontend permission resolver, and `FrontendPermissionOptions(ctx, db)`, the catalogue by tab and code.
|
- `MergedPermissions(ctx, db, userID)`, `HasPermission(merged, code)` and `UserHasPermission(ctx, db, userID, code)`, the frontend permission resolver, and `FrontendPermissionOptions(ctx, db)`, the catalogue by tab and code.
|
||||||
- `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule.
|
- `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule.
|
||||||
- `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction.
|
- `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction.
|
||||||
|
- `RevokeUserAccess(ctx, db, userIDs)` ends every session JWT issued before the current second (it stamps `users.tokens_valid_after`, deactivated rows included) and revokes every personal API token of the users. `DeactivateUsers` and `BanUsers` call it; an application may call it for its own offboarding and after writing a ban directly to `user_throttle`.
|
||||||
- `TouchLastSeen(ctx, db, userID)`.
|
- `TouchLastSeen(ctx, db, userID)`.
|
||||||
|
- `OAuthProvider` (`Name`, `Label`) and `OAuthProviderSource` (`EnabledOAuthProviders(ctx)`, `KnownOAuthProviders()`), the seam a social-login plugin publishes with an explicit type argument, `app.Publish[classes.OAuthProviderSource](src)`, so the lookup finds it. `GET /oauth-providers` lists its enabled providers in order; the unlink routes accept its known names.
|
||||||
|
- `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`.
|
||||||
|
- `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
|
||||||
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
|
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
|
||||||
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`).
|
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `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.
|
||||||
- `updates`: the gormigrate migrations, returned by `updates.All()`.
|
- `updates`: the gormigrate migrations, returned by `updates.All()`.
|
||||||
- `console`: `RequirePasswordChange` and `APITokenCommands`.
|
- `console`: `RequirePasswordChange`, `BackfillHandles` and `APITokenCommands`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -178,7 +234,8 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
|
|||||||
| `golem15.user.jwt.blacklist_grace` | `0` | Grace period in seconds for a blacklisted token. |
|
| `golem15.user.jwt.blacklist_grace` | `0` | Grace period in seconds for a blacklisted token. |
|
||||||
| `golem15.user.jwt.leeway` | `0` | Clock leeway in seconds (declared for parity, not read yet). |
|
| `golem15.user.jwt.leeway` | `0` | Clock leeway in seconds (declared for parity, not read yet). |
|
||||||
| `golem15.user.jwt.blacklist_sweep_interval` | `10m` | How often expired blacklist rows are deleted (a Go duration). |
|
| `golem15.user.jwt.blacklist_sweep_interval` | `10m` | How often expired blacklist rows are deleted (a Go duration). |
|
||||||
| `golem15.user.activation.require_activation` | `true` | Whether a new account must be activated before login. |
|
| `golem15.user.jwt.token_cookie` | `false` | When on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set the HttpOnly session cookie with the `auth.cookie.*` attributes (`bouncer.LoadCookieConfig`; Max-Age `refresh_ttl`) and leave `token` out of the body (`user`, and `message` on activate-by-code, stay). When off, the token is in the body and no cookie is set. The twin of `golem15.sociallogin.token_cookie`. |
|
||||||
|
| `golem15.user.activation.require_activation` | `true` | Whether an account must be activated before login. When `true`, `POST /login` refuses a not-activated account (the opaque invalid-login answer, counted as a failed attempt) and `/register` in `admin` mode returns no token; when `false`, both sign such an account in. |
|
||||||
| `golem15.user.activation.activate_mode` | `auto` | `auto`, `user` (mailed code) or `admin`. |
|
| `golem15.user.activation.activate_mode` | `auto` | `auto`, `user` (mailed code) or `admin`. |
|
||||||
| `golem15.user.activation.reset_ttl_minutes` | `60` | Password reset code lifetime. |
|
| `golem15.user.activation.reset_ttl_minutes` | `60` | Password reset code lifetime. |
|
||||||
| `golem15.user.activation.activation_ttl_hours` | `72` | Activation code lifetime. |
|
| `golem15.user.activation.activation_ttl_hours` | `72` | Activation code lifetime. |
|
||||||
@@ -189,21 +246,29 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
|
|||||||
| `golem15.user.throttle.use_throttle` | `true` | Whether login throttling is on. |
|
| `golem15.user.throttle.use_throttle` | `true` | Whether login throttling is on. |
|
||||||
| `golem15.user.password.bcrypt_cost` | `10` | bcrypt cost for new password hashes. |
|
| `golem15.user.password.bcrypt_cost` | `10` | bcrypt cost for new password hashes. |
|
||||||
| `golem15.user.password.min_length` | `8` | Minimum password length. |
|
| `golem15.user.password.min_length` | `8` | Minimum password length. |
|
||||||
|
| `golem15.user.password.public_routes` | `true` | When `false`, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered and answer 404 like any unknown path; `POST /register` (still gated by `allow_registration` and guarded by `golem15.user.turnstile`) and every other route stay. It is read when the routes are built, so a change needs a restart. It takes `true` or `false`, also as a string (`SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false`); an empty value means `true`; any other value fails boot. |
|
||||||
| `golem15.user.api_tokens.prefix` | `usr_` | Prefix for newly minted and accepted shared API-token secrets. Override an existing application's legacy prefix without creating a plugin-specific token table. |
|
| `golem15.user.api_tokens.prefix` | `usr_` | Prefix for newly minted and accepted shared API-token secrets. Override an existing application's legacy prefix without creating a plugin-specific token table. |
|
||||||
| `golem15.user.privileged_groups` | `[admin]` | Codes of the privileged user groups. Membership of such a group, the group's code and its delete, and the email, password and permanent delete of its members need `golem15.users.manage_privileged_groups`. Read on every request. An empty list protects no group. |
|
| `golem15.user.privileged_groups` | `[admin]` | Codes of the privileged user groups. Membership of such a group, the group's code and its delete, and the email, password 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.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. |
|
||||||
|
|
||||||
## CLI commands
|
## CLI commands
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose | Example |
|
||||||
|---------|---------|
|
|---------|---------|---------|
|
||||||
| `user:require-password-change <email>` | Flag an account so its next session must set a new password. |
|
| `user:require-password-change <email>` | Flag an account so its next session must set a new password. Ends with `Password change required for <email>`. | `bin/<app> user:require-password-change jane@example.com` |
|
||||||
| `user:token:create <email> [--name api] [--scopes read,write] [--expires RFC3339]` | Create an application-wide API token and print its secret once. |
|
| `user:backfill-handles` | Assign a unique handle to every user row that still has none, with a progress bar. Ends with `Assigned handles to N user(s)`. | `bin/<app> user:backfill-handles` |
|
||||||
| `user:token:list` | List token metadata without secrets or hashes. |
|
| `user:token:create <email> [--name api] [--scopes read,write] [--expires RFC3339] [--json]` | Create an application-wide API token and print its secret once (also under `-q`); `--json` prints the token and its details as one object. | `bin/<app> user:token:create jane@example.com --scopes read --json` |
|
||||||
| `user:token:revoke <id>` | Revoke a shared API token immediately. |
|
| `user:token:list` | List token metadata without secrets or hashes; `--json` prints a JSON array with the expiry, revocation and last-use times. | `bin/<app> user:token:list --json` |
|
||||||
|
| `user:token:revoke <id>` | Revoke a shared API token immediately. | `bin/<app> user:token:revoke 12` |
|
||||||
|
|
||||||
|
Each command uses the database the serve command published or, run from the console, opens one from `database.dsn` (and loads the encryption keys from `app.key`) for its duration. `bin/<app> help <command>` shows each command's long help and examples. The plugin logs through the component `user` (`log.components.user` in `config/log.yaml`).
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `pact`, `party`, `surf` and others).
|
- The SummerCMS framework v0.4.4, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `diary`, `pact`, `party`, `surf` and others).
|
||||||
|
- The organisation role cleanup of the admin (members manager and user form) uses cabana's `ClearOnChange`, added in SummerCMS v0.4.3.
|
||||||
- gormigrate, GORM, golang-jwt and gocloud.dev, as pinned in `go.mod`.
|
- gormigrate, GORM, golang-jwt and gocloud.dev, as pinned in `go.mod`.
|
||||||
- testcontainers-go for the Postgres-backed tests.
|
- testcontainers-go for the Postgres-backed tests.
|
||||||
|
|
||||||
@@ -216,4 +281,30 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
|
|||||||
|
|
||||||
`TestRegisterCORSPath` reads the host application's `config/http.yaml`, so run the tests inside an application checkout where the plugin is mounted at `plugins/golem15/user`.
|
`TestRegisterCORSPath` reads the host application's `config/http.yaml`, so run the tests inside an application checkout where the plugin is mounted at `plugins/golem15/user`.
|
||||||
|
|
||||||
|
`TestCommandsDocumented` (in `-short`) logs, without failing, each command whose help lacks a description, long help, examples or an option description (`bonfire.CheckDocs`).
|
||||||
|
|
||||||
|
`TestRegisterIgnoresProtectedFields` posts organisation, IP and account-flag keys to `/register` and `RegisterUser` and checks that only the allow-listed columns and the server-side IP are stored; `TestRegisterListenerErrors` checks that failing register and created listeners do not fail the registration.
|
||||||
|
|
||||||
|
`TestOffboarding` runs the admin deactivate and ban bulk actions and checks that the user's older session JWT and personal API token stop working, that a password login restores a deactivated account with a working token while the old credentials stay dead, and that unban revives nothing; `TestRefreshSubjectChecks` covers the `/refresh` subject checks; `TestRevokeUserAccess` (in `classes`) covers the revocation writes.
|
||||||
|
|
||||||
|
`TestLogoutEndsTheSession` signs in through `POST /login` and logs out with the bearer header, with only the `token` or `auth_token` cookie, and with both a bearer and a different cookie token, then checks that the token is refused on a `jwt.auth` route, at `/fetch` and at `/refresh`, each through every carrier (bearer header, `token` cookie, `auth_token` cookie), that the forget cookies match the token cookie's attributes (also with a configured domain and SameSite), that the blacklist row lasts for the refresh window so `/refresh` stays refused after the sweep that follows the access token's expiry, that an expired but refreshable token is revoked, and which tokens a logout refuses with 401.
|
||||||
|
|
||||||
|
`TestBootBeforeDatabase` boots in the serve order (`party.Activate` with no database, then `lagoon.Publish`, through `newServeOrderEnv` in `admin_harness_test.go`). It checks that the blacklist store and the GORM hooks are installed on Publish and not before, and that a token ended by `POST /logout` is refused on a `jwt.auth` route through every carrier, at `/fetch` and at `/refresh`, with its `jwt_blacklist` row. `TestAppBlacklist` (in `-short`) covers the guard's per-request store lookup: no store, delegation and a failing store.
|
||||||
|
|
||||||
|
`TestSessionRoutesAcceptCookies` checks that a session `jwt.auth` accepts through only the `token` or `auth_token` cookie is served by `GET /fetch`, `POST /refresh` (through `token`, the `auth.cookie` default, the rotated token comes back in a rewritten cookie with a `{}` body; through `auth_token` it comes in the body with no cookie; the old token is refused afterwards), `POST /update`, `POST /change-password` (the calling token stays valid) and `POST /marketing-consent`; that a request without credentials still gets the old 401 bodies; that the bearer header wins over a cookie exactly as on `jwt.auth`, an invalid bearer included; and that a cut-off token and a banned user are refused through the cookie.
|
||||||
|
|
||||||
|
`TestLoginRequiresActivation` pins the activation gate of `POST /login`: under the default and an explicit `require_activation: true` a not-activated account's correct password gets the byte-identical 401 (status, body, `Content-Type` and `Cache-Control`) of a wrong password, stamps no `last_seen`, adds one throttle attempt and suspends the account after `attempt_limit` refusals; `require_activation: false` and activated accounts sign in; a deactivated activated account is restored with the reactivate mail, and a deactivated not-activated account is refused and stays deactivated unless the key is off.
|
||||||
|
|
||||||
|
`TestLoginThrottleClientIP` sends failed logins through one trusted proxy (`http.trusted_proxies`). It checks that the throttle row carries the `X-Forwarded-For` client, and that this client is suspended while another client behind the same proxy still signs in. It also checks that without trusted proxies a changing `X-Forwarded-For` neither moves the counter nor escapes the suspension.
|
||||||
|
|
||||||
|
`TestRefreshRewritesAuthCookie` covers the cookie-carried `POST /refresh`: the `token` cookie rewritten with the rotated token and the `LoadCookieConfig` attributes (the old token refused, the new one served by `/fetch` and `jwt.auth`), `auth_token` and bearer refreshes left body-only (the bearer wins over a cookie), a configured cookie name and refresh window, refusals that set no cookie, and a broken `auth.cookie` block that answers the opaque 500 and rotates nothing. `TestSessionTokenCookie` (in `-short`) is the carrier table of `classes.SessionTokenCookie`.
|
||||||
|
|
||||||
|
`TestTokenCookie` covers `golem15.user.jwt.token_cookie`: off by default (body tokens, no cookie); on for `POST /login`, `POST /register` and `POST /activate-by-code`, with the session cookie serving `/fetch` and rotating through a cookie refresh and no `token` in the body; `POST /tokens` unaffected; a configured cookie name and refresh window; and a broken `auth.cookie` block that answers the opaque 500 and signs in, creates and activates nothing.
|
||||||
|
|
||||||
|
`TestPasswordPublicRoutes` (in `-short`) and `TestPasswordPublicRoutesAnswer404` cover `golem15.user.password.public_routes`: unset, `true` and the `config.yaml` default register every route; `false` (also from a `SUMMER_` variable) drops exactly the three password routes and keeps every other route and its middleware, the turnstile on `/register` included; an invalid value fails `Routes`. Through the assembled router the dropped routes (and a `GET /login`) answer exactly like an unknown path and sign in, mail and reset nothing, while the default answers are unchanged and `/register`, `/fetch`, `/refresh` and `/logout` still work.
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`.
|
The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`.
|
||||||
|
|||||||
142
account_contributions.go
Normal file
142
account_contributions.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"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/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ pact.HasAccountContributions = (*Plugin)(nil)
|
||||||
|
|
||||||
|
// backendLinkLang prefixes the phrase keys of the account link.
|
||||||
|
const backendLinkLang = "golem15.user::lang.backend_link."
|
||||||
|
|
||||||
|
// AccountContributions adds the "Frontend account" section to the admin's
|
||||||
|
// My account page (D-04): its status names the linked frontend account,
|
||||||
|
// "link" starts a link request and answers the site URL the administrator
|
||||||
|
// opens to sign in there as the frontend user and confirm (D-05; the link is
|
||||||
|
// written only by that confirmation), and "unlink" removes the link.
|
||||||
|
func (p *Plugin) AccountContributions() []pact.AccountContribution {
|
||||||
|
return []pact.AccountContribution{{
|
||||||
|
Name: "frontend_account",
|
||||||
|
Label: backendLinkLang + "section",
|
||||||
|
Description: backendLinkLang + "section_comment",
|
||||||
|
Status: p.frontendAccountStatus,
|
||||||
|
Actions: []pact.AccountAction{
|
||||||
|
{
|
||||||
|
Name: "link",
|
||||||
|
Label: backendLinkLang + "link",
|
||||||
|
Applies: func(ctx context.Context, adminID uint) (bool, error) {
|
||||||
|
link, err := p.backendLinks().LinkFor(ctx, adminID)
|
||||||
|
return link == nil, err
|
||||||
|
},
|
||||||
|
Run: p.startFrontendLink,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "unlink",
|
||||||
|
Label: backendLinkLang + "unlink",
|
||||||
|
Confirm: backendLinkLang + "unlink_confirm",
|
||||||
|
Danger: true,
|
||||||
|
Applies: func(ctx context.Context, adminID uint) (bool, error) {
|
||||||
|
link, err := p.backendLinks().LinkFor(ctx, adminID)
|
||||||
|
return link != nil, err
|
||||||
|
},
|
||||||
|
Run: p.unlinkOwnFrontendAccount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
}
|
||||||
|
|
||||||
|
// frontendAccountStatus answers "Linked to <email>" or "Not linked" in the
|
||||||
|
// request locale for the signed-in administrator.
|
||||||
|
func (p *Plugin) frontendAccountStatus(ctx context.Context, adminID uint) (string, error) {
|
||||||
|
link, err := p.backendLinks().LinkFor(ctx, adminID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if link == nil {
|
||||||
|
return backendLinkLang + "status_unlinked", nil
|
||||||
|
}
|
||||||
|
email, err := p.frontendEmail(ctx, link.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
const key = backendLinkLang + "status_linked"
|
||||||
|
if p.app != nil {
|
||||||
|
if tr, ok := p.app.Lookup[*phrasebook.Translator](); ok && tr != nil {
|
||||||
|
if text := tr.Get(ctx, key, map[string]string{"email": email}); text != "" && text != key {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Linked to " + email, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// frontendEmail reads the email of a frontend user, deactivated or not.
|
||||||
|
func (p *Plugin) frontendEmail(ctx context.Context, userID uint) (string, error) {
|
||||||
|
var db *gorm.DB
|
||||||
|
if tx, ok := cabana.TxFromContext(ctx); ok {
|
||||||
|
db = tx.Session(&gorm.Session{NewDB: true})
|
||||||
|
} else if p.app != nil {
|
||||||
|
db, _ = p.app.Lookup[*gorm.DB]()
|
||||||
|
}
|
||||||
|
if db == nil {
|
||||||
|
return "", errors.New("golem15.user: gorm handle is missing")
|
||||||
|
}
|
||||||
|
var emails []string
|
||||||
|
if err := db.WithContext(ctx).Unscoped().Model(&models.User{}).Where("id = ?", userID).Pluck("email", &emails).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(emails) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return emails[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>.
|
||||||
|
func (p *Plugin) startFrontendLink(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) {
|
||||||
|
plain, _, err := p.backendLinks().CreateLinkRequest(ctx, in.AdminID)
|
||||||
|
if err != nil {
|
||||||
|
return pact.AccountActionResult{}, err
|
||||||
|
}
|
||||||
|
return pact.AccountActionResult{
|
||||||
|
Message: backendLinkLang + "link_started",
|
||||||
|
URL: classes.FrontendURL(p.app) + "/link-admin?code=" + plain,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlinkOwnFrontendAccount removes the signed-in administrator's link in
|
||||||
|
// the action's transaction.
|
||||||
|
func (p *Plugin) unlinkOwnFrontendAccount(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) {
|
||||||
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return pact.AccountActionResult{}, errors.New("golem15.user: unlinking needs the action's transaction")
|
||||||
|
}
|
||||||
|
links := p.backendLinks()
|
||||||
|
link, err := links.LinkFor(ctx, in.AdminID)
|
||||||
|
if err != nil {
|
||||||
|
return pact.AccountActionResult{}, err
|
||||||
|
}
|
||||||
|
if err := links.RemoveLink(ctx, tx, link); err != nil {
|
||||||
|
return pact.AccountActionResult{}, err
|
||||||
|
}
|
||||||
|
return pact.AccountActionResult{Message: backendLinkLang + "unlinked"}, nil
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ func TestResetPassword(t *testing.T) {
|
|||||||
|
|
||||||
func TestActivate(t *testing.T) {
|
func TestActivate(t *testing.T) {
|
||||||
app, gdb := sessionApp(t, "0s")
|
app, gdb := sessionApp(t, "0s")
|
||||||
token := loginToken(t, app, gdb, "activate-auth@example.test")
|
token := inactiveSessionToken(t, app, gdb, "activate-auth@example.test")
|
||||||
rec := bearerJSON(t, controllers.Activate(app), "/_user/api/v1/activate", token, `{"code":"wrong"}`)
|
rec := bearerJSON(t, controllers.Activate(app), "/_user/api/v1/activate", token, `{"code":"wrong"}`)
|
||||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"is_activated":false`) {
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"is_activated":false`) {
|
||||||
t.Fatalf("activate %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("activate %d %s", rec.Code, rec.Body.String())
|
||||||
@@ -97,7 +97,7 @@ func TestActivate(t *testing.T) {
|
|||||||
|
|
||||||
func TestActivateByCode(t *testing.T) {
|
func TestActivateByCode(t *testing.T) {
|
||||||
app, gdb := sessionApp(t, "0s")
|
app, gdb := sessionApp(t, "0s")
|
||||||
user := insertUser(t, gdb, "activate-code@example.test", "correct-horse")
|
user := insertInactiveUser(t, gdb, "activate-code@example.test", "correct-horse")
|
||||||
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
|
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -126,11 +126,10 @@ func adminHarnessDB(t *testing.T) (*gorm.DB, *sql.DB) {
|
|||||||
return adminHarnessGDB, adminHarnessSQL
|
return adminHarnessGDB, adminHarnessSQL
|
||||||
}
|
}
|
||||||
|
|
||||||
// newAdminEnv boots the plugin alone with the admin API mounted. configure
|
// adminEnvConfig is the config every harness boot starts from: the plugin's
|
||||||
// functions adjust the config before the plugin is activated.
|
// JWT secret, the admin secret, body limits and the log mail driver.
|
||||||
func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
|
func adminEnvConfig(t *testing.T) *compass.Config {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
gdb, sqlDB := adminHarnessDB(t)
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-admin\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-admin\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -158,7 +157,22 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app := backpack.New(cfg)
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAdminEnv boots the plugin alone with the admin API mounted. configure
|
||||||
|
// functions adjust the config before the plugin is activated.
|
||||||
|
func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
|
||||||
|
t.Helper()
|
||||||
|
return newAdminEnvWith(t, nil, configure...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAdminEnvWith is newAdminEnv with more registered plugins activated
|
||||||
|
// after golem15.user, by ID.
|
||||||
|
func newAdminEnvWith(t *testing.T, extra []string, configure ...func(*compass.Config)) *adminEnv {
|
||||||
|
t.Helper()
|
||||||
|
gdb, sqlDB := adminHarnessDB(t)
|
||||||
|
app := backpack.New(adminEnvConfig(t))
|
||||||
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -173,7 +187,7 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
|
|||||||
}
|
}
|
||||||
// The registered plugin value is shared by every boot in this binary;
|
// The registered plugin value is shared by every boot in this binary;
|
||||||
// its blacklist sweep is started at most once and is harmless here.
|
// its blacklist sweep is started at most once and is harmless here.
|
||||||
plugins, err := party.Activate(app, []string{"golem15.user"})
|
plugins, err := party.Activate(app, append([]string{"golem15.user"}, extra...))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -191,6 +205,44 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
|
|||||||
return &adminEnv{h: h, app: app, gdb: gdb, bucket: bucket, mail: mail}
|
return &adminEnv{h: h, app: app, gdb: gdb, bucket: bucket, mail: mail}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newServeOrderEnv boots the plugin the way surf's serve command does after
|
||||||
|
// the generated main's party.Activate: every Boot runs with no database
|
||||||
|
// published, then lagoon.Publish, the uploads bucket and the router.
|
||||||
|
// beforePublish, when set, runs between Activate and Publish. Migrate is
|
||||||
|
// there only because the shared harness database may not be migrated yet.
|
||||||
|
func newServeOrderEnv(t *testing.T, beforePublish func(*backpack.App)) *adminEnv {
|
||||||
|
t.Helper()
|
||||||
|
gdb, sqlDB := adminHarnessDB(t)
|
||||||
|
app := backpack.New(adminEnvConfig(t))
|
||||||
|
mail := &mailCapture{next: slog.Default().Handler()}
|
||||||
|
if err := app.Publish(slog.New(mail)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plugins, err := party.Activate(app, []string{"golem15.user"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if beforePublish != nil {
|
||||||
|
beforePublish(app)
|
||||||
|
}
|
||||||
|
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bucket := memblob.OpenBucket(nil)
|
||||||
|
t.Cleanup(func() { _ = bucket.Close() })
|
||||||
|
if err := attach.Publish(app, bucket); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h, err := surf.Assemble(app, plugins)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &adminEnv{h: h, app: app, gdb: gdb, bucket: bucket, mail: mail}
|
||||||
|
}
|
||||||
|
|
||||||
// admin inserts a backend role holding exactly permissions and a backend
|
// admin inserts a backend role holding exactly permissions and a backend
|
||||||
// user in it, logs the user in and returns the bearer token. No permission
|
// user in it, logs the user in and returns the bearer token. No permission
|
||||||
// at all gives an administrator who may sign in and open nothing.
|
// at all gives an administrator who may sign in and open nothing.
|
||||||
|
|||||||
@@ -162,13 +162,13 @@ func TestAdminOrganisationsEdge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAdminOrganisationRoleOutlivesMembership pins a known gap
|
// TestAdminOrganisationMembershipClearsRole: a role belongs to one
|
||||||
// (12.1-SECURITY-REVIEW.md, open item 6): removing a member through the
|
// organisation (12.1-SECURITY-REVIEW.md open item 6, closed). Removing a
|
||||||
// members manager clears organisation_id and nothing else, so the user keeps
|
// member through the members manager clears organisation_id and
|
||||||
// organisation_role and carries it into the next organisation they are
|
// organisation_role, adding one clears a leftover role, and changing the
|
||||||
// added to. Deleting the organisation clears both. When the unlink learns to
|
// organisation in the Users form clears the role while a save that keeps the
|
||||||
// clear the role, this test changes with it.
|
// organisation keeps it. Deleting the organisation clears both.
|
||||||
func TestAdminOrganisationRoleOutlivesMembership(t *testing.T) {
|
func TestAdminOrganisationMembershipClearsRole(t *testing.T) {
|
||||||
env := newAdminEnv(t)
|
env := newAdminEnv(t)
|
||||||
token := env.admin(t, permAccessUsers)
|
token := env.admin(t, permAccessUsers)
|
||||||
stamp := adminStamp()
|
stamp := adminStamp()
|
||||||
@@ -182,18 +182,49 @@ func TestAdminOrganisationRoleOutlivesMembership(t *testing.T) {
|
|||||||
|
|
||||||
env.expect(t, http.StatusOK, http.MethodPost, organisationPath(first.ID, "/relations/members/unlink"), token, ids)
|
env.expect(t, http.StatusOK, http.MethodPost, organisationPath(first.ID, "/relations/members/unlink"), token, ids)
|
||||||
got := env.reload(t, member.ID)
|
got := env.reload(t, member.ID)
|
||||||
if got.OrganisationID != nil {
|
if got.OrganisationID != nil || got.OrganisationRole != nil {
|
||||||
t.Fatalf("organisation_id after the unlink = %d", *got.OrganisationID)
|
t.Fatalf("after the unlink: organisation %v role %v, want both NULL", got.OrganisationID, got.OrganisationRole)
|
||||||
}
|
|
||||||
if got.OrganisationRole == nil || *got.OrganisationRole != "owner" {
|
|
||||||
t.Fatalf("organisation_role after the unlink = %v; the known gap is closed, update this test and the review", got.OrganisationRole)
|
|
||||||
}
|
}
|
||||||
env.expect(t, http.StatusOK, http.MethodPost, organisationPath(second.ID, "/relations/members/link"), token, ids)
|
env.expect(t, http.StatusOK, http.MethodPost, organisationPath(second.ID, "/relations/members/link"), token, ids)
|
||||||
got = env.reload(t, member.ID)
|
got = env.reload(t, member.ID)
|
||||||
if got.OrganisationID == nil || *got.OrganisationID != second.ID || got.OrganisationRole == nil || *got.OrganisationRole != "owner" {
|
if got.OrganisationID == nil || *got.OrganisationID != second.ID || got.OrganisationRole != nil {
|
||||||
t.Fatalf("after the link elsewhere: organisation %v role %v", got.OrganisationID, got.OrganisationRole)
|
t.Fatalf("after the link elsewhere: organisation %v role %v", got.OrganisationID, got.OrganisationRole)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An organisation-less user with a leftover role loses it when added.
|
||||||
|
leftover := env.seedUser(t, "role-leftover-"+stamp, func(u *models.User) { u.OrganisationRole = &owner })
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, organisationPath(first.ID, "/relations/members/link"), token, map[string]any{"ids": []uint{leftover.ID}})
|
||||||
|
if got := env.reload(t, leftover.ID); got.OrganisationID == nil || *got.OrganisationID != first.ID || got.OrganisationRole != nil {
|
||||||
|
t.Fatalf("linked leftover: organisation %v role %v", got.OrganisationID, got.OrganisationRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Users form: keeping the organisation keeps the role, changing it
|
||||||
|
// (to another organisation or to none) clears it.
|
||||||
|
formUser := env.seedUser(t, "role-form-"+stamp, func(u *models.User) { u.OrganisationID, u.OrganisationRole = &first.ID, &owner })
|
||||||
|
setRole := func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if err := env.gdb.Model(&models.User{}).Where("id = ?", formUser.ID).UpdateColumn("organisation_role", owner).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveForm := func(t *testing.T, organisation any) *models.User {
|
||||||
|
t.Helper()
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(formUser.ID, ""), token, map[string]any{
|
||||||
|
"name": "role-form", "email": formUser.Email, "organisation": organisation,
|
||||||
|
})
|
||||||
|
return env.reload(t, formUser.ID)
|
||||||
|
}
|
||||||
|
if got := saveForm(t, first.ID); got.OrganisationRole == nil || *got.OrganisationRole != owner {
|
||||||
|
t.Fatalf("a save that keeps the organisation dropped the role: %v", got.OrganisationRole)
|
||||||
|
}
|
||||||
|
if got := saveForm(t, second.ID); got.OrganisationID == nil || *got.OrganisationID != second.ID || got.OrganisationRole != nil {
|
||||||
|
t.Fatalf("after moving to another organisation: organisation %v role %v", got.OrganisationID, got.OrganisationRole)
|
||||||
|
}
|
||||||
|
setRole(t)
|
||||||
|
if got := saveForm(t, nil); got.OrganisationID != nil || got.OrganisationRole != nil {
|
||||||
|
t.Fatalf("after clearing the organisation: organisation %v role %v", got.OrganisationID, got.OrganisationRole)
|
||||||
|
}
|
||||||
|
|
||||||
// The delete of an organisation releases its members completely.
|
// The delete of an organisation releases its members completely.
|
||||||
env.expect(t, http.StatusOK, http.MethodDelete, organisationPath(doomed.ID, ""), token, nil)
|
env.expect(t, http.StatusOK, http.MethodDelete, organisationPath(doomed.ID, ""), token, nil)
|
||||||
if got := env.reload(t, other.ID); got == nil || got.OrganisationID != nil || got.OrganisationRole != nil {
|
if got := env.reload(t, other.ID); got == nil || got.OrganisationID != nil || got.OrganisationRole != nil {
|
||||||
|
|||||||
@@ -475,6 +475,11 @@ func TestAdminOrganisationMembers(t *testing.T) {
|
|||||||
if rec := mutate(there, "unlink", foreign); rec.Code != http.StatusOK {
|
if rec := mutate(there, "unlink", foreign); rec.Code != http.StatusOK {
|
||||||
t.Fatalf("unlink there = %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("unlink there = %d %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
// The role belonged to the organisation left: the unlink cleared it.
|
||||||
|
if got := env.reload(t, foreign.ID); got.OrganisationRole != nil {
|
||||||
|
t.Fatalf("organisation_role after leaving = %q", *got.OrganisationRole)
|
||||||
|
}
|
||||||
|
stored[foreign.ID].OrganisationRole = nil
|
||||||
if rec := mutate(here, "link", foreign); rec.Code != http.StatusOK || organisationOf(foreign) != here.ID {
|
if rec := mutate(here, "link", foreign); rec.Code != http.StatusOK || organisationOf(foreign) != here.ID {
|
||||||
t.Fatalf("link after the release = %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("link after the release = %d %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import "git.golem15.com/golem15/summercms/modules/pact"
|
import (
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
const userPermissionTab = "golem15.user::lang.plugin.tab"
|
const userPermissionTab = "golem15.user::lang.plugin.tab"
|
||||||
|
|
||||||
// Permissions is the plugin's Winter registerPermissions() catalog plus one
|
// Permissions is the plugin's Winter registerPermissions() catalog plus two
|
||||||
// code of its own. The four PHP codes default to the developer role;
|
// codes of its own. The four PHP codes default to the developer role;
|
||||||
// impersonate_user is registered so imported backend roles that name it stay
|
// impersonate_user is registered so imported backend roles that name it stay
|
||||||
// valid, although no screen uses it. The fifth code
|
// valid, although no screen uses it. The fifth code
|
||||||
// (classes.PermissionManagePrivilegedGroups) has no default role: only a
|
// (classes.PermissionManagePrivilegedGroups) has no default role: only a
|
||||||
// superuser, or a role that was granted it explicitly, may change the email
|
// superuser, or a role that was granted it explicitly, may change the email
|
||||||
// or password of a privileged-group member or delete one.
|
// 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.
|
||||||
func (*Plugin) Permissions() []pact.Permission {
|
func (*Plugin) Permissions() []pact.Permission {
|
||||||
return []pact.Permission{
|
return []pact.Permission{
|
||||||
{
|
{
|
||||||
@@ -42,5 +48,10 @@ func (*Plugin) Permissions() []pact.Permission {
|
|||||||
Tab: userPermissionTab,
|
Tab: userPermissionTab,
|
||||||
Label: "golem15.user::lang.plugin.manage_privileged_groups",
|
Label: "golem15.user::lang.plugin.manage_privileged_groups",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Code: classes.PermissionManageBackendLinks,
|
||||||
|
Tab: userPermissionTab,
|
||||||
|
Label: "golem15.user::lang.plugin.manage_backend_links",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ var langKey = regexp.MustCompile(`golem15\.user::lang\.[a-z0-9_]+(\.[a-z0-9_]+)+
|
|||||||
func TestAdminRegistration(t *testing.T) {
|
func TestAdminRegistration(t *testing.T) {
|
||||||
plugin := &Plugin{}
|
plugin := &Plugin{}
|
||||||
|
|
||||||
// The five permission codes, each on the plugin's tab.
|
// The six permission codes, each on the plugin's tab.
|
||||||
var codes []string
|
var codes []string
|
||||||
for _, permission := range plugin.Permissions() {
|
for _, permission := range plugin.Permissions() {
|
||||||
codes = append(codes, permission.Code)
|
codes = append(codes, permission.Code)
|
||||||
@@ -40,14 +40,15 @@ func TestAdminRegistration(t *testing.T) {
|
|||||||
wantCodes := []string{
|
wantCodes := []string{
|
||||||
"golem15.users.access_users", "golem15.users.access_groups", "golem15.users.access_settings",
|
"golem15.users.access_users", "golem15.users.access_groups", "golem15.users.access_settings",
|
||||||
"golem15.users.impersonate_user", "golem15.users.manage_privileged_groups",
|
"golem15.users.impersonate_user", "golem15.users.manage_privileged_groups",
|
||||||
|
"golem15.users.manage_backend_links",
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(codes, wantCodes) {
|
if !reflect.DeepEqual(codes, wantCodes) {
|
||||||
t.Fatalf("permission codes = %v, want %v", codes, wantCodes)
|
t.Fatalf("permission codes = %v, want %v", codes, wantCodes)
|
||||||
}
|
}
|
||||||
// The privileged-groups permission has no default role: nobody holds it
|
// The privileged-groups and backend-links permissions have no default
|
||||||
// until it is granted on purpose.
|
// role: nobody holds them until they are granted on purpose.
|
||||||
for _, permission := range plugin.Permissions() {
|
for _, permission := range plugin.Permissions() {
|
||||||
if privileged := permission.Code == permManagePrivileged; privileged != (len(permission.Roles) == 0) {
|
if privileged := permission.Code == permManagePrivileged || permission.Code == permManageBackendLinks; privileged != (len(permission.Roles) == 0) {
|
||||||
t.Fatalf("permission %s has roles %v", permission.Code, permission.Roles)
|
t.Fatalf("permission %s has roles %v", permission.Code, permission.Roles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func TestAdminUsersTracer(t *testing.T) {
|
|||||||
if want := []string{
|
if want := []string{
|
||||||
"golem15.users.access_users@developer", "golem15.users.access_groups@developer",
|
"golem15.users.access_users@developer", "golem15.users.access_groups@developer",
|
||||||
"golem15.users.access_settings@developer", "golem15.users.impersonate_user@developer",
|
"golem15.users.access_settings@developer", "golem15.users.impersonate_user@developer",
|
||||||
classes.PermissionManagePrivilegedGroups + "@-",
|
classes.PermissionManagePrivilegedGroups + "@-", classes.PermissionManageBackendLinks + "@-",
|
||||||
}; !reflect.DeepEqual(codes, want) || classes.PermissionManagePrivilegedGroups != permManagePrivileged {
|
}; !reflect.DeepEqual(codes, want) || classes.PermissionManagePrivilegedGroups != permManagePrivileged {
|
||||||
t.Fatalf("permissions = %v", codes)
|
t.Fatalf("permissions = %v", codes)
|
||||||
}
|
}
|
||||||
@@ -1000,7 +1000,18 @@ func TestAdminUserPassword(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
assertNoSecret(t, rec.Body.String(), first, user.Password)
|
assertNoSecret(t, rec.Body.String(), first, user.Password)
|
||||||
}
|
}
|
||||||
// A request cannot turn the activation on, and cannot store a hash directly.
|
// Escalation G2: the account the administrator created is not activated,
|
||||||
|
// so its password does not sign it in while require_activation is on
|
||||||
|
// (the default); it gets the invalid-login answer of a wrong password.
|
||||||
|
// A request still cannot turn the activation on, and cannot store a hash
|
||||||
|
// directly. Once an administrator activates it, the password signs in.
|
||||||
|
refused := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": email, "password": first})
|
||||||
|
if refused.Code != http.StatusUnauthorized || refused.Body.String() != invalidLoginBody {
|
||||||
|
t.Fatalf("login of the not-activated user = %d %s", refused.Code, refused.Body.String())
|
||||||
|
}
|
||||||
|
if rec := env.bulk(t, token, "activate", user); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("activate = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
if status, _ := env.userLogin(t, email, first); status != http.StatusOK {
|
if status, _ := env.userLogin(t, email, first); status != http.StatusOK {
|
||||||
t.Fatalf("login with the created password = %d", status)
|
t.Fatalf("login with the created password = %d", status)
|
||||||
}
|
}
|
||||||
@@ -1094,8 +1105,8 @@ func TestAdminUserPassword(t *testing.T) {
|
|||||||
if status, _ := env.userLogin(t, email, second); status != http.StatusOK {
|
if status, _ := env.userLogin(t, email, second); status != http.StatusOK {
|
||||||
t.Fatalf("the new password does not sign in: %d", status)
|
t.Fatalf("the new password does not sign in: %d", status)
|
||||||
}
|
}
|
||||||
// The reset did not activate the user or touch anything else.
|
// The reset kept the activation and the email.
|
||||||
if after.IsActivated || after.Email != email {
|
if !after.IsActivated || after.Email != email {
|
||||||
t.Fatalf("the reset changed more than the password: %+v", after)
|
t.Fatalf("the reset changed more than the password: %+v", after)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
avatar_url_test.go
Normal file
136
avatar_url_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"gocloud.dev/blob/memblob"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jpegAvatar stores a real JPEG avatar for user (a system_files row and its
|
||||||
|
// blob) and returns the row.
|
||||||
|
func (e *adminEnv) jpegAvatar(t *testing.T, user *models.User) attach.File {
|
||||||
|
t.Helper()
|
||||||
|
disk := "face" + adminStamp() + ".jpg"
|
||||||
|
data := jpegBytes(t)
|
||||||
|
if err := e.bucket.WriteAll(context.Background(), attach.BlobKey(disk), data, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
row := attach.File{
|
||||||
|
DiskName: disk, FileName: "face.jpg", FileSize: int64(len(data)), ContentType: "image/jpeg",
|
||||||
|
Field: models.AvatarField, AttachmentID: strconv.FormatUint(uint64(user.ID), 10), AttachmentType: user.MorphName(),
|
||||||
|
}
|
||||||
|
if err := e.gdb.Create(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func gravatarFor(email string, size int) string {
|
||||||
|
return fmt.Sprintf("//www.gravatar.com/avatar/%x?s=%d&d=mm", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email)))), size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAvatarURL pins classes.AvatarURL (D-05): the avatar thumbnail at the
|
||||||
|
// asked size, the file path when the thumbnail fails, the gravatar
|
||||||
|
// fallback with the size, errors only for a failing lookup, and the user
|
||||||
|
// API payload (applyAvatar) unchanged: its avatar_url is AvatarURL at 128.
|
||||||
|
func TestAvatarURL(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
if _, err := classes.AvatarURL(ctx, env.app, nil, 128); err == nil {
|
||||||
|
t.Fatal("a nil user must be refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gravatar: no app, an unsaved user, no attachment; the email is
|
||||||
|
// trimmed and lower-cased, the size is the one asked for.
|
||||||
|
plain := env.seedUser(t, "plain", func(u *models.User) { u.Email = " Mixed.Case-" + adminStamp() + "@Example.TEST " })
|
||||||
|
for _, size := range []int{32, 128, 256} {
|
||||||
|
for name, call := range map[string]func() (string, error){
|
||||||
|
"no app": func() (string, error) { return classes.AvatarURL(ctx, nil, plain, size) },
|
||||||
|
"unsaved user": func() (string, error) { return classes.AvatarURL(ctx, env.app, &models.User{Email: plain.Email}, size) },
|
||||||
|
"no attachment": func() (string, error) { return classes.AvatarURL(ctx, env.app, plain, size) },
|
||||||
|
} {
|
||||||
|
got, err := call()
|
||||||
|
if err != nil || got != gravatarFor(plain.Email, size) {
|
||||||
|
t.Fatalf("%s at %d = %q %v, want %q", name, size, got, err, gravatarFor(plain.Email, size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gravatarFor(plain.Email, 1) != gravatarFor(strings.ToLower(strings.TrimSpace(plain.Email)), 1) {
|
||||||
|
t.Fatal("the gravatar hash is not of the normalised email")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file avatar: the thumbnail at size by size, mode auto.
|
||||||
|
withFile := env.seedUser(t, "withfile", nil)
|
||||||
|
row := env.jpegAvatar(t, withFile)
|
||||||
|
thumb, err := classes.AvatarURL(ctx, env.app, withFile, 64)
|
||||||
|
want := attach.ThumbFilename(row.ID, 64, 64, 0, 0, "auto", "jpg")
|
||||||
|
if err != nil || !strings.HasSuffix(thumb, want) || strings.HasPrefix(thumb, "//") {
|
||||||
|
t.Fatalf("thumbnail = %q %v, want a path ending in %s", thumb, err, want)
|
||||||
|
}
|
||||||
|
// Without a published bucket the gravatar is used even with a file.
|
||||||
|
noBucket := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
||||||
|
if err := noBucket.Publish(env.gdb); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := classes.AvatarURL(ctx, noBucket, withFile, 64); err != nil || got != gravatarFor(withFile.Email, 64) {
|
||||||
|
t.Fatalf("no bucket = %q %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An original that does not decode gets WinterCMS's broken-image
|
||||||
|
// thumbnail, still a thumbnail path.
|
||||||
|
notImage := env.seedUser(t, "notimage", nil)
|
||||||
|
env.seedAvatar(t, notImage) // its blob is not an image
|
||||||
|
if got, err := classes.AvatarURL(ctx, env.app, notImage, 128); err != nil || !strings.Contains(got, "thumb_") {
|
||||||
|
t.Fatalf("non-image avatar = %q %v", got, err)
|
||||||
|
}
|
||||||
|
// A thumbnail that cannot be made (here: the storage fails) falls back
|
||||||
|
// to the file path.
|
||||||
|
closed := memblob.OpenBucket(nil)
|
||||||
|
_ = closed.Close()
|
||||||
|
failing := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
||||||
|
if err := failing.Publish(env.gdb); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := attach.Publish(failing, closed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := classes.AvatarURL(ctx, failing, withFile, 128); err != nil || got != "/"+attach.BlobKey(row.DiskName) {
|
||||||
|
t.Fatalf("failing thumbnail = %q %v, want /%s", got, err, attach.BlobKey(row.DiskName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failing lookup is the only error.
|
||||||
|
canceled, cancel := context.WithCancel(ctx)
|
||||||
|
cancel()
|
||||||
|
if got, err := classes.AvatarURL(canceled, env.app, withFile, 128); err == nil {
|
||||||
|
t.Fatalf("failing lookup = %q, want an error", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user API payload is unchanged: avatar_url is AvatarURL at 128,
|
||||||
|
// next to has_avatar and the disk name.
|
||||||
|
payload, err := controllers.APIArray(ctx, env.app, withFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
at128, _ := classes.AvatarURL(ctx, env.app, withFile, 128)
|
||||||
|
if payload["has_avatar"] != true || payload["avatar"] != row.DiskName || payload["avatar_url"] != at128 {
|
||||||
|
t.Fatalf("payload avatar fields = %v %v %v, want true %s %s", payload["has_avatar"], payload["avatar"], payload["avatar_url"], row.DiskName, at128)
|
||||||
|
}
|
||||||
|
without, err := controllers.APIArray(ctx, env.app, plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Without one the payload keeps its gravatar default (size 25).
|
||||||
|
if without["has_avatar"] != false || without["avatar"] != gravatarFor(plain.Email, 25) {
|
||||||
|
t.Fatalf("payload without an avatar = %v %v", without["has_avatar"], without["avatar"])
|
||||||
|
}
|
||||||
|
}
|
||||||
151
backend_link_admin_smoke_test.go
Normal file
151
backend_link_admin_smoke_test.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
const permManageBackendLinks = "golem15.users.manage_backend_links"
|
||||||
|
|
||||||
|
// administratorPath is the admin API path of one administrator on the
|
||||||
|
// Administrators screen, with an optional suffix.
|
||||||
|
func administratorPath(id uint, suffix string) string {
|
||||||
|
return adminAPI("/summercms/cabana/users/" + strconv.FormatUint(uint64(id), 10) + suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// superAdmin is admin with the superuser flag set.
|
||||||
|
func (e *adminEnv) superAdmin(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
token := e.admin(t)
|
||||||
|
if err := e.gdb.Exec(`UPDATE backend_users SET is_superuser = TRUE WHERE id = ?`, adminID(t, token)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// administratorActions returns the record actions the Administrators screen
|
||||||
|
// offers on an administrator.
|
||||||
|
func (e *adminEnv) administratorActions(t *testing.T, token string, id uint) []string {
|
||||||
|
t.Helper()
|
||||||
|
rec := e.expect(t, http.StatusOK, http.MethodGet, administratorPath(id, ""), token, nil)
|
||||||
|
var meta struct {
|
||||||
|
Actions []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"actions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(adminDecode(t, rec).Meta, &meta); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
names := []string{}
|
||||||
|
for _, action := range meta.Actions {
|
||||||
|
if strings.HasSuffix(action.Name, "frontend_account") {
|
||||||
|
names = append(names, action.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackendLinkAdminSmoke: on the Administrators screen an administrator
|
||||||
|
// with golem15.users.manage_backend_links links a frontend account to
|
||||||
|
// another administrator by its exact email (any letter case) and unlinks
|
||||||
|
// it; linked_by records the caller, the event fires, a taken or unknown
|
||||||
|
// account is a 422 on the prompt, and an administrator without the
|
||||||
|
// permission is neither offered the actions nor allowed to run them.
|
||||||
|
func TestBackendLinkAdminSmoke(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
var mu sync.Mutex
|
||||||
|
var linked []models.BackendLink
|
||||||
|
env.app.Events.Listen("test.backend_linked", func(_ context.Context, e *classes.BackendLinkedEvent) error {
|
||||||
|
mu.Lock()
|
||||||
|
linked = append(linked, *e.Link)
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
stamp := adminStamp()
|
||||||
|
editor := env.seedUser(t, "editor", func(u *models.User) { u.Email = "editor-" + stamp + "@example.test" })
|
||||||
|
second := env.seedUser(t, "second", nil)
|
||||||
|
targetToken := env.admin(t)
|
||||||
|
target := adminID(t, targetToken)
|
||||||
|
super := env.superAdmin(t)
|
||||||
|
superID := adminID(t, super)
|
||||||
|
resolver, _ := env.app.Lookup[pact.MCPLinkedBackendResolver]()
|
||||||
|
|
||||||
|
if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"link_frontend_account"}) {
|
||||||
|
t.Fatalf("offered before the link = %v", got)
|
||||||
|
}
|
||||||
|
link := func(token, value string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.call(t, http.MethodPost, administratorPath(target, "/actions/link_frontend_account"), token, map[string]any{"payload": value})
|
||||||
|
return rec.Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown email: 422 on payload with the unknown_user text.
|
||||||
|
rec := env.call(t, http.MethodPost, administratorPath(target, "/actions/link_frontend_account"), super, map[string]any{"payload": "nobody-" + stamp + "@example.test"})
|
||||||
|
if rec.Code != http.StatusUnprocessableEntity || !slices.Contains(adminDecode(t, rec).Error.Details["payload"], "No active frontend user has this email.") {
|
||||||
|
t.Fatalf("unknown email = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The email matches whatever its letter case.
|
||||||
|
if res := link(super, " Editor-"+stamp+"@Example.TEST "); res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("set = %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
var row models.BackendLink
|
||||||
|
if err := env.gdb.Where("backend_user_id = ?", target).Take(&row).Error; err != nil || row.UserID != editor.ID || row.LinkedBy == nil || *row.LinkedBy != superID {
|
||||||
|
t.Fatalf("link row = %+v %v", row, err)
|
||||||
|
}
|
||||||
|
if got, found, err := resolver.LinkedBackendUser(context.Background(), editor.ID); err != nil || !found || got.ID != target {
|
||||||
|
t.Fatalf("LinkedBackendUser = %+v %v %v", got, found, err)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if len(linked) != 1 || linked[0].UserID != editor.ID || linked[0].BackendUserID != target {
|
||||||
|
t.Fatalf("BackendLinkedEvent saw %+v", linked)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"unlink_frontend_account"}) {
|
||||||
|
t.Fatalf("offered after the link = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The administrator is linked: a second set no longer applies (409),
|
||||||
|
// and the same frontend user cannot be linked to another administrator.
|
||||||
|
if res := link(super, second.Email); res.StatusCode != http.StatusConflict {
|
||||||
|
t.Fatalf("second set on a linked administrator = %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
otherToken := env.admin(t)
|
||||||
|
other := adminID(t, otherToken)
|
||||||
|
rec = env.call(t, http.MethodPost, administratorPath(other, "/actions/link_frontend_account"), super, map[string]any{"payload": editor.Email})
|
||||||
|
if rec.Code != http.StatusUnprocessableEntity || !slices.Contains(adminDecode(t, rec).Error.Details["payload"], "This administrator or this frontend user is already linked.") {
|
||||||
|
t.Fatalf("frontend user linked twice = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the permission: not offered, and refused.
|
||||||
|
limited := env.admin(t, cabana.PermissionManageUsers)
|
||||||
|
if got := env.administratorActions(t, limited, target); len(got) != 0 {
|
||||||
|
t.Fatalf("limited administrator is offered %v", got)
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodPost, administratorPath(target, "/actions/unlink_frontend_account"), limited, map[string]any{})
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodPost, administratorPath(other, "/actions/link_frontend_account"), limited, map[string]any{"payload": second.Email})
|
||||||
|
manager := env.admin(t, cabana.PermissionManageUsers, permManageBackendLinks)
|
||||||
|
if got := env.administratorActions(t, manager, target); !slices.Equal(got, []string{"unlink_frontend_account"}) {
|
||||||
|
t.Fatalf("manager is offered %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlink.
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, administratorPath(target, "/actions/unlink_frontend_account"), manager, map[string]any{})
|
||||||
|
if _, found, err := resolver.LinkedBackendUser(context.Background(), editor.ID); err != nil || found {
|
||||||
|
t.Fatalf("after unlink found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"link_frontend_account"}) {
|
||||||
|
t.Fatalf("offered after the unlink = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
272
backend_link_smoke_test.go
Normal file
272
backend_link_smoke_test.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// subjectSpy records the pact.MCPSubjectChanged events fired on an app.
|
||||||
|
type subjectSpy struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []pact.MCPSubjectChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *subjectSpy) has(guard string, id uint, kind string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return slices.Contains(s.events, pact.MCPSubjectChanged{Guard: guard, UserID: id, Kind: kind})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *subjectSpy) reset() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.events = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// spySubjects listens for the subject events the plugin fires.
|
||||||
|
func (e *adminEnv) spySubjects() *subjectSpy {
|
||||||
|
spy := &subjectSpy{}
|
||||||
|
e.app.Events.Listen("test.subjects", func(_ context.Context, ev *pact.MCPSubjectChanged) error {
|
||||||
|
spy.mu.Lock()
|
||||||
|
spy.events = append(spy.events, *ev)
|
||||||
|
spy.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return spy
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminID is the backend user id inside an admin session token.
|
||||||
|
func adminID(t *testing.T, token string) uint {
|
||||||
|
t.Helper()
|
||||||
|
sub, _, _, _, err := bouncer.VerifyClaimsAudience(token, adminHarnessSecret, bouncer.AudienceBackend)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(sub, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return uint(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminLogin reads an administrator's login.
|
||||||
|
func (e *adminEnv) adminLogin(t *testing.T, id uint) string {
|
||||||
|
t.Helper()
|
||||||
|
var login string
|
||||||
|
if err := e.gdb.Raw(`SELECT login FROM backend_users WHERE id = ?`, id).Scan(&login).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return login
|
||||||
|
}
|
||||||
|
|
||||||
|
// freshUserToken mints a frontend session token issued now.
|
||||||
|
func freshUserToken(t *testing.T, user *models.User) string {
|
||||||
|
t.Helper()
|
||||||
|
token, _, err := bouncer.Mint(sessionSecret, strconv.FormatUint(uint64(user.ID), 10), "https://app.example.test", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// accountSection is one My account contribution as the admin API lists it.
|
||||||
|
type accountSection struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Actions []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"actions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s accountSection) actions() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, a := range s.Actions {
|
||||||
|
out = append(out, a.Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// frontendSection lists My account and returns the frontend_account section.
|
||||||
|
func (e *adminEnv) frontendSection(t *testing.T, token string) accountSection {
|
||||||
|
t.Helper()
|
||||||
|
var sections []accountSection
|
||||||
|
adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, adminAPI("/account/contributions"), token, nil), §ions)
|
||||||
|
for _, s := range sections {
|
||||||
|
if s.Name == "frontend_account" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("My account has no frontend_account section: %+v", sections)
|
||||||
|
return accountSection{}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
t.Helper()
|
||||||
|
var out struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
adminInto(t, e.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), token, map[string]any{}), &out)
|
||||||
|
m := linkURL.FindStringSubmatch(out.URL)
|
||||||
|
if m == nil || out.Message == "" {
|
||||||
|
t.Fatalf("link action answered %+v", out)
|
||||||
|
}
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmLink posts the code as the frontend session token.
|
||||||
|
func (e *adminEnv) confirmLink(t *testing.T, userToken, code string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := e.call(t, http.MethodPost, "/_user/api/v1/backend-link/confirm", userToken, map[string]string{"code": code})
|
||||||
|
return rec.Code, rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackendLinkSmoke: an administrator starts a link on My account, the
|
||||||
|
// site shows who asked, a session older than the request is refused, a
|
||||||
|
// fresh one links the accounts (the published resolver answers), the code is
|
||||||
|
// one-shot, the 1:1 rule holds, unlink and deleting the frontend user remove
|
||||||
|
// the link, and the subject events fire.
|
||||||
|
func TestBackendLinkSmoke(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
spy := env.spySubjects()
|
||||||
|
frontend := env.seedUser(t, "linker", nil)
|
||||||
|
other := env.seedUser(t, "linker-other", nil)
|
||||||
|
token := env.admin(t)
|
||||||
|
id := adminID(t, token)
|
||||||
|
login := env.adminLogin(t, id)
|
||||||
|
links, ok := env.app.Lookup[*classes.BackendLinks]()
|
||||||
|
if !ok || links == nil {
|
||||||
|
t.Fatal("*classes.BackendLinks is not published")
|
||||||
|
}
|
||||||
|
resolver, ok := env.app.Lookup[pact.MCPLinkedBackendResolver]()
|
||||||
|
if !ok || resolver == nil {
|
||||||
|
t.Fatal("pact.MCPLinkedBackendResolver is not published")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
section := env.frontendSection(t, token)
|
||||||
|
if section.Status != "Not linked" || !slices.Equal(section.actions(), []string{"link"}) {
|
||||||
|
t.Fatalf("unlinked section = %+v", section)
|
||||||
|
}
|
||||||
|
|
||||||
|
code := env.startLink(t, token)
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil)
|
||||||
|
var shown struct {
|
||||||
|
Data struct {
|
||||||
|
Admin map[string]any `json:"admin"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &shown); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if shown.Data.Admin["login"] != login || shown.Data.Admin["name"] != login || len(shown.Data.Admin) != 2 {
|
||||||
|
t.Fatalf("show = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if expires, err := time.Parse(time.RFC3339, shown.Data.ExpiresAt); err != nil || time.Until(expires) < 9*time.Minute || time.Until(expires) > 11*time.Minute {
|
||||||
|
t.Fatalf("expires_at = %q (%v)", shown.Data.ExpiresAt, err)
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusNotFound, http.MethodGet, "/_user/api/v1/backend-link/"+strings.Repeat("0", 64), "", nil)
|
||||||
|
|
||||||
|
// A session signed in before the request is refused: sign in again.
|
||||||
|
stale := craftToken(t, strconv.FormatUint(uint64(frontend.ID), 10), "stale-"+adminStamp(), time.Now().Add(-time.Hour), time.Now().Add(time.Hour))
|
||||||
|
if status, body := env.confirmLink(t, stale, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
|
||||||
|
t.Fatalf("stale confirm = %d %s", status, body)
|
||||||
|
}
|
||||||
|
if status, _ := env.confirmLink(t, "", code); status != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("anonymous confirm = %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
spy.reset()
|
||||||
|
status, body := env.confirmLink(t, freshUserToken(t, frontend), code)
|
||||||
|
if status != http.StatusOK || !strings.Contains(body, `"login":"`+login+`"`) {
|
||||||
|
t.Fatalf("fresh confirm = %d %s", status, body)
|
||||||
|
}
|
||||||
|
linked, found, err := resolver.LinkedBackendUser(ctx, frontend.ID)
|
||||||
|
if err != nil || !found || linked.ID != id || linked.Login != login || linked.DisplayName != login {
|
||||||
|
t.Fatalf("LinkedBackendUser = %+v %v %v", linked, found, err)
|
||||||
|
}
|
||||||
|
if !spy.has("user", frontend.ID, pact.MCPSubjectLink) || !spy.has("backend", id, pact.MCPSubjectLink) {
|
||||||
|
t.Fatalf("confirm fired %+v", spy.events)
|
||||||
|
}
|
||||||
|
var row models.BackendLink
|
||||||
|
if err := env.gdb.Where("user_id = ?", frontend.ID).Take(&row).Error; err != nil || row.BackendUserID != id || row.LinkedBy != nil {
|
||||||
|
t.Fatalf("link row = %+v %v", row, err)
|
||||||
|
}
|
||||||
|
section = env.frontendSection(t, token)
|
||||||
|
if !strings.Contains(section.Status, frontend.Email) || !slices.Equal(section.actions(), []string{"unlink"}) {
|
||||||
|
t.Fatalf("linked section = %+v", section)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The code is one-shot.
|
||||||
|
if status, _ := env.confirmLink(t, freshUserToken(t, frontend), code); status != http.StatusNotFound {
|
||||||
|
t.Fatalf("replayed confirm = %d", status)
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusNotFound, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil)
|
||||||
|
|
||||||
|
// One link per administrator: another frontend user cannot take it.
|
||||||
|
second, _, err := links.CreateLinkRequest(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status, body := env.confirmLink(t, freshUserToken(t, other), second); status != http.StatusConflict || !strings.Contains(body, `"already_linked"`) {
|
||||||
|
t.Fatalf("second link = %d %s", status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlink on My account.
|
||||||
|
spy.reset()
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/unlink"), token, map[string]any{})
|
||||||
|
if _, found, err := resolver.LinkedBackendUser(ctx, frontend.ID); err != nil || found {
|
||||||
|
t.Fatalf("after unlink LinkedBackendUser found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if !spy.has("user", frontend.ID, pact.MCPSubjectLink) || !spy.has("backend", id, pact.MCPSubjectLink) {
|
||||||
|
t.Fatalf("unlink fired %+v", spy.events)
|
||||||
|
}
|
||||||
|
if section := env.frontendSection(t, token); !slices.Equal(section.actions(), []string{"link"}) {
|
||||||
|
t.Fatalf("section after unlink = %+v", section)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An expired request answers 404 like an unknown one.
|
||||||
|
expired, _, err := links.CreateLinkRequest(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := env.gdb.Exec(`UPDATE golem15_user_backend_link_requests SET expires_at = NOW() - INTERVAL '1 minute' WHERE backend_user_id = ? AND used_at IS NULL`, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status, _ := env.confirmLink(t, freshUserToken(t, frontend), expired); status != http.StatusNotFound {
|
||||||
|
t.Fatalf("expired confirm = %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting the frontend user removes the link and ends its subject.
|
||||||
|
code = env.startLink(t, token)
|
||||||
|
if status, body := env.confirmLink(t, freshUserToken(t, frontend), code); status != http.StatusOK {
|
||||||
|
t.Fatalf("relink = %d %s", status, body)
|
||||||
|
}
|
||||||
|
spy.reset()
|
||||||
|
manager := env.admin(t, permAccessUsers)
|
||||||
|
env.expect(t, http.StatusOK, http.MethodDelete, userPath(frontend.ID, ""), manager, nil)
|
||||||
|
var left int64
|
||||||
|
if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_backend_links WHERE backend_user_id = ?`, id).Scan(&left).Error; err != nil || left != 0 {
|
||||||
|
t.Fatalf("links left after delete = %d %v", left, err)
|
||||||
|
}
|
||||||
|
if !spy.has("user", frontend.ID, pact.MCPSubjectDeleted) || !spy.has("backend", id, pact.MCPSubjectLink) {
|
||||||
|
t.Fatalf("delete fired %+v", spy.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
128
backend_links_admin.go
Normal file
128
backend_links_admin.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ pact.HasAdminActionExtensions = (*Plugin)(nil)
|
||||||
|
|
||||||
|
// AdminActionExtensions adds two record actions to the admin's
|
||||||
|
// Administrators screen (D-07, D-34), both behind
|
||||||
|
// golem15.users.manage_backend_links: link_frontend_account asks for the
|
||||||
|
// email of an active frontend user and links that user to the
|
||||||
|
// administrator shown (exact, case-insensitive match; no search and no
|
||||||
|
// list), recording who set it; unlink_frontend_account removes the
|
||||||
|
// administrator's link. cabana's outranking check runs before either.
|
||||||
|
func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
|
||||||
|
return []pact.AdminActionExtension{{
|
||||||
|
Controller: cabana.AdministratorsControllerID,
|
||||||
|
RecordActions: []pact.AdminRecordAction{
|
||||||
|
{
|
||||||
|
Name: "link_frontend_account",
|
||||||
|
Label: backendLinkLang + "set",
|
||||||
|
Confirm: backendLinkLang + "set_confirm",
|
||||||
|
Permissions: []string{classes.PermissionManageBackendLinks},
|
||||||
|
Prompt: &pact.AdminActionPrompt{
|
||||||
|
Label: backendLinkLang + "prompt_email",
|
||||||
|
Comment: backendLinkLang + "prompt_comment",
|
||||||
|
MaxLength: 254,
|
||||||
|
},
|
||||||
|
Applies: func(ctx context.Context, record any) (bool, error) {
|
||||||
|
link, err := p.adminRecordLink(ctx, record)
|
||||||
|
return link == nil, err
|
||||||
|
},
|
||||||
|
Run: p.setFrontendLink,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "unlink_frontend_account",
|
||||||
|
Label: backendLinkLang + "remove",
|
||||||
|
Confirm: backendLinkLang + "remove_confirm",
|
||||||
|
Danger: true,
|
||||||
|
Permissions: []string{classes.PermissionManageBackendLinks},
|
||||||
|
Applies: func(ctx context.Context, record any) (bool, error) {
|
||||||
|
link, err := p.adminRecordLink(ctx, record)
|
||||||
|
return link != nil, err
|
||||||
|
},
|
||||||
|
Run: p.removeFrontendLink,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminRecordLink returns the link of the administrator record, or nil.
|
||||||
|
func (p *Plugin) adminRecordLink(ctx context.Context, record any) (*models.BackendLink, error) {
|
||||||
|
admin, ok := record.(*cabana.BackendUser)
|
||||||
|
if !ok || admin == nil {
|
||||||
|
return nil, errors.New("golem15.user: the record is not an administrator")
|
||||||
|
}
|
||||||
|
return p.backendLinks().LinkFor(ctx, admin.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// payloadError is the 422 on the prompt's input.
|
||||||
|
func payloadError(key string) error {
|
||||||
|
return &cabana.ValidationError{Details: map[string]any{"payload": []string{backendLinkLang + key}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setFrontendLink links the active frontend user whose email is the prompt
|
||||||
|
// value to the administrator record.
|
||||||
|
func (p *Plugin) setFrontendLink(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||||
|
admin, ok := in.Record.(*cabana.BackendUser)
|
||||||
|
if !ok || admin == nil {
|
||||||
|
return pact.AdminRecordActionResult{}, errors.New("golem15.user: the record is not an administrator")
|
||||||
|
}
|
||||||
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return pact.AdminRecordActionResult{}, errors.New("golem15.user: linking needs the action's transaction")
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(in.Value)
|
||||||
|
var ids []uint
|
||||||
|
if err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Model(&models.User{}).
|
||||||
|
Where("lower(email) = lower(?) AND is_activated = TRUE", email).Limit(2).Pluck("id", &ids).Error; err != nil {
|
||||||
|
return pact.AdminRecordActionResult{}, err
|
||||||
|
}
|
||||||
|
if email == "" || len(ids) != 1 {
|
||||||
|
return pact.AdminRecordActionResult{}, payloadError("unknown_user")
|
||||||
|
}
|
||||||
|
var by uint
|
||||||
|
if caller, ok := bouncer.User(ctx); ok && caller != nil {
|
||||||
|
by = caller.ID
|
||||||
|
}
|
||||||
|
_, err := p.backendLinks().SetLink(ctx, tx, ids[0], admin.ID, by)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case errors.Is(err, classes.ErrAlreadyLinked):
|
||||||
|
return pact.AdminRecordActionResult{}, payloadError("already_linked")
|
||||||
|
case errors.Is(err, classes.ErrFrontendUserUnavailable):
|
||||||
|
return pact.AdminRecordActionResult{}, payloadError("unknown_user")
|
||||||
|
case errors.Is(err, classes.ErrBackendUserUnavailable):
|
||||||
|
return pact.AdminRecordActionResult{}, &cabana.ForbiddenError{}
|
||||||
|
default:
|
||||||
|
return pact.AdminRecordActionResult{}, err
|
||||||
|
}
|
||||||
|
return pact.AdminRecordActionResult{Message: backendLinkLang + "set_success"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeFrontendLink removes the administrator record's link.
|
||||||
|
func (p *Plugin) removeFrontendLink(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||||
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return pact.AdminRecordActionResult{}, errors.New("golem15.user: unlinking needs the action's transaction")
|
||||||
|
}
|
||||||
|
link, err := p.adminRecordLink(ctx, in.Record)
|
||||||
|
if err != nil {
|
||||||
|
return pact.AdminRecordActionResult{}, err
|
||||||
|
}
|
||||||
|
if err := p.backendLinks().RemoveLink(ctx, tx, link); err != nil {
|
||||||
|
return pact.AdminRecordActionResult{}, err
|
||||||
|
}
|
||||||
|
return pact.AdminRecordActionResult{Message: backendLinkLang + "remove_success"}, nil
|
||||||
|
}
|
||||||
61
blacklist.go
Normal file
61
blacklist.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ bouncer.BlacklistStore = (*appBlacklist)(nil)
|
||||||
|
|
||||||
|
// appBlacklist is the blacklist store the jwt guard is built with. The guard
|
||||||
|
// is built in Boot, which under summer serve runs before the database is
|
||||||
|
// published, so the store the plugin publishes on lagoon.Publish is resolved
|
||||||
|
// on every call. While none is published, no token counts as blacklisted and
|
||||||
|
// Add and Sweep do nothing. An appBlacklist must never be published as the
|
||||||
|
// app's bouncer.BlacklistStore: the lookup would then find itself.
|
||||||
|
type appBlacklist struct {
|
||||||
|
app *backpack.App
|
||||||
|
}
|
||||||
|
|
||||||
|
// store returns the published blacklist store, or nil when there is none
|
||||||
|
// (or the published value is an appBlacklist).
|
||||||
|
func (b *appBlacklist) store() bouncer.BlacklistStore {
|
||||||
|
if b == nil || b.app == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s, ok := b.app.Lookup[bouncer.BlacklistStore]()
|
||||||
|
if !ok || s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, self := s.(*appBlacklist); self {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *appBlacklist) Add(ctx context.Context, jti string, expiresAt, validUntil time.Time) error {
|
||||||
|
s := b.store()
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.Add(ctx, jti, expiresAt, validUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *appBlacklist) IsBlacklisted(ctx context.Context, jti string) (bool, error) {
|
||||||
|
s := b.store()
|
||||||
|
if s == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return s.IsBlacklisted(ctx, jti)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *appBlacklist) Sweep(ctx context.Context, now time.Time) error {
|
||||||
|
s := b.store()
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.Sweep(ctx, now)
|
||||||
|
}
|
||||||
86
blacklist_test.go
Normal file
86
blacklist_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAppBlacklist: the jwt guard's store resolves the app's published
|
||||||
|
// bouncer.BlacklistStore on every call. With none published (or only an
|
||||||
|
// appBlacklist, which would recurse) no token is blacklisted and Add and
|
||||||
|
// Sweep do nothing; otherwise calls are delegated and a store error comes
|
||||||
|
// back unchanged, so the guard still fails closed.
|
||||||
|
func TestAppBlacklist(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
now := time.Now()
|
||||||
|
newApp := func() *appBlacklist {
|
||||||
|
return &appBlacklist{app: sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)}
|
||||||
|
}
|
||||||
|
assertNoStore := func(t *testing.T, bl *appBlacklist) {
|
||||||
|
t.Helper()
|
||||||
|
if got, err := bl.IsBlacklisted(ctx, "jti-none"); got || err != nil {
|
||||||
|
t.Fatalf("IsBlacklisted with no store = %v, %v", got, err)
|
||||||
|
}
|
||||||
|
if err := bl.Add(ctx, "jti-none", now.Add(time.Hour), now); err != nil {
|
||||||
|
t.Fatalf("Add with no store = %v", err)
|
||||||
|
}
|
||||||
|
if err := bl.Sweep(ctx, now); err != nil {
|
||||||
|
t.Fatalf("Sweep with no store = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no store", func(t *testing.T) {
|
||||||
|
assertNoStore(t, newApp())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrapper published as the store", func(t *testing.T) {
|
||||||
|
bl := newApp()
|
||||||
|
if err := bl.app.Publish[bouncer.BlacklistStore](&appBlacklist{app: bl.app}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertNoStore(t, bl)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("delegates", func(t *testing.T) {
|
||||||
|
bl := newApp()
|
||||||
|
mem := bouncer.NewMemoryBlacklist()
|
||||||
|
if err := bl.app.Publish[bouncer.BlacklistStore](mem); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Blacklisted from now on, kept for an hour.
|
||||||
|
if err := bl.Add(ctx, "jti-ended", now.Add(time.Hour), now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := mem.IsBlacklisted(ctx, "jti-ended"); !got || err != nil {
|
||||||
|
t.Fatalf("memory store after Add through the wrapper = %v, %v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := bl.IsBlacklisted(ctx, "jti-ended"); !got || err != nil {
|
||||||
|
t.Fatalf("IsBlacklisted(jti-ended) = %v, %v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := bl.IsBlacklisted(ctx, "jti-other"); got || err != nil {
|
||||||
|
t.Fatalf("IsBlacklisted(jti-other) = %v, %v", got, err)
|
||||||
|
}
|
||||||
|
if err := bl.Sweep(ctx, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("store error passes through", func(t *testing.T) {
|
||||||
|
bl := newApp()
|
||||||
|
if err := bl.app.Publish[bouncer.BlacklistStore](failingBlacklist{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := bl.IsBlacklisted(ctx, "jti"); !errors.Is(err, errBlacklistDown) {
|
||||||
|
t.Fatalf("IsBlacklisted error = %v", err)
|
||||||
|
}
|
||||||
|
if err := bl.Add(ctx, "jti", now.Add(time.Hour), now); !errors.Is(err, errBlacklistDown) {
|
||||||
|
t.Fatalf("Add error = %v", err)
|
||||||
|
}
|
||||||
|
if err := bl.Sweep(ctx, now); !errors.Is(err, errBlacklistDown) {
|
||||||
|
t.Fatalf("Sweep error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package classes
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -91,8 +90,41 @@ func ActivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int,
|
|||||||
return changed, nil
|
return changed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RevokeUserAccess ends every session JWT issued before now and every
|
||||||
|
// personal API token of userIDs: it stamps users.tokens_valid_after with the
|
||||||
|
// current time truncated to whole seconds (soft-deleted rows included) and
|
||||||
|
// sets revoked_at on each of their user_api_tokens rows that is not revoked
|
||||||
|
// yet (earlier revoked_at values stay). The jwt guard, RequestUser and
|
||||||
|
// /refresh refuse a token whose iat precedes tokens_valid_after; the
|
||||||
|
// user.api_token guard refuses a revoked row. JWT iat has whole-second
|
||||||
|
// precision, so the cutoff is truncated: a token minted right after the
|
||||||
|
// revocation (a reactivating login) is valid, and so is one minted earlier
|
||||||
|
// within that same second. An empty list writes nothing. It takes the
|
||||||
|
// handle of the caller's transaction. DeactivateUsers and BanUsers call it;
|
||||||
|
// an application may call it for its own offboarding, and after writing a
|
||||||
|
// ban directly to user_throttle.
|
||||||
|
func RevokeUserAccess(ctx context.Context, db *gorm.DB, userIDs []uint) error {
|
||||||
|
if db == nil {
|
||||||
|
return fmt.Errorf("golem15.user: gorm handle is missing")
|
||||||
|
}
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Truncate(time.Second)
|
||||||
|
if err := fresh(ctx, db).Unscoped().Model(&models.User{}).Where("id IN ?", userIDs).UpdateColumn("tokens_valid_after", cutoff).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fresh(ctx, db).Model(&models.APIToken{}).
|
||||||
|
Where("user_id IN ? AND revoked_at IS NULL", userIDs).
|
||||||
|
UpdateColumn("revoked_at", cutoff).Error
|
||||||
|
}
|
||||||
|
|
||||||
// DeactivateUsers soft-deletes the users that are not deactivated yet, the
|
// DeactivateUsers soft-deletes the users that are not deactivated yet, the
|
||||||
// PHP $user->delete(), and returns how many it changed.
|
// PHP $user->delete(), and returns how many it changed. It then ends the
|
||||||
|
// sessions and personal API tokens of every selected user, already
|
||||||
|
// deactivated ones included (RevokeUserAccess), so a deactivated account
|
||||||
|
// keeps no working credential: its owner signs in again, which restores
|
||||||
|
// it, and gets a fresh token. Restoring revives no revoked token.
|
||||||
func DeactivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
|
func DeactivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return 0, fmt.Errorf("golem15.user: gorm handle is missing")
|
return 0, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||||
@@ -110,6 +142,13 @@ func DeactivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (in
|
|||||||
user.DeletedAt = gorm.DeletedAt{Time: now, Valid: true}
|
user.DeletedAt = gorm.DeletedAt{Time: now, Valid: true}
|
||||||
changed++
|
changed++
|
||||||
}
|
}
|
||||||
|
ids, err := userIDs(db, users)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := RevokeUserAccess(ctx, db, ids); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
return changed, nil
|
return changed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +176,9 @@ func RestoreUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int,
|
|||||||
// BanUsers bans the users and returns how many were not banned before. A ban
|
// BanUsers bans the users and returns how many were not banned before. A ban
|
||||||
// is set on every user_throttle row of the user, and a row without an IP
|
// is set on every user_throttle row of the user, and a row without an IP
|
||||||
// address is created when the user has none, so a login from any address is
|
// address is created when the user has none, so a login from any address is
|
||||||
// refused (RejectIfThrottled falls back to that row).
|
// refused (RejectIfThrottled falls back to that row). It then ends the
|
||||||
|
// sessions and personal API tokens of every selected user, already banned
|
||||||
|
// ones included (RevokeUserAccess); unbanning revives none of them.
|
||||||
func BanUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
|
func BanUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
|
||||||
ids, err := userIDs(db, users)
|
ids, err := userIDs(db, users)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -165,6 +206,9 @@ func BanUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, erro
|
|||||||
changed++
|
changed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := RevokeUserAccess(ctx, db, ids); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
return changed, nil
|
return changed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +285,7 @@ func ForceDeleteCleanup(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, u
|
|||||||
// The rows are gone by now, so a blob that cannot be removed is
|
// The rows are gone by now, so a blob that cannot be removed is
|
||||||
// orphaned, not lost data: log it and carry on.
|
// orphaned, not lost data: log it and carry on.
|
||||||
if err := attach.DeleteKeys(ctx, bucket, keys); err != nil {
|
if err := attach.DeleteKeys(ctx, bucket, keys); err != nil {
|
||||||
slog.Default().Warn("golem15.user: remove attachment blobs of a deleted user", "user_id", userID, "err", err)
|
pluginLog.Warn("golem15.user: remove attachment blobs of a deleted user", "user_id", userID, "err", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -437,3 +437,126 @@ func TestAdminActions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRevokeUserAccess: the cutoff is stamped in whole seconds on active and
|
||||||
|
// deactivated users, every live API token of the users is revoked, earlier
|
||||||
|
// revocations keep their time, other users are untouched, and DeactivateUsers
|
||||||
|
// and BanUsers revoke for every selected user with unchanged counts.
|
||||||
|
func TestRevokeUserAccess(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
db := throttleDB(t)
|
||||||
|
token := func(t *testing.T, user *models.User, revoked *time.Time) models.APIToken {
|
||||||
|
t.Helper()
|
||||||
|
row := models.APIToken{UserID: user.ID, TokenHash: "hash-" + strconv.FormatUint(uint64(user.ID), 10) + "-" + strconv.FormatInt(time.Now().UnixNano(), 10), RevokedAt: revoked}
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
reloadToken := func(t *testing.T, id uint) models.APIToken {
|
||||||
|
t.Helper()
|
||||||
|
var row models.APIToken
|
||||||
|
if err := db.Take(&row, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a missing handle is an error and an empty list writes nothing", func(t *testing.T) {
|
||||||
|
if err := RevokeUserAccess(ctx, nil, []uint{1}); err == nil {
|
||||||
|
t.Fatal("nil handle accepted")
|
||||||
|
}
|
||||||
|
if err := RevokeUserAccess(ctx, db, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stamps the cutoff and revokes live tokens only", func(t *testing.T) {
|
||||||
|
active := actionUser(t, db, "revoke-active", nil)
|
||||||
|
gone := actionUser(t, db, "revoke-gone", func(u *models.User) {
|
||||||
|
u.DeletedAt = gorm.DeletedAt{Time: time.Now(), Valid: true}
|
||||||
|
})
|
||||||
|
other := actionUser(t, db, "revoke-other", nil)
|
||||||
|
earlier := time.Now().Add(-time.Hour).Truncate(time.Microsecond)
|
||||||
|
live := token(t, active, nil)
|
||||||
|
old := token(t, active, &earlier)
|
||||||
|
goneTok := token(t, gone, nil)
|
||||||
|
otherTok := token(t, other, nil)
|
||||||
|
before := time.Now().Truncate(time.Second)
|
||||||
|
if err := RevokeUserAccess(ctx, db, []uint{active.ID, gone.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after := time.Now()
|
||||||
|
for _, id := range []uint{active.ID, gone.ID} {
|
||||||
|
row := actionReload(t, db, id)
|
||||||
|
if row.TokensValidAfter == nil || row.TokensValidAfter.Before(before) || row.TokensValidAfter.After(after) ||
|
||||||
|
!row.TokensValidAfter.Equal(row.TokensValidAfter.Truncate(time.Second)) {
|
||||||
|
t.Fatalf("user %d tokens_valid_after = %v", id, row.TokensValidAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row := actionReload(t, db, other.ID); row.TokensValidAfter != nil {
|
||||||
|
t.Fatalf("other user stamped: %v", row.TokensValidAfter)
|
||||||
|
}
|
||||||
|
for _, id := range []uint{live.ID, goneTok.ID} {
|
||||||
|
if row := reloadToken(t, id); row.RevokedAt == nil {
|
||||||
|
t.Fatalf("token %d not revoked", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row := reloadToken(t, old.ID); row.RevokedAt == nil || !row.RevokedAt.Equal(earlier) {
|
||||||
|
t.Fatalf("earlier revocation changed: %v want %v", row.RevokedAt, earlier)
|
||||||
|
}
|
||||||
|
if row := reloadToken(t, otherTok.ID); row.RevokedAt != nil {
|
||||||
|
t.Fatalf("other user's token revoked")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deactivate and ban revoke for every selected user", func(t *testing.T) {
|
||||||
|
fresh := actionUser(t, db, "revoke-deactivate", nil)
|
||||||
|
already := actionUser(t, db, "revoke-already", func(u *models.User) {
|
||||||
|
u.DeletedAt = gorm.DeletedAt{Time: time.Now(), Valid: true}
|
||||||
|
})
|
||||||
|
a, b := token(t, fresh, nil), token(t, already, nil)
|
||||||
|
changed, err := DeactivateUsers(ctx, db, []*models.User{fresh, already})
|
||||||
|
if err != nil || changed != 1 {
|
||||||
|
t.Fatalf("DeactivateUsers = %d, %v", changed, err)
|
||||||
|
}
|
||||||
|
for _, id := range []uint{a.ID, b.ID} {
|
||||||
|
if row := reloadToken(t, id); row.RevokedAt == nil {
|
||||||
|
t.Fatalf("token %d survived deactivate", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range []uint{fresh.ID, already.ID} {
|
||||||
|
if row := actionReload(t, db, id); row.TokensValidAfter == nil {
|
||||||
|
t.Fatalf("user %d not stamped by deactivate", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ban := actionUser(t, db, "revoke-ban", nil)
|
||||||
|
bannedBefore := actionUser(t, db, "revoke-banned-before", nil)
|
||||||
|
if err := db.Create(&models.Throttle{UserID: bannedBefore.ID, IsBanned: true}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, d := token(t, ban, nil), token(t, bannedBefore, nil)
|
||||||
|
changed, err = BanUsers(ContextWithThrottle(ctx, nil), db, []*models.User{ban, bannedBefore})
|
||||||
|
if err != nil || changed != 1 {
|
||||||
|
t.Fatalf("BanUsers = %d, %v", changed, err)
|
||||||
|
}
|
||||||
|
for _, id := range []uint{c.ID, d.ID} {
|
||||||
|
if row := reloadToken(t, id); row.RevokedAt == nil {
|
||||||
|
t.Fatalf("token %d survived ban", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unban and restore revive nothing.
|
||||||
|
if _, err := UnbanUsers(ctx, db, []*models.User{ban}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := RestoreUsers(ctx, db, []*models.User{fresh}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, id := range []uint{a.ID, c.ID} {
|
||||||
|
if row := reloadToken(t, id); row.RevokedAt == nil {
|
||||||
|
t.Fatalf("token %d revived", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
48
classes/authorizer.go
Normal file
48
classes/authorizer.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lighthouse"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthorizeUser is the lighthouse authorizer for the user namespace
|
||||||
|
// (D-17, T-19-10). It allows only user:{id} for that user. ChannelID is
|
||||||
|
// the two-segment reader (segment 1 is the id). A second presence: prefix
|
||||||
|
// or extra segments are denied.
|
||||||
|
func AuthorizeUser() lighthouse.AuthorizerFunc {
|
||||||
|
return func(_ context.Context, userID uint, channel string) lighthouse.Result {
|
||||||
|
name := strings.TrimPrefix(channel, "presence:")
|
||||||
|
if strings.HasPrefix(name, "presence:") {
|
||||||
|
return lighthouse.Denied("invalid user channel")
|
||||||
|
}
|
||||||
|
parts := strings.Split(name, ":")
|
||||||
|
if len(parts) != 2 || parts[0] != "user" {
|
||||||
|
return lighthouse.Denied("invalid user channel")
|
||||||
|
}
|
||||||
|
if userID == 0 || lighthouse.ChannelID(name) != int64(userID) {
|
||||||
|
return lighthouse.Denied("not that user")
|
||||||
|
}
|
||||||
|
return lighthouse.Allowed(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterUserAuthorizer registers the user namespace if it is free. A
|
||||||
|
// missing lighthouse service is skipped so Boot stays additive on hosts
|
||||||
|
// that never mount realtime.
|
||||||
|
func RegisterUserAuthorizer(app *backpack.App) error {
|
||||||
|
svc, err := lighthouse.From(app)
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.Warn("golem15.user: lighthouse unavailable; user authorizer not registered", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if svc == nil || svc.Registry() == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := svc.Registry().Get("user"); ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return svc.Registry().Register("user", AuthorizeUser())
|
||||||
|
}
|
||||||
36
classes/authorizer_test.go
Normal file
36
classes/authorizer_test.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lighthouse"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUserAuthorizer: user:9 is allowed only for user 9 (T-19-10).
|
||||||
|
func TestUserAuthorizer(t *testing.T) {
|
||||||
|
auth := AuthorizeUser()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if got := auth.Authorize(ctx, 9, "user:9"); !got.Allowed {
|
||||||
|
t.Fatalf("user:9 for 9 denied: %s", got.Reason())
|
||||||
|
}
|
||||||
|
if got := auth.Authorize(ctx, 9, "presence:user:9"); !got.Allowed {
|
||||||
|
t.Fatalf("presence:user:9 for 9 denied: %s", got.Reason())
|
||||||
|
}
|
||||||
|
if lighthouse.ChannelID("user:9") != 9 {
|
||||||
|
t.Fatalf("ChannelID(user:9) = %d, want 9", lighthouse.ChannelID("user:9"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, channel := range []string{"user:8", "user:9:extra", "chat:9", "user:", "user", "presence:presence:user:9"} {
|
||||||
|
if got := auth.Authorize(ctx, 9, channel); got.Allowed {
|
||||||
|
t.Errorf("%q for 9 allowed, want deny", channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := auth.Authorize(ctx, 8, "user:9"); got.Allowed {
|
||||||
|
t.Fatal("user:9 for 8 allowed, want deny")
|
||||||
|
}
|
||||||
|
if got := auth.Authorize(ctx, 0, "user:0"); got.Allowed {
|
||||||
|
t.Fatal("user:0 for 0 allowed, want deny")
|
||||||
|
}
|
||||||
|
}
|
||||||
64
classes/avatar.go
Normal file
64
classes/avatar.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AvatarURL returns the display picture URL of user at size pixels, the
|
||||||
|
// rule the user API payload follows: the thumbnail (size by size, mode
|
||||||
|
// auto) of the user's avatar attachment when there is one and a file
|
||||||
|
// bucket is published, the attachment's file path when the thumbnail
|
||||||
|
// fails (logged), and otherwise the gravatar URL
|
||||||
|
// //www.gravatar.com/avatar/{md5 of the email}?s={size}&d=mm. The URL may
|
||||||
|
// be protocol-relative or relative; a caller that needs an absolute URL
|
||||||
|
// resolves it. Only a failing database lookup is an error.
|
||||||
|
func AvatarURL(ctx context.Context, app *backpack.App, user *models.User, size int) (string, error) {
|
||||||
|
if user == nil {
|
||||||
|
return "", errors.New("golem15.user: avatar needs a user")
|
||||||
|
}
|
||||||
|
fallback := gravatar(user.Email, size)
|
||||||
|
if app == nil || user.ID == 0 {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
gdb, ok := app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || gdb == nil {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
var f attach.File
|
||||||
|
err := gdb.WithContext(ctx).
|
||||||
|
Where("attachment_type = ? AND attachment_id = ? AND field = ?", user.MorphName(), strconv.FormatUint(uint64(user.ID), 10), models.AvatarField).
|
||||||
|
Take(&f).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
bucket, ok := app.Lookup[*blob.Bucket]()
|
||||||
|
if !ok || bucket == nil {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
url, err := f.Thumb(ctx, bucket, size, size, "auto")
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.Error("golem15.user: avatar thumb", "err", err)
|
||||||
|
return "/" + attach.BlobKey(f.DiskName), nil
|
||||||
|
}
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gravatar is the gravatar URL of an email at size pixels.
|
||||||
|
func gravatar(email string, size int) string {
|
||||||
|
sum := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email))))
|
||||||
|
return fmt.Sprintf("//www.gravatar.com/avatar/%x?s=%d&d=mm", sum, size)
|
||||||
|
}
|
||||||
507
classes/backend_links.go
Normal file
507
classes/backend_links.go
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors of the account link service. A request that is unknown, used or
|
||||||
|
// expired is reported apart so callers can log the difference; the site
|
||||||
|
// routes answer all three with the same 404.
|
||||||
|
var (
|
||||||
|
ErrLinkRequestNotFound = errors.New("golem15.user: backend link request not found")
|
||||||
|
ErrLinkRequestUsed = errors.New("golem15.user: backend link request already used")
|
||||||
|
ErrLinkRequestExpired = errors.New("golem15.user: backend link request expired")
|
||||||
|
ErrStaleSession = errors.New("golem15.user: the session was signed in before the link request")
|
||||||
|
ErrAlreadyLinked = errors.New("golem15.user: one of the accounts is already linked")
|
||||||
|
ErrBackendUserUnavailable = errors.New("golem15.user: the administrator is missing or not activated")
|
||||||
|
ErrFrontendUserUnavailable = errors.New("golem15.user: the frontend user is missing or deactivated")
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultBackendLinkRequestTTL is how long a link request stays usable when
|
||||||
|
// golem15.user.backend_links.request_ttl is not set.
|
||||||
|
const DefaultBackendLinkRequestTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
var _ pact.MCPLinkedBackendResolver = (*BackendLinks)(nil)
|
||||||
|
|
||||||
|
// BackendLinks is the verified link between a frontend user and a backend
|
||||||
|
// administrator (D-03): one link per account on each side, written either
|
||||||
|
// after the frontend user proves the account with a fresh sign-in
|
||||||
|
// (CreateLinkRequest, then ConfirmLinkRequest) or by an administrator with
|
||||||
|
// golem15.users.manage_backend_links on the Administrators screen (SetLink).
|
||||||
|
// Boot publishes it as *BackendLinks and as pact.MCPLinkedBackendResolver.
|
||||||
|
//
|
||||||
|
// Every read and write uses the transaction cabana runs a hook or an action
|
||||||
|
// in when the context carries one (cabana.TxFromContext), else the
|
||||||
|
// application's database.
|
||||||
|
type BackendLinks struct {
|
||||||
|
App *backpack.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
|
||||||
|
// lifetime.
|
||||||
|
func BackendLinkRequestTTL(app *backpack.App) (time.Duration, error) {
|
||||||
|
if app == nil || app.Config == nil {
|
||||||
|
return DefaultBackendLinkRequestTTL, nil
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(app.Config.String("golem15.user.backend_links.request_ttl"))
|
||||||
|
if raw == "" {
|
||||||
|
return DefaultBackendLinkRequestTTL, nil
|
||||||
|
}
|
||||||
|
ttl, err := time.ParseDuration(raw)
|
||||||
|
if err != nil || ttl <= 0 {
|
||||||
|
return 0, fmt.Errorf("golem15.user: backend_links.request_ttl must be a positive duration such as 10m, got %q", raw)
|
||||||
|
}
|
||||||
|
return ttl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func FrontendURL(app *backpack.App) string {
|
||||||
|
if app == nil || app.Config == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
base := strings.TrimSpace(app.Config.String("golem15.user.frontend_url"))
|
||||||
|
if base == "" {
|
||||||
|
base = strings.TrimSpace(app.Config.String("app.url"))
|
||||||
|
}
|
||||||
|
return strings.TrimRight(base, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// db returns the handle a call works on: the cabana transaction on ctx, or
|
||||||
|
// the application's database.
|
||||||
|
func (s *BackendLinks) db(ctx context.Context) (*gorm.DB, error) {
|
||||||
|
if tx, ok := cabana.TxFromContext(ctx); ok {
|
||||||
|
return fresh(ctx, tx), nil
|
||||||
|
}
|
||||||
|
if s == nil || s.App == nil {
|
||||||
|
return nil, fmt.Errorf("golem15.user: app is nil")
|
||||||
|
}
|
||||||
|
gdb, ok := s.App.Lookup[*gorm.DB]()
|
||||||
|
if !ok || gdb == nil {
|
||||||
|
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||||
|
}
|
||||||
|
return gdb.WithContext(ctx), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashLinkCode is the only form of a link code that is stored.
|
||||||
|
func hashLinkCode(plain string) string {
|
||||||
|
sum := sha256.Sum256([]byte(plain))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// validLinkCode reports whether plain has the shape CreateLinkRequest
|
||||||
|
// issues: 64 lower-case hex characters.
|
||||||
|
func validLinkCode(plain string) bool {
|
||||||
|
if len(plain) != 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range plain {
|
||||||
|
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLinkRequest starts a link for the administrator: a one-shot code
|
||||||
|
// (32 random bytes, hex) valid for golem15.user.backend_links.request_ttl,
|
||||||
|
// stored as its SHA-256. The administrator's earlier requests that were not
|
||||||
|
// used are dropped, so only the newest code works. The plain code is
|
||||||
|
// returned once, for the site URL.
|
||||||
|
func (s *BackendLinks) CreateLinkRequest(ctx context.Context, backendUserID uint) (string, time.Time, error) {
|
||||||
|
if backendUserID == 0 {
|
||||||
|
return "", time.Time{}, ErrBackendUserUnavailable
|
||||||
|
}
|
||||||
|
ttl, err := BackendLinkRequestTTL(s.app())
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
db, err := s.db(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
plain, err := newCode()
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
req := &models.BackendLinkRequest{
|
||||||
|
CodeHash: hashLinkCode(plain),
|
||||||
|
BackendUserID: backendUserID,
|
||||||
|
ExpiresAt: now.Add(ttl),
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
if err := fresh(ctx, db).Where("backend_user_id = ? AND used_at IS NULL", backendUserID).
|
||||||
|
Delete(&models.BackendLinkRequest{}).Error; err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
if err := fresh(ctx, db).Create(req).Error; err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
return plain, req.ExpiresAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkRequestByCode returns a usable request and its administrator for the
|
||||||
|
// site's link page. An unknown, used or expired code is
|
||||||
|
// ErrLinkRequestNotFound, ErrLinkRequestUsed or ErrLinkRequestExpired; an
|
||||||
|
// administrator who was deactivated or deleted since is
|
||||||
|
// ErrBackendUserUnavailable.
|
||||||
|
func (s *BackendLinks) LinkRequestByCode(ctx context.Context, plain string) (*models.BackendLinkRequest, *cabana.BackendUser, error) {
|
||||||
|
if !validLinkCode(plain) {
|
||||||
|
return nil, nil, ErrLinkRequestNotFound
|
||||||
|
}
|
||||||
|
db, err := s.db(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var rows []models.BackendLinkRequest
|
||||||
|
if err := fresh(ctx, db).Where("code_hash = ?", hashLinkCode(plain)).Limit(1).Find(&rows).Error; err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, nil, ErrLinkRequestNotFound
|
||||||
|
}
|
||||||
|
req := &rows[0]
|
||||||
|
if err := requestUsable(req, time.Now()); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
admin, err := activeBackendUser(ctx, db, req.BackendUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return req, admin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestUsable(req *models.BackendLinkRequest, now time.Time) error {
|
||||||
|
if req.UsedAt != nil {
|
||||||
|
return ErrLinkRequestUsed
|
||||||
|
}
|
||||||
|
if !req.ExpiresAt.After(now) {
|
||||||
|
return ErrLinkRequestExpired
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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) {
|
||||||
|
if !validLinkCode(plain) {
|
||||||
|
return nil, ErrLinkRequestNotFound
|
||||||
|
}
|
||||||
|
if frontendUserID == 0 {
|
||||||
|
return nil, ErrFrontendUserUnavailable
|
||||||
|
}
|
||||||
|
gdb, err := s.pool()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var link *models.BackendLink
|
||||||
|
err = lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
var rows []models.BackendLinkRequest
|
||||||
|
if err := fresh(ctx, tx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("code_hash = ?", hashLinkCode(plain)).Limit(1).Find(&rows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return ErrLinkRequestNotFound
|
||||||
|
}
|
||||||
|
req := &rows[0]
|
||||||
|
now := time.Now()
|
||||||
|
if err := requestUsable(req, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sessionIssuedAt.IsZero() || sessionIssuedAt.Before(req.CreatedAt.Truncate(time.Second)) {
|
||||||
|
return ErrStaleSession
|
||||||
|
}
|
||||||
|
if _, err := activeBackendUser(ctx, tx, req.BackendUserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
created, err := s.insertLink(ctx, tx, frontendUserID, req.BackendUserID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fresh(ctx, tx).Model(&models.BackendLinkRequest{}).Where("id = ?", req.ID).
|
||||||
|
Update("used_at", now).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
link = created
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLink stores a link an administrator sets on the Administrators screen
|
||||||
|
// (D-07): the frontend user must exist and not be deactivated
|
||||||
|
// (ErrFrontendUserUnavailable), the administrator must be activated
|
||||||
|
// (ErrBackendUserUnavailable) and neither may be linked already
|
||||||
|
// (ErrAlreadyLinked). linkedBy is recorded (0 records none). It runs in tx,
|
||||||
|
// fires BackendLinkedEvent there and the two pact.MCPSubjectChanged events
|
||||||
|
// after tx commits.
|
||||||
|
func (s *BackendLinks) SetLink(ctx context.Context, tx *gorm.DB, frontendUserID, backendUserID, linkedBy uint) (*models.BackendLink, error) {
|
||||||
|
if tx == nil {
|
||||||
|
return nil, fmt.Errorf("golem15.user: setting a link needs a transaction")
|
||||||
|
}
|
||||||
|
var users []models.User
|
||||||
|
if err := fresh(ctx, tx).Where("id = ?", frontendUserID).Limit(1).Find(&users).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if frontendUserID == 0 || len(users) == 0 {
|
||||||
|
return nil, ErrFrontendUserUnavailable
|
||||||
|
}
|
||||||
|
if _, err := activeBackendUser(ctx, tx, backendUserID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var by *uint
|
||||||
|
if linkedBy != 0 {
|
||||||
|
by = &linkedBy
|
||||||
|
}
|
||||||
|
return s.insertLink(ctx, tx, frontendUserID, backendUserID, by)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertLink writes one link in tx after checking both sides are free; a
|
||||||
|
// concurrent writer that wins the unique index also ends in
|
||||||
|
// ErrAlreadyLinked.
|
||||||
|
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{}).
|
||||||
|
Where("user_id = ? OR backend_user_id = ?", frontendUserID, backendUserID).
|
||||||
|
Count(&taken).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if taken > 0 {
|
||||||
|
return nil, ErrAlreadyLinked
|
||||||
|
}
|
||||||
|
link := &models.BackendLink{UserID: frontendUserID, BackendUserID: backendUserID, LinkedBy: linkedBy, CreatedAt: time.Now()}
|
||||||
|
if err := fresh(ctx, tx).Create(link).Error; err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return nil, ErrAlreadyLinked
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
app := s.app()
|
||||||
|
if app != nil && app.Events != nil {
|
||||||
|
if err := app.Events.Fire(ctx, &BackendLinkedEvent{Link: link, Tx: tx}); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", BackendLinkedEventName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardUser, link.UserID, pact.MCPSubjectLink)
|
||||||
|
FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardBackend, link.BackendUserID, pact.MCPSubjectLink)
|
||||||
|
return link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveLink deletes link in tx, fires BackendUnlinkedEvent 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 {
|
||||||
|
if link == nil || link.ID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if tx == nil {
|
||||||
|
return fmt.Errorf("golem15.user: removing a link needs a transaction")
|
||||||
|
}
|
||||||
|
res := fresh(ctx, tx).Where("id = ?", link.ID).Delete(&models.BackendLink{})
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
app := s.app()
|
||||||
|
if app != nil && app.Events != nil {
|
||||||
|
if err := app.Events.Fire(ctx, &BackendUnlinkedEvent{Link: link, Tx: tx}); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", BackendUnlinkedEventName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardUser, link.UserID, pact.MCPSubjectLink)
|
||||||
|
FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardBackend, link.BackendUserID, pact.MCPSubjectLink)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkFor returns the link of the administrator, or nil.
|
||||||
|
func (s *BackendLinks) LinkFor(ctx context.Context, backendUserID uint) (*models.BackendLink, error) {
|
||||||
|
return s.linkWhere(ctx, "backend_user_id = ?", backendUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkOf returns the link of the frontend user, or nil.
|
||||||
|
func (s *BackendLinks) LinkOf(ctx context.Context, frontendUserID uint) (*models.BackendLink, error) {
|
||||||
|
return s.linkWhere(ctx, "user_id = ?", frontendUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackendLinks) linkWhere(ctx context.Context, where string, id uint) (*models.BackendLink, error) {
|
||||||
|
if id == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
db, err := s.db(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rows []models.BackendLink
|
||||||
|
if err := fresh(ctx, db).Where(where, id).Limit(1).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &rows[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (s *BackendLinks) LinkedBackendUser(ctx context.Context, frontendUserID uint) (pact.LinkedBackendUser, bool, error) {
|
||||||
|
if frontendUserID == 0 {
|
||||||
|
return pact.LinkedBackendUser{}, false, nil
|
||||||
|
}
|
||||||
|
db, err := s.db(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return pact.LinkedBackendUser{}, false, err
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
ID uint
|
||||||
|
Login string
|
||||||
|
FirstName *string
|
||||||
|
LastName *string
|
||||||
|
}
|
||||||
|
err = fresh(ctx, db).Raw(`SELECT b.id, b.login, b.first_name, b.last_name
|
||||||
|
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
|
||||||
|
LIMIT 1`, frontendUserID).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return pact.LinkedBackendUser{}, false, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return pact.LinkedBackendUser{}, false, nil
|
||||||
|
}
|
||||||
|
row := rows[0]
|
||||||
|
return pact.LinkedBackendUser{ID: row.ID, Login: row.Login, DisplayName: backendDisplayName(deref(row.FirstName), deref(row.LastName), row.Login)}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendDisplayName is the name the link pages show for an administrator:
|
||||||
|
// first and last name, else the login.
|
||||||
|
func BackendDisplayName(admin *cabana.BackendUser) string {
|
||||||
|
if admin == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return backendDisplayName(admin.FirstName, admin.LastName, admin.Login)
|
||||||
|
}
|
||||||
|
|
||||||
|
func backendDisplayName(first, last, login string) string {
|
||||||
|
if name := strings.TrimSpace(strings.TrimSpace(first) + " " + strings.TrimSpace(last)); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return login
|
||||||
|
}
|
||||||
|
|
||||||
|
func deref(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeBackendUser loads an activated, not deleted administrator, else
|
||||||
|
// ErrBackendUserUnavailable.
|
||||||
|
func activeBackendUser(ctx context.Context, db *gorm.DB, id uint) (*cabana.BackendUser, error) {
|
||||||
|
if id == 0 {
|
||||||
|
return nil, ErrBackendUserUnavailable
|
||||||
|
}
|
||||||
|
var rows []cabana.BackendUser
|
||||||
|
if err := fresh(ctx, db).Where("id = ? AND is_activated = TRUE", id).Limit(1).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, ErrBackendUserUnavailable
|
||||||
|
}
|
||||||
|
return &rows[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterBackendLinkListeners removes a frontend user's link inside the
|
||||||
|
// transaction that deletes (DeletingEvent) or deactivates
|
||||||
|
// (DeactivatingEvent) the user, and fires pact.MCPSubjectChanged deleted
|
||||||
|
// for the user and link for the administrator after it commits.
|
||||||
|
func (s *BackendLinks) RegisterBackendLinkListeners(app *backpack.App) {
|
||||||
|
if app == nil || app.Events == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const owner = "golem15.user.backend_links"
|
||||||
|
app.Events.Listen(owner, func(ctx context.Context, e *DeletingEvent) error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.dropUserLink(ctx, e.Tx, e.User)
|
||||||
|
})
|
||||||
|
app.Events.Listen(owner, func(ctx context.Context, e *DeactivatingEvent) error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.dropUserLink(ctx, e.Tx, e.User)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackendLinks) dropUserLink(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
||||||
|
if user == nil || user.ID == 0 || tx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rows []models.BackendLink
|
||||||
|
if err := fresh(ctx, tx).Where("user_id = ?", user.ID).Limit(1).Find(&rows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(rows) > 0 {
|
||||||
|
if err := s.RemoveLink(ctx, tx, &rows[0]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FireSubjectChangedAfterCommit(ctx, s.app(), tx, SubjectGuardUser, user.ID, pact.MCPSubjectDeleted)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackendLinks) app() *backpack.App {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackendLinks) pool() (*gorm.DB, error) {
|
||||||
|
if s == nil || s.App == nil {
|
||||||
|
return nil, fmt.Errorf("golem15.user: app is nil")
|
||||||
|
}
|
||||||
|
gdb, ok := s.App.Lookup[*gorm.DB]()
|
||||||
|
if !ok || gdb == nil {
|
||||||
|
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||||
|
}
|
||||||
|
return gdb, nil
|
||||||
|
}
|
||||||
@@ -113,7 +113,9 @@ func VerifyResetCode(ctx context.Context, db *gorm.DB, userID uint, code string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// VerifyActivationCode accepts the stored code inside the activation TTL.
|
// VerifyActivationCode accepts the stored code inside the activation TTL.
|
||||||
// A soft-deleted row is visible and is restored on success.
|
// A soft-deleted row is visible and is restored on success. The code was
|
||||||
|
// mailed to the account's address, so success also records the proof of that
|
||||||
|
// address (email_verified_at, D-27) in the same write that activates it.
|
||||||
func VerifyActivationCode(ctx context.Context, db *gorm.DB, userID uint, code string) (*models.User, bool) {
|
func VerifyActivationCode(ctx context.Context, db *gorm.DB, userID uint, code string) (*models.User, bool) {
|
||||||
user, err := takeUser(ctx, db, userID, true)
|
user, err := takeUser(ctx, db, userID, true)
|
||||||
if err != nil || user == nil || user.IsActivated || user.ActivationCode == nil {
|
if err != nil || user == nil || user.IsActivated || user.ActivationCode == nil {
|
||||||
@@ -134,11 +136,13 @@ func VerifyActivationCode(ctx context.Context, db *gorm.DB, userID uint, code st
|
|||||||
"activated_at": now,
|
"activated_at": now,
|
||||||
"activation_code": nil,
|
"activation_code": nil,
|
||||||
"activation_code_issued_at": nil,
|
"activation_code_issued_at": nil,
|
||||||
|
"email_verified_at": now,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
user.IsActivated = true
|
user.IsActivated = true
|
||||||
user.ActivatedAt = &now
|
user.ActivatedAt = &now
|
||||||
|
user.EmailVerifiedAt = &now
|
||||||
user.ActivationCode = nil
|
user.ActivationCode = nil
|
||||||
user.ActivationCodeIssuedAt = nil
|
user.ActivationCodeIssuedAt = nil
|
||||||
return user, true
|
return user, true
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
package classes
|
package classes
|
||||||
|
|
||||||
import "git.golem15.com/golem15/sm-user-plugin/models"
|
import (
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreatedEventName is the festival name of CreatedEvent (D-13 / RESEARCH A6).
|
||||||
|
const CreatedEventName = "user.Created"
|
||||||
|
|
||||||
|
// DeletingEventName is the festival name of DeletingEvent (D-13 / RESEARCH A6).
|
||||||
|
const DeletingEventName = "user.Deleting"
|
||||||
|
|
||||||
|
// DeactivatingEventName is the festival name of DeactivatingEvent.
|
||||||
|
const DeactivatingEventName = "user.Deactivating"
|
||||||
|
|
||||||
|
// BackendLinkedEventName is the festival name of BackendLinkedEvent.
|
||||||
|
const BackendLinkedEventName = "user.BackendLinked"
|
||||||
|
|
||||||
|
// BackendUnlinkedEventName is the festival name of BackendUnlinkedEvent.
|
||||||
|
const BackendUnlinkedEventName = "user.BackendUnlinked"
|
||||||
|
|
||||||
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
|
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
|
||||||
type GetApiArrayEvent struct {
|
type GetApiArrayEvent struct {
|
||||||
@@ -27,3 +45,48 @@ type RegisterEvent struct {
|
|||||||
// or hashed. Listeners must treat it as read-only input.
|
// or hashed. Listeners must treat it as read-only input.
|
||||||
Payload map[string]any
|
Payload map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreatedEvent fires from every user create path (API register and admin
|
||||||
|
// create) so listeners can run inside the same write, including handle
|
||||||
|
// generation (D-13, D-15). Tx is the create transaction when there is one.
|
||||||
|
type CreatedEvent struct {
|
||||||
|
User *models.User
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletingEvent fires from FormAfterDelete before ForceDeleteCleanup /
|
||||||
|
// unscoped delete, inside the same transaction, so listeners (chatchannels
|
||||||
|
// GDPR) can sever rows before the user row is gone (D-13, T-19-07).
|
||||||
|
type DeletingEvent struct {
|
||||||
|
User *models.User
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivatingEvent fires after a user is soft-deleted (deactivated, the
|
||||||
|
// PHP $user->delete()) by the admin deactivate action, inside the same
|
||||||
|
// transaction, so listeners can revoke what a deactivated account must not
|
||||||
|
// keep (OAuth grants, for example) while leaving what a restore needs. The
|
||||||
|
// user's session JWTs and personal API tokens are already revoked when it
|
||||||
|
// fires (DeactivateUsers calls RevokeUserAccess). A listener error rolls the
|
||||||
|
// deactivation back.
|
||||||
|
type DeactivatingEvent struct {
|
||||||
|
User *models.User
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendLinkedEvent fires inside the write transaction after a link was
|
||||||
|
// stored, by the frontend user's confirmation (Link.LinkedBy nil) or by an
|
||||||
|
// administrator (Link.LinkedBy set). A listener error rolls the link back.
|
||||||
|
type BackendLinkedEvent struct {
|
||||||
|
Link *models.BackendLink
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendUnlinkedEvent fires inside the write transaction after a link was
|
||||||
|
// removed, from My account, the Administrators screen or because one of the
|
||||||
|
// accounts was deleted or deactivated. A listener error rolls the removal
|
||||||
|
// back.
|
||||||
|
type BackendUnlinkedEvent struct {
|
||||||
|
Link *models.BackendLink
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,3 +46,41 @@ func TestRegisterEventFires(t *testing.T) {
|
|||||||
t.Fatalf("event user = %#v", seen)
|
t.Fatalf("event user = %#v", seen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreatedEventFires(t *testing.T) {
|
||||||
|
if CreatedEventName != "user.Created" {
|
||||||
|
t.Fatalf("CreatedEventName = %q, want user.Created", CreatedEventName)
|
||||||
|
}
|
||||||
|
bus := festival.New()
|
||||||
|
var seen *models.User
|
||||||
|
bus.Listen[*CreatedEvent]("golem15.user", func(_ context.Context, e *CreatedEvent) error {
|
||||||
|
seen = e.User
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
user := &models.User{ID: 9, Email: "created@example.test"}
|
||||||
|
if err := bus.Fire(context.Background(), &CreatedEvent{User: user}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seen == nil || seen.Email != user.Email {
|
||||||
|
t.Fatalf("created event user = %#v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletingEventFires(t *testing.T) {
|
||||||
|
if DeletingEventName != "user.Deleting" {
|
||||||
|
t.Fatalf("DeletingEventName = %q, want user.Deleting", DeletingEventName)
|
||||||
|
}
|
||||||
|
bus := festival.New()
|
||||||
|
var seen *models.User
|
||||||
|
bus.Listen[*DeletingEvent]("golem15.user", func(_ context.Context, e *DeletingEvent) error {
|
||||||
|
seen = e.User
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
user := &models.User{ID: 9, Email: "gone@example.test"}
|
||||||
|
if err := bus.Fire(context.Background(), &DeletingEvent{User: user}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seen == nil || seen.Email != user.Email {
|
||||||
|
t.Fatalf("deleting event user = %#v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
59
classes/frontend_permission_catalog.go
Normal file
59
classes/frontend_permission_catalog.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FrontendPermissionCatalog is what the user and user group permission
|
||||||
|
// editors offer (D-09): the rows of golem15_user_frontend_permissions first,
|
||||||
|
// in FrontendPermissionOptions order and unchanged, then the codes activated
|
||||||
|
// plugins declare through pact.HasFrontendPermissions, in activation order.
|
||||||
|
// A declared code that a seeded row already has is skipped (the seeded row
|
||||||
|
// wins), and so is a code an earlier plugin declared; an empty code is
|
||||||
|
// skipped. The table, its rows and the stored permissions are never
|
||||||
|
// written.
|
||||||
|
func FrontendPermissionCatalog(ctx context.Context, app *backpack.App, db *gorm.DB) ([]cabana.PermissionOption, error) {
|
||||||
|
rows, err := FrontendPermissionOptions(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
options := make([]cabana.PermissionOption, 0, len(rows))
|
||||||
|
seen := make(map[string]bool, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab}
|
||||||
|
if row.Comment != nil {
|
||||||
|
option.Comment = *row.Comment
|
||||||
|
}
|
||||||
|
options = append(options, option)
|
||||||
|
seen[row.Code] = true
|
||||||
|
}
|
||||||
|
if app == nil {
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
|
activated, ok := app.Lookup[party.Activated]()
|
||||||
|
if !ok {
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
|
for _, plugin := range activated {
|
||||||
|
declares, ok := plugin.(pact.HasFrontendPermissions)
|
||||||
|
if !ok || declares == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, perm := range declares.FrontendPermissions() {
|
||||||
|
code := strings.TrimSpace(perm.Code)
|
||||||
|
if code == "" || seen[code] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[code] = true
|
||||||
|
options = append(options, cabana.PermissionOption{Code: code, Label: perm.Label, Tab: perm.Tab, Comment: perm.Comment})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
160
classes/handle_generator.go
Normal file
160
classes/handle_generator.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
handleMinLen = 2
|
||||||
|
handleMaxLen = 255
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleGenerator ports the PHP ChatChannels HandleGenerator. The emitted
|
||||||
|
// charset [a-z0-9._-] is a strict subset of MentionParser [A-Za-z0-9._-],
|
||||||
|
// length 2–255, so every generated handle is matchable as an @-token.
|
||||||
|
type HandleGenerator struct{}
|
||||||
|
|
||||||
|
// Slug transliterates name to ASCII, lowercases, drops every rune outside
|
||||||
|
// [a-z0-9._-], then trims leading/trailing `._-`. A result shorter than 2
|
||||||
|
// is not a valid base (Generate falls back to user{id}).
|
||||||
|
func (HandleGenerator) Slug(name string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(name))
|
||||||
|
for _, r := range name {
|
||||||
|
b.WriteString(foldHandleRune(r))
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), "._-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate returns a unique handle for name. The base is Slug(name); if
|
||||||
|
// that is shorter than 2 it falls back to "user{fallbackID}" (or "user"
|
||||||
|
// when no id is known). The smallest free integer suffix is then appended
|
||||||
|
// until isTaken returns false: first "James" → "james", second → "james1".
|
||||||
|
func (g HandleGenerator) Generate(name string, isTaken func(string) bool, fallbackID *uint) string {
|
||||||
|
base := g.Slug(name)
|
||||||
|
if len(base) < handleMinLen {
|
||||||
|
if fallbackID != nil {
|
||||||
|
base = "user" + strconv.FormatUint(uint64(*fallbackID), 10)
|
||||||
|
} else {
|
||||||
|
base = "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base = clipHandle(base)
|
||||||
|
candidate := base
|
||||||
|
i := 0
|
||||||
|
for isTaken != nil && isTaken(candidate) {
|
||||||
|
i++
|
||||||
|
candidate = clipHandle(base + strconv.Itoa(i))
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignHandle writes a unique handle onto user when it has none, using
|
||||||
|
// the user's display name and id. It is idempotent when handle is already
|
||||||
|
// set. The write uses db (the create transaction when there is one).
|
||||||
|
func AssignHandle(ctx context.Context, db *gorm.DB, user *models.User) error {
|
||||||
|
if user == nil {
|
||||||
|
return errors.New("golem15.user: a handle needs a user")
|
||||||
|
}
|
||||||
|
if user.Handle != nil && strings.TrimSpace(*user.Handle) != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if db == nil {
|
||||||
|
return errors.New("golem15.user: a handle needs a database")
|
||||||
|
}
|
||||||
|
name := ""
|
||||||
|
if user.Name != nil {
|
||||||
|
name = *user.Name
|
||||||
|
}
|
||||||
|
var fallback *uint
|
||||||
|
if user.ID != 0 {
|
||||||
|
id := user.ID
|
||||||
|
fallback = &id
|
||||||
|
}
|
||||||
|
gen := HandleGenerator{}
|
||||||
|
for attempt := 0; attempt < 8; attempt++ {
|
||||||
|
handle := gen.Generate(name, func(candidate string) bool {
|
||||||
|
var n int64
|
||||||
|
q := db.WithContext(ctx).Model(&models.User{}).Where("handle = ?", candidate)
|
||||||
|
if user.ID != 0 {
|
||||||
|
q = q.Where("id <> ?", user.ID)
|
||||||
|
}
|
||||||
|
if err := q.Count(&n).Error; err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return n > 0
|
||||||
|
}, fallback)
|
||||||
|
user.Handle = &handle
|
||||||
|
res := db.WithContext(ctx).Model(user).Where("id = ?", user.ID).Update("handle", handle)
|
||||||
|
if res.Error == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isUniqueHandleConflict(res.Error) {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("golem15.user: could not allocate a unique handle")
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipHandle(s string) string {
|
||||||
|
if len(s) <= handleMaxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:handleMaxLen]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUniqueHandleConflict(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "users_handle_unique") || strings.Contains(msg, "duplicate key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// foldHandleRune maps r into the handle charset [a-z0-9._-] or "".
|
||||||
|
func foldHandleRune(r rune) string {
|
||||||
|
r = unicode.ToLower(r)
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
if mapped, ok := latinHandleFold[r]; ok {
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// latinHandleFold covers Latin-1 / Latin Extended-A letters PHP Str::ascii
|
||||||
|
// would emit for the names we generate handles from (Polish, French, German).
|
||||||
|
var latinHandleFold = map[rune]string{
|
||||||
|
'à': "a", 'á': "a", 'â': "a", 'ã': "a", 'ä': "a", 'å': "a", 'ą': "a", 'ā': "a",
|
||||||
|
'æ': "ae",
|
||||||
|
'ç': "c", 'ć': "c", 'č': "c",
|
||||||
|
'ď': "d", 'đ': "d", 'ð': "d",
|
||||||
|
'è': "e", 'é': "e", 'ê': "e", 'ë': "e", 'ę': "e", 'ē': "e", 'ě': "e",
|
||||||
|
'ì': "i", 'í': "i", 'î': "i", 'ï': "i", 'ī': "i",
|
||||||
|
'ł': "l",
|
||||||
|
'ñ': "n", 'ń': "n", 'ň': "n",
|
||||||
|
'ò': "o", 'ó': "o", 'ô': "o", 'õ': "o", 'ö': "o", 'ø': "o", 'ō': "o",
|
||||||
|
'œ': "oe",
|
||||||
|
'ř': "r",
|
||||||
|
'ś': "s", 'š': "s", 'ş': "s", 'ß': "ss",
|
||||||
|
'ť': "t", 'þ': "th",
|
||||||
|
'ù': "u", 'ú': "u", 'û': "u", 'ü': "u", 'ū': "u",
|
||||||
|
'ý': "y", 'ÿ': "y",
|
||||||
|
'ź': "z", 'ż': "z", 'ž': "z",
|
||||||
|
}
|
||||||
78
classes/handle_generator_test.go
Normal file
78
classes/handle_generator_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestHandle: generator charset is a subset of MentionParser, uniqueness
|
||||||
|
// uses the smallest free integer suffix, and short names fall back to user{id}.
|
||||||
|
func TestHandle(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
g := HandleGenerator{}
|
||||||
|
|
||||||
|
if got := g.Slug("Paweł"); got != "pawel" {
|
||||||
|
t.Fatalf("slug Paweł = %q, want pawel", got)
|
||||||
|
}
|
||||||
|
if got := g.Slug("John Doe"); got != "johndoe" {
|
||||||
|
t.Fatalf("slug John Doe = %q, want johndoe", got)
|
||||||
|
}
|
||||||
|
if got := g.Slug("Zoë"); got != "zoe" {
|
||||||
|
t.Fatalf("slug Zoë = %q, want zoe", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
handle := g.Generate("James", func(string) bool { return false }, nil)
|
||||||
|
if handle != "james" {
|
||||||
|
t.Fatalf("generate James = %q, want james", handle)
|
||||||
|
}
|
||||||
|
if !validHandle(handle) {
|
||||||
|
t.Fatalf("generated %q is outside [a-z0-9._-]{2,255}", handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
taken := map[string]struct{}{}
|
||||||
|
isTaken := func(c string) bool {
|
||||||
|
_, ok := taken[c]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
first := g.Generate("James", isTaken, nil)
|
||||||
|
taken[first] = struct{}{}
|
||||||
|
second := g.Generate("James", isTaken, nil)
|
||||||
|
taken[second] = struct{}{}
|
||||||
|
third := g.Generate("James", isTaken, nil)
|
||||||
|
if first != "james" || second != "james1" || third != "james2" {
|
||||||
|
t.Fatalf("suffixes = %q %q %q, want james james1 james2", first, second, third)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uint(42)
|
||||||
|
if got := g.Generate("", func(string) bool { return false }, &id); got != "user42" {
|
||||||
|
t.Fatalf("empty name = %q, want user42", got)
|
||||||
|
}
|
||||||
|
if got := g.Generate("x", func(string) bool { return false }, &id); got != "user42" {
|
||||||
|
t.Fatalf("short name = %q, want user42", got)
|
||||||
|
}
|
||||||
|
if got := g.Generate("???", func(string) bool { return false }, &id); got != "user42" {
|
||||||
|
t.Fatalf("punctuation name = %q, want user42", got)
|
||||||
|
}
|
||||||
|
if got := g.Generate("", func(string) bool { return false }, nil); got != "user" {
|
||||||
|
t.Fatalf("no id = %q, want user", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := []string{"James", "Paweł", "John Doe", "???", "a", "Zoë", " ", "....", "O'Brien"}
|
||||||
|
for _, name := range names {
|
||||||
|
got := g.Generate(name, func(string) bool { return false }, &id)
|
||||||
|
if !validHandle(got) {
|
||||||
|
t.Errorf("generated %q for %q is outside the MentionParser subset", got, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validHandle(s string) bool {
|
||||||
|
if len(s) < 2 || len(s) > 255 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
7
classes/logger.go
Normal file
7
classes/logger.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import "git.golem15.com/golem15/summercms/modules/diary"
|
||||||
|
|
||||||
|
// pluginLog is the plugin's logger: its records carry component=user, so
|
||||||
|
// config/log.yaml can raise or lower them (log.components.user).
|
||||||
|
var pluginLog = diary.For("user")
|
||||||
192
classes/mcp_guard.go
Normal file
192
classes/mcp_guard.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The middleware names the guard provider reads on a route.
|
||||||
|
const (
|
||||||
|
mcpTokenMiddleware = "user.api_token"
|
||||||
|
mcpScopeMiddleware = "user.scope:"
|
||||||
|
mcpPermissionMiddleware = "user.permission:"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.MCPGuard = (*MCPUserGuard)(nil)
|
||||||
|
_ pact.MCPPersonalTokenResolver = (*MCPUserGuard)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// MCPUserGuard is the plugin's MCP guard provider, named "user": MCP tools
|
||||||
|
// bind to the plugin's token-guarded routes (user.api_token, user.scope:*,
|
||||||
|
// user.permission:*) through it, and it resolves the plugin's personal API
|
||||||
|
// tokens (the usr_ prefix, golem15.user.api_tokens.prefix) at the MCP
|
||||||
|
// endpoint. Boot registers it in the application's *pact.MCPGuards and
|
||||||
|
// publishes it as pact.MCPPersonalTokenResolver.
|
||||||
|
//
|
||||||
|
// Its checks are the ones the routes run: a scope must be granted to the
|
||||||
|
// session (as user.scope checks the token), and a permission code must be
|
||||||
|
// granted by one of the user's groups (as user.permission checks the
|
||||||
|
// principal PrincipalByID loads; a user's own permission overrides do not
|
||||||
|
// grant, D-35).
|
||||||
|
type MCPUserGuard struct {
|
||||||
|
App *backpack.App
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPGuardName is "user".
|
||||||
|
func (g *MCPUserGuard) MCPGuardName() string { return SubjectGuardUser }
|
||||||
|
|
||||||
|
// MCPTokenGuarded reports whether the route runs the user.api_token guard.
|
||||||
|
func (g *MCPUserGuard) MCPTokenGuarded(middleware []string) bool {
|
||||||
|
return slices.Contains(middleware, mcpTokenMiddleware)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPRequirements collects the scopes of user.scope:<scope> and the codes of
|
||||||
|
// user.permission:<code>. Another user.* name than these and user.api_token
|
||||||
|
// is an error, so a guard this provider does not understand cannot be
|
||||||
|
// skipped silently.
|
||||||
|
func (g *MCPUserGuard) MCPRequirements(middleware []string) (pact.MCPRequirements, error) {
|
||||||
|
var req pact.MCPRequirements
|
||||||
|
for _, name := range middleware {
|
||||||
|
switch {
|
||||||
|
case name == mcpTokenMiddleware:
|
||||||
|
case strings.HasPrefix(name, mcpScopeMiddleware):
|
||||||
|
scope := strings.TrimPrefix(name, mcpScopeMiddleware)
|
||||||
|
if scope == "" {
|
||||||
|
return pact.MCPRequirements{}, fmt.Errorf("golem15.user: middleware %q names no scope", name)
|
||||||
|
}
|
||||||
|
if !slices.Contains(req.Scopes, scope) {
|
||||||
|
req.Scopes = append(req.Scopes, scope)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(name, mcpPermissionMiddleware):
|
||||||
|
code := strings.TrimPrefix(name, mcpPermissionMiddleware)
|
||||||
|
if code == "" {
|
||||||
|
return pact.MCPRequirements{}, fmt.Errorf("golem15.user: middleware %q names no permission", name)
|
||||||
|
}
|
||||||
|
if !slices.Contains(req.Permissions, code) {
|
||||||
|
req.Permissions = append(req.Permissions, code)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(name, "user."):
|
||||||
|
return pact.MCPRequirements{}, fmt.Errorf("golem15.user: the MCP guard does not know middleware %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPAllows requires every scope of req in the subject's scopes, then every
|
||||||
|
// permission code of req and of the tool, one by one (the route runs one
|
||||||
|
// user.permission middleware per code), granted to the user's groups.
|
||||||
|
func (g *MCPUserGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string) (bool, error) {
|
||||||
|
for _, scope := range req.Scopes {
|
||||||
|
if !slices.Contains(subject.Scopes, scope) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db, err := g.db()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
principal, err := PrincipalByID(ctx, db.WithContext(ctx), subject.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if principal == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
for _, code := range append(slices.Clone(req.Permissions), permissions...) {
|
||||||
|
if !cabana.Allows(principal, []string{code}) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPDispatch builds the principal (the user with the group grants, as the
|
||||||
|
// api_token guard loads it) and an in-memory token credential carrying the
|
||||||
|
// session's scopes, so user.scope sees what the session was granted. The
|
||||||
|
// credential has ID 0 and is never saved. The marker is honoured by the
|
||||||
|
// api_token guard only.
|
||||||
|
func (g *MCPUserGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
|
||||||
|
db, err := g.db()
|
||||||
|
if err != nil {
|
||||||
|
return pact.MCPDispatch{}, err
|
||||||
|
}
|
||||||
|
principal, err := PrincipalByID(ctx, db.WithContext(ctx), subject.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return pact.MCPDispatch{}, err
|
||||||
|
}
|
||||||
|
if principal == nil {
|
||||||
|
return pact.MCPDispatch{}, fmt.Errorf("golem15.user: user %d is gone", subject.UserID)
|
||||||
|
}
|
||||||
|
credential := &models.APIToken{
|
||||||
|
UserID: subject.UserID,
|
||||||
|
Scopes: lagoon.Jsonable[[]string]{Data: slices.Clone(subject.Scopes), Valid: true},
|
||||||
|
}
|
||||||
|
return pact.MCPDispatch{Principal: principal, Credential: credential, Guards: []string{"api_token"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPResolvePersonalToken resolves a personal API token of this plugin into
|
||||||
|
// an MCP subject with the token's own scopes. A token without the configured
|
||||||
|
// prefix answers false; one with it that is unknown, revoked, expired or
|
||||||
|
// whose user is gone answers pact.ErrMCPTokenInvalid. A usable token gets
|
||||||
|
// its last_used_at stamped, 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) {
|
||||||
|
return pact.MCPSubject{}, false, nil
|
||||||
|
}
|
||||||
|
db, err := g.db()
|
||||||
|
if err != nil {
|
||||||
|
return pact.MCPSubject{}, true, err
|
||||||
|
}
|
||||||
|
db = db.WithContext(ctx)
|
||||||
|
var token models.APIToken
|
||||||
|
result := db.Where("token_hash = ?", HashAPIToken(raw)).Limit(1).Find(&token)
|
||||||
|
if result.Error != nil {
|
||||||
|
return pact.MCPSubject{}, true, result.Error
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if result.RowsAffected == 0 || !token.IsUsableAt(now) {
|
||||||
|
return pact.MCPSubject{}, true, pact.ErrMCPTokenInvalid
|
||||||
|
}
|
||||||
|
principal, err := PrincipalByID(ctx, db, token.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return pact.MCPSubject{}, true, err
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return pact.MCPSubject{}, true, err
|
||||||
|
}
|
||||||
|
return pact.MCPSubject{Guard: SubjectGuardUser, UserID: token.UserID, Scopes: slices.Clone(token.Scopes.Get()), TokenID: token.ID}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *MCPUserGuard) app() *backpack.App {
|
||||||
|
if g == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return g.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *MCPUserGuard) db() (*gorm.DB, error) {
|
||||||
|
app := g.app()
|
||||||
|
if app == nil {
|
||||||
|
return nil, fmt.Errorf("golem15.user: app is nil")
|
||||||
|
}
|
||||||
|
gdb, ok := app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || gdb == nil {
|
||||||
|
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||||
|
}
|
||||||
|
return gdb, nil
|
||||||
|
}
|
||||||
31
classes/oauth_providers.go
Normal file
31
classes/oauth_providers.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// OAuthProvider is one social sign-in provider as GET
|
||||||
|
// /_user/api/v1/oauth-providers lists it: Name is the route segment
|
||||||
|
// (/oauth/{name}) and Label the button text.
|
||||||
|
type OAuthProvider struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthProviderSource is what a social-login plugin publishes so the user
|
||||||
|
// plugin can list its providers and accept them in the unlink routes. The
|
||||||
|
// user plugin looks it up on every request, so a plugin that boots later is
|
||||||
|
// still found.
|
||||||
|
//
|
||||||
|
// Publish it with an explicit type argument,
|
||||||
|
//
|
||||||
|
// app.Publish[classes.OAuthProviderSource](src)
|
||||||
|
//
|
||||||
|
// because a bare app.Publish(src) keys the service on its concrete type and
|
||||||
|
// the lookup misses it (the list then stays empty).
|
||||||
|
type OAuthProviderSource interface {
|
||||||
|
// EnabledOAuthProviders returns the providers that are configured right
|
||||||
|
// now, in display order.
|
||||||
|
EnabledOAuthProviders(ctx context.Context) []OAuthProvider
|
||||||
|
// KnownOAuthProviders returns the name of every registered provider,
|
||||||
|
// enabled or not: an identity of any of them may be unlinked.
|
||||||
|
KnownOAuthProviders() []string
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ import (
|
|||||||
// good.
|
// good.
|
||||||
const PermissionManagePrivilegedGroups = "golem15.users.manage_privileged_groups"
|
const PermissionManagePrivilegedGroups = "golem15.users.manage_privileged_groups"
|
||||||
|
|
||||||
|
// PermissionManageBackendLinks is the backend permission an administrator
|
||||||
|
// needs to link a frontend account to another administrator, or to unlink
|
||||||
|
// one, on the Administrators screen. No role has it by default.
|
||||||
|
const PermissionManageBackendLinks = "golem15.users.manage_backend_links"
|
||||||
|
|
||||||
// privilegedGroupsKey is the config list of privileged group codes.
|
// privilegedGroupsKey is the config list of privileged group codes.
|
||||||
const privilegedGroupsKey = "golem15.user.privileged_groups"
|
const privilegedGroupsKey = "golem15.user.privileged_groups"
|
||||||
|
|
||||||
|
|||||||
275
classes/session_user.go
Normal file
275
classes/session_user.go
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionCookieNames are the cookies the plugin's "jwt" guard reads after
|
||||||
|
// the Authorization header, in the same order (plugin.go Boot).
|
||||||
|
var sessionCookieNames = []string{"token", "auth_token"}
|
||||||
|
|
||||||
|
// SessionCookieNames returns a copy of the cookie names the plugin's "jwt"
|
||||||
|
// guard (plugin.go Boot) reads after the Authorization header, in order.
|
||||||
|
// A handler that builds its own guard passes them to bouncer.NewJWTGuard so
|
||||||
|
// it reads the same token as SessionToken and jwt.auth.
|
||||||
|
func SessionCookieNames() []string {
|
||||||
|
return slices.Clone(sessionCookieNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestUser returns the signed-in user of r for handlers that run without
|
||||||
|
// the jwt.auth middleware and must also serve anonymous requests (an OAuth
|
||||||
|
// provider's authorize page, for example). The token is read and checked
|
||||||
|
// exactly as the plugin's "jwt" guard does: the Authorization bearer header
|
||||||
|
// first, then the token and auth_token cookies; the plugin's JWT secret;
|
||||||
|
// the published bouncer.BlacklistStore when there is one; and the user's
|
||||||
|
// tokens_valid_after cutoff. A soft-deleted user is not returned.
|
||||||
|
//
|
||||||
|
// An anonymous request, or a token that is invalid, expired, blacklisted or
|
||||||
|
// names no user, returns (nil, nil). Only an infrastructure failure (no
|
||||||
|
// database handle, a failing query or blacklist lookup) returns an error.
|
||||||
|
func RequestUser(ctx context.Context, app *backpack.App, r *http.Request) (*models.User, error) {
|
||||||
|
if r == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
raw := SessionToken(r)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
secret, err := JWTSecret(app)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sub, iat, _, jti, err := bouncer.VerifyClaims(raw, secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(sub, 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
gdb, ok := app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || gdb == nil {
|
||||||
|
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||||
|
}
|
||||||
|
var user models.User
|
||||||
|
err = gdb.WithContext(ctx).Where("id = ?", uint(id)).Take(&user).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if bl, ok := app.Lookup[bouncer.BlacklistStore](); ok && bl != nil {
|
||||||
|
blocked, err := bl.IsBlacklisted(ctx, jti)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if blocked {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user.TokensValidAfter != nil && iat.Before(*user.TokensValidAfter) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndSession signs the request out; POST /logout runs it. Every session JWT
|
||||||
|
// r carries (the Authorization bearer header and the token and auth_token
|
||||||
|
// cookies the "jwt" guard reads) that verifies is added to the published
|
||||||
|
// bouncer.BlacklistStore, so the guard, RequestUser and POST /refresh refuse
|
||||||
|
// it from then on. A token verifies while it is unexpired, and a token with
|
||||||
|
// the user audience also after its access lifetime while its refresh window
|
||||||
|
// is open (it could still be refreshed). The row is kept until the later of
|
||||||
|
// the token's exp and its iat plus the refresh window
|
||||||
|
// (golem15.user.jwt.refresh_ttl), plus one minute, as bouncer.Refresh keeps
|
||||||
|
// a rotated jti: a row that ended at exp would let /refresh revive the
|
||||||
|
// logged-out token for the rest of its window.
|
||||||
|
//
|
||||||
|
// When w is not nil the response gets the forget cookie of
|
||||||
|
// bouncer.LoadCookieConfig(app.Config), with the name, path, domain,
|
||||||
|
// SameSite and Secure of the token cookie a login writes, and one with the
|
||||||
|
// same attributes for each other session cookie r carries. It writes no
|
||||||
|
// status or body.
|
||||||
|
//
|
||||||
|
// It returns the subject of the token the guard would read (the bearer
|
||||||
|
// header first, then the cookies in order), or "" when r carries none or it
|
||||||
|
// does not verify. Only a failing blacklist write is an error; the cookies
|
||||||
|
// are forgotten and the other tokens revoked in any case. Without a
|
||||||
|
// published blacklist nothing is revoked.
|
||||||
|
func EndSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) (string, error) {
|
||||||
|
if w != nil {
|
||||||
|
forgetSessionCookies(app, w, r)
|
||||||
|
}
|
||||||
|
if r == nil || app == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
secret, err := JWTSecret(app)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
bl, _ := app.Lookup[bouncer.BlacklistStore]()
|
||||||
|
window := refreshWindow(app)
|
||||||
|
subject := ""
|
||||||
|
var firstErr error
|
||||||
|
for i, raw := range sessionTokens(r) {
|
||||||
|
sub, iat, exp, jti, ok := verifySessionToken(raw, secret, window)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
subject = sub
|
||||||
|
}
|
||||||
|
if bl == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := bl.Add(ctx, jti, revokedUntil(iat, exp, window), time.Now()); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subject, firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndRequestSession is the best-effort EndSession, as in the PHP provider:
|
||||||
|
// it revokes the session JWTs r carries and forgets the session cookies the
|
||||||
|
// same way, logs a blacklist failure instead of returning it, and always
|
||||||
|
// returns nil. It writes no status or body.
|
||||||
|
func EndRequestSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if _, err := EndSession(ctx, app, w, r); err != nil {
|
||||||
|
pluginLog.Warn("golem15.user: end session: blacklist token", "err", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetSessionCookies expires the token cookie of
|
||||||
|
// bouncer.LoadCookieConfig(app.Config) and, with the same path, domain,
|
||||||
|
// SameSite and Secure, each other session cookie r carries.
|
||||||
|
func forgetSessionCookies(app *backpack.App, w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := bouncer.CookieConfig{Name: bouncer.DefaultCookieName, Path: "/", SameSite: http.SameSiteLaxMode}
|
||||||
|
if app != nil && app.Config != nil {
|
||||||
|
loaded, err := bouncer.LoadCookieConfig(app.Config)
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.Warn("golem15.user: end session: cookie config", "err", err)
|
||||||
|
} else {
|
||||||
|
cfg = loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.SetCookie(w, cfg.ForgetTokenCookie())
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, name := range sessionCookieNames {
|
||||||
|
if name == cfg.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := r.Cookie(name); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
other := cfg
|
||||||
|
other.Name = name
|
||||||
|
http.SetCookie(w, other.ForgetTokenCookie())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifySessionToken accepts raw when it is a token the "jwt" guard accepts
|
||||||
|
// or POST /refresh still rotates: a user-audience token whose refresh window
|
||||||
|
// is open (its exp may have passed), or an unexpired token, which also
|
||||||
|
// covers one without an audience (issued by the PHP backend). A token
|
||||||
|
// without a jti cannot be revoked and is refused.
|
||||||
|
func verifySessionToken(raw, secret string, window time.Duration) (sub string, iat, exp time.Time, jti string, ok bool) {
|
||||||
|
sub, iat, exp, jti, err := bouncer.VerifyRefreshableClaimsAudience(raw, secret, bouncer.AudienceUser, window)
|
||||||
|
if err != nil {
|
||||||
|
sub, iat, exp, jti, err = bouncer.VerifyClaims(raw, secret)
|
||||||
|
}
|
||||||
|
return sub, iat, exp, jti, err == nil && jti != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokedUntil is the storage expiry of a revoked session jti: the later of
|
||||||
|
// exp and iat plus the refresh window, plus one minute (PHP jwt-auth's
|
||||||
|
// Blacklist::getMinutesUntilExpired, which bouncer.Refresh follows).
|
||||||
|
func revokedUntil(iat, exp time.Time, window time.Duration) time.Time {
|
||||||
|
until := iat.Add(window)
|
||||||
|
if exp.After(until) {
|
||||||
|
until = exp
|
||||||
|
}
|
||||||
|
return until.Add(time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshWindow is golem15.user.jwt.refresh_ttl minutes, default 20160: the
|
||||||
|
// window POST /refresh accepts (controllers.RefreshTTL reads the same key).
|
||||||
|
func refreshWindow(app *backpack.App) time.Duration {
|
||||||
|
mins := 20160
|
||||||
|
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.refresh_ttl") > 0 {
|
||||||
|
mins = app.Config.Int("golem15.user.jwt.refresh_ttl")
|
||||||
|
}
|
||||||
|
return time.Duration(mins) * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionToken returns the session JWT the plugin's "jwt" guard reads from r:
|
||||||
|
// the Authorization bearer header, then each cookie of SessionCookieNames in
|
||||||
|
// order, trimmed, empty values skipped. It returns "" when r carries none.
|
||||||
|
// A controller that builds its own guard passes SessionCookieNames() to
|
||||||
|
// bouncer.NewJWTGuard, so the guard and SessionToken read the same token.
|
||||||
|
func SessionToken(r *http.Request) string {
|
||||||
|
if tokens := sessionTokens(r); len(tokens) > 0 {
|
||||||
|
return tokens[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionTokenCookie names the session cookie that supplied the token
|
||||||
|
// SessionToken returns, for a handler that writes that cookie back (POST
|
||||||
|
// /refresh rewrites it with the rotated token). It is "" when the bearer
|
||||||
|
// header supplied the token or the request carries none.
|
||||||
|
func SessionTokenCookie(r *http.Request) string {
|
||||||
|
if r == nil || bearerToken(r) != "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, name := range sessionCookieNames {
|
||||||
|
if c, err := r.Cookie(name); err == nil && strings.TrimSpace(c.Value) != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearerToken returns the trimmed token of r's Authorization bearer header,
|
||||||
|
// "" when there is none.
|
||||||
|
func bearerToken(r *http.Request) string {
|
||||||
|
h := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||||
|
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||||
|
return strings.TrimSpace(token)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionTokens returns every distinct session JWT r carries, in the order
|
||||||
|
// the "jwt" guard tries them: the Authorization bearer header, then each
|
||||||
|
// session cookie. Empty values are skipped.
|
||||||
|
func sessionTokens(r *http.Request) []string {
|
||||||
|
var out []string
|
||||||
|
add := func(v string) {
|
||||||
|
if v = strings.TrimSpace(v); v != "" && !slices.Contains(out, v) {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(bearerToken(r))
|
||||||
|
for _, name := range sessionCookieNames {
|
||||||
|
if c, err := r.Cookie(name); err == nil {
|
||||||
|
add(c.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
55
classes/subject_events.go
Normal file
55
classes/subject_events.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Guard names the plugin's pact.MCPSubjectChanged events carry: the frontend
|
||||||
|
// user guard this plugin provides and the admin's guard (for the backend
|
||||||
|
// side of an account link).
|
||||||
|
const (
|
||||||
|
SubjectGuardUser = "user"
|
||||||
|
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 == "" {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FireSubjectChangedAfterCommit is FireSubjectChanged 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 == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tx == nil {
|
||||||
|
FireSubjectChanged(ctx, app, guard, userID, kind)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||||
|
FireSubjectChanged(ctx, app, guard, userID, kind)
|
||||||
|
})
|
||||||
|
}
|
||||||
21
commands_documented_test.go
Normal file
21
commands_documented_test.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCommandsDocumented logs every console command whose help lacks a
|
||||||
|
// description, long help, examples or an option description
|
||||||
|
// (bonfire.CheckDocs). It only warns: the framework's own coverage test
|
||||||
|
// fails on a gap, plugin repositories report it.
|
||||||
|
func TestCommandsDocumented(t *testing.T) {
|
||||||
|
commands := (&Plugin{}).Commands()
|
||||||
|
if len(commands) == 0 {
|
||||||
|
t.Fatal("the plugin declares no commands")
|
||||||
|
}
|
||||||
|
for _, problem := range bonfire.CheckDocs(commands) {
|
||||||
|
t.Logf("%s", problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,11 @@ jwt:
|
|||||||
blacklist_grace: 0
|
blacklist_grace: 0
|
||||||
leeway: 0
|
leeway: 0
|
||||||
blacklist_sweep_interval: 10m
|
blacklist_sweep_interval: 10m
|
||||||
|
# On: POST /login, /register (when it signs the account in) and
|
||||||
|
# /activate-by-code set the HttpOnly session cookie from auth.cookie.* and
|
||||||
|
# leave token out of the body. A POST /refresh carried by that cookie
|
||||||
|
# rewrites it either way. Keep auth.cookie.name token or auth_token.
|
||||||
|
token_cookie: false
|
||||||
activation:
|
activation:
|
||||||
require_activation: true
|
require_activation: true
|
||||||
activate_mode: auto
|
activate_mode: auto
|
||||||
@@ -20,7 +25,23 @@ throttle:
|
|||||||
password:
|
password:
|
||||||
bcrypt_cost: 10
|
bcrypt_cost: 10
|
||||||
min_length: 8
|
min_length: 8
|
||||||
|
# false leaves POST /login, /forgot-password and /reset-password
|
||||||
|
# unregistered, so they 404 like any unknown path; /register and the other
|
||||||
|
# routes stay. Read at boot (a change needs a restart). Takes true or false.
|
||||||
|
public_routes: true
|
||||||
api_tokens:
|
api_tokens:
|
||||||
prefix: usr_
|
prefix: usr_
|
||||||
privileged_groups:
|
privileged_groups:
|
||||||
- admin
|
- admin
|
||||||
|
# Cloudflare Turnstile on POST /login and /register. Empty (the default) adds
|
||||||
|
# nothing. "open" lets requests pass when Cloudflare is unreachable, "closed"
|
||||||
|
# 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.
|
||||||
|
frontend_url: ""
|
||||||
|
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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package console
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,11 +29,25 @@ func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm
|
|||||||
return []bonfire.Command{
|
return []bonfire.Command{
|
||||||
{
|
{
|
||||||
Name: "user:token:create", Description: "Create one API token usable by application plugins",
|
Name: "user:token:create", Description: "Create one API token usable by application plugins",
|
||||||
|
Long: `Mints a personal API token that acts as the user with this e-mail, for
|
||||||
|
scripts and integrations that call the application's API. --scopes limits
|
||||||
|
what it may do (read, write, ai); --expires sets an RFC3339 expiry, and
|
||||||
|
without it the token does not expire. The secret is printed once, on its own
|
||||||
|
line (also under -q), and only its hash is stored, so keep it now; with
|
||||||
|
--json the token and its details are printed as one JSON object instead.
|
||||||
|
|
||||||
|
Revoke a token with user:token:revoke and list them with user:token:list.`,
|
||||||
Args: []bonfire.Arg{{Name: "email", Description: "E-mail of the user the token acts as", Required: true}},
|
Args: []bonfire.Arg{{Name: "email", Description: "E-mail of the user the token acts as", Required: true}},
|
||||||
Flags: []bonfire.Flag{
|
Flags: []bonfire.Flag{
|
||||||
{Name: "name", Description: "What the token is for", Default: "api"},
|
{Name: "name", Description: "What the token is for", Default: "api"},
|
||||||
{Name: "scopes", Description: "Comma-separated scopes: read, write, ai", Default: "read,write"},
|
{Name: "scopes", Description: "Comma-separated scopes: read, write, ai", Default: "read,write"},
|
||||||
{Name: "expires", Description: "RFC3339 expiry; empty means no expiry"},
|
{Name: "expires", Description: "RFC3339 expiry; empty means no expiry"},
|
||||||
|
{Name: "json", Description: "Print the token and its details as JSON", Bare: true},
|
||||||
|
},
|
||||||
|
Examples: []bonfire.Example{
|
||||||
|
{Command: "user:token:create jane@example.com --name backup-script --scopes read", Description: "A read-only token for a script"},
|
||||||
|
{Command: "user:token:create jane@example.com --expires 2027-01-01T00:00:00Z", Description: "A read and write token that expires"},
|
||||||
|
{Command: "user:token:create jane@example.com --json", Description: "Print the token and its details as JSON"},
|
||||||
},
|
},
|
||||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
email, _ := in.Argument("email")
|
email, _ := in.Argument("email")
|
||||||
@@ -71,6 +86,17 @@ func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm
|
|||||||
if err := db.Create(token).Error; err != nil {
|
if err := db.Create(token).Error; err != nil {
|
||||||
return fmt.Errorf("user:token:create: %w", err)
|
return fmt.Errorf("user:token:create: %w", err)
|
||||||
}
|
}
|
||||||
|
if flagOn(in, "json") {
|
||||||
|
return printJSON(out, map[string]any{
|
||||||
|
"id": token.ID,
|
||||||
|
"user_id": token.UserID,
|
||||||
|
"email": user.Email,
|
||||||
|
"name": token.Name,
|
||||||
|
"token": secret,
|
||||||
|
"scopes": token.Scopes.Get(),
|
||||||
|
"expires_at": token.ExpiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
out.Printf("token %d for %s, scopes %s\n", token.ID, email, strings.Join(token.Scopes.Get(), ","))
|
out.Printf("token %d for %s, scopes %s\n", token.ID, email, strings.Join(token.Scopes.Get(), ","))
|
||||||
out.Println(secret)
|
out.Println(secret)
|
||||||
out.Success("user:token:create: store the secret now; it is not shown again")
|
out.Success("user:token:create: store the secret now; it is not shown again")
|
||||||
@@ -80,13 +106,25 @@ func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "user:token:list", Description: "List API tokens",
|
Name: "user:token:list", Description: "List API tokens",
|
||||||
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
|
Long: `Lists every personal API token: its id, the user it acts as, its name, its
|
||||||
|
scopes and its state (active, expired or revoked). Secrets are never shown;
|
||||||
|
only their hashes are stored. --json prints the tokens as a JSON array with
|
||||||
|
their expiry, revocation and last use times.`,
|
||||||
|
Flags: []bonfire.Flag{
|
||||||
|
{Name: "json", Description: "Print the tokens as JSON", Bare: true},
|
||||||
|
},
|
||||||
|
Examples: []bonfire.Example{
|
||||||
|
{Command: "user:token:list", Description: "Show every token as a table"},
|
||||||
|
{Command: "user:token:list --json", Description: "Print the tokens as JSON for a script"},
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
return open(ctx, func(db *gorm.DB) error {
|
return open(ctx, func(db *gorm.DB) error {
|
||||||
var tokens []models.APIToken
|
var tokens []models.APIToken
|
||||||
if err := db.Order("id").Find(&tokens).Error; err != nil {
|
if err := db.Order("id").Find(&tokens).Error; err != nil {
|
||||||
return fmt.Errorf("user:token:list: %w", err)
|
return fmt.Errorf("user:token:list: %w", err)
|
||||||
}
|
}
|
||||||
rows := make([][]string, 0, len(tokens))
|
rows := make([][]string, 0, len(tokens))
|
||||||
|
list := make([]map[string]any, 0, len(tokens))
|
||||||
for _, token := range tokens {
|
for _, token := range tokens {
|
||||||
state := "active"
|
state := "active"
|
||||||
if token.RevokedAt != nil {
|
if token.RevokedAt != nil {
|
||||||
@@ -99,6 +137,20 @@ func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm
|
|||||||
name = *token.Name
|
name = *token.Name
|
||||||
}
|
}
|
||||||
rows = append(rows, []string{strconv.FormatUint(uint64(token.ID), 10), strconv.FormatUint(uint64(token.UserID), 10), name, strings.Join(token.Scopes.Get(), ","), state})
|
rows = append(rows, []string{strconv.FormatUint(uint64(token.ID), 10), strconv.FormatUint(uint64(token.UserID), 10), name, strings.Join(token.Scopes.Get(), ","), state})
|
||||||
|
list = append(list, map[string]any{
|
||||||
|
"id": token.ID,
|
||||||
|
"user_id": token.UserID,
|
||||||
|
"name": token.Name,
|
||||||
|
"scopes": token.Scopes.Get(),
|
||||||
|
"state": state,
|
||||||
|
"expires_at": token.ExpiresAt,
|
||||||
|
"revoked_at": token.RevokedAt,
|
||||||
|
"last_used_at": token.LastUsedAt,
|
||||||
|
"created_at": token.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if flagOn(in, "json") {
|
||||||
|
return printJSON(out, list)
|
||||||
}
|
}
|
||||||
out.Table([]string{"id", "user", "name", "scopes", "state"}, rows)
|
out.Table([]string{"id", "user", "name", "scopes", "state"}, rows)
|
||||||
return nil
|
return nil
|
||||||
@@ -107,7 +159,13 @@ func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "user:token:revoke", Description: "Revoke an API token",
|
Name: "user:token:revoke", Description: "Revoke an API token",
|
||||||
|
Long: `Revokes the active API token with this id (see user:token:list): requests
|
||||||
|
that present it are refused from now on. The token row stays, marked
|
||||||
|
revoked; a token that is already revoked or does not exist is an error.`,
|
||||||
Args: []bonfire.Arg{{Name: "id", Description: "Token id", Required: true}},
|
Args: []bonfire.Arg{{Name: "id", Description: "Token id", Required: true}},
|
||||||
|
Examples: []bonfire.Example{
|
||||||
|
{Command: "user:token:revoke 12", Description: "Revoke the token with id 12"},
|
||||||
|
},
|
||||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
raw, _ := in.Argument("id")
|
raw, _ := in.Argument("id")
|
||||||
id, err := strconv.ParseUint(raw, 10, 64)
|
id, err := strconv.ParseUint(raw, 10, 64)
|
||||||
@@ -129,3 +187,24 @@ func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// printJSON writes v as indented JSON and a newline on out: data, printed
|
||||||
|
// under -q too.
|
||||||
|
func printJSON(out bonfire.Output, v any) error {
|
||||||
|
b, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Println(string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// flagOn reads a bare flag ("true" when given).
|
||||||
|
func flagOn(in bonfire.Input, name string) bool {
|
||||||
|
v, _ := in.Flag(name)
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "true", "1", "yes":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
71
console/backfill_handles.go
Normal file
71
console/backfill_handles.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackfillHandles fills a unique handle on every user row that still has none.
|
||||||
|
// It uses the database the serve command published or, when it runs as a
|
||||||
|
// console command, opens one from the config for the duration of the run,
|
||||||
|
// as the plugin's other commands do.
|
||||||
|
func BackfillHandles(app *backpack.App) bonfire.Command {
|
||||||
|
return bonfire.Command{
|
||||||
|
Name: "user:backfill-handles",
|
||||||
|
Description: "Assign unique handles to users that do not have one",
|
||||||
|
Long: `Gives every user without a handle a unique one, derived the same way as
|
||||||
|
for a new registration, with a progress bar over the users (hidden under
|
||||||
|
-q). Users that already have a handle are not touched, so running it again
|
||||||
|
changes nothing.
|
||||||
|
|
||||||
|
Run it once after copying users from WinterCMS, which had no handles. It
|
||||||
|
ends with "Assigned handles to N user(s)".`,
|
||||||
|
Examples: []bonfire.Example{
|
||||||
|
{Command: "user:backfill-handles", Description: "Assign a handle to every user that has none"},
|
||||||
|
{Command: "user:backfill-handles -q", Description: "Run it quietly, printing only the result line"},
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
|
||||||
|
if app == nil {
|
||||||
|
return fmt.Errorf("user:backfill-handles: app is nil")
|
||||||
|
}
|
||||||
|
gdb, ok := app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || gdb == nil {
|
||||||
|
sqlDB, opened, err := lagoon.OpenFromApp(ctx, app)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user:backfill-handles: %w", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
gdb = opened
|
||||||
|
}
|
||||||
|
var users []models.User
|
||||||
|
if err := gdb.WithContext(ctx).Where("handle IS NULL OR handle = ''").Find(&users).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
if len(users) > 0 {
|
||||||
|
err := out.Progress(len(users), func(p bonfire.Progress) error {
|
||||||
|
for i := range users {
|
||||||
|
if err := classes.AssignHandle(ctx, gdb, &users[i]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
p.Advance(1)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Success(fmt.Sprintf("Assigned handles to %d user(s)", n))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,14 @@ import (
|
|||||||
func RequirePasswordChange(withDB func(context.Context, func(*gorm.DB) error) error) bonfire.Command {
|
func RequirePasswordChange(withDB func(context.Context, func(*gorm.DB) error) error) bonfire.Command {
|
||||||
return bonfire.Command{
|
return bonfire.Command{
|
||||||
Name: "user:require-password-change",
|
Name: "user:require-password-change",
|
||||||
Description: "Force a user to change their password on next login (D-21)",
|
Description: "Force a user to change their password on next login",
|
||||||
|
Long: `Marks the account with this e-mail so that it must set a new password at its
|
||||||
|
next sign-in; setting the password clears the mark. Use it after a suspected
|
||||||
|
leak or when handing over an account created with a temporary password. It
|
||||||
|
ends with "Password change required for <email>".`,
|
||||||
|
Examples: []bonfire.Example{
|
||||||
|
{Command: "user:require-password-change jane@example.com", Description: "Make Jane set a new password at her next sign-in"},
|
||||||
|
},
|
||||||
Args: []bonfire.Arg{{
|
Args: []bonfire.Arg{{
|
||||||
Name: "email",
|
Name: "email",
|
||||||
Description: "Account email",
|
Description: "Account email",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package user
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -77,6 +78,40 @@ func TestConsoleWithDBOpensFromConfigWhenUnpublished(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBackfillHandlesOpensFromConfigWhenUnpublished runs user:backfill-handles
|
||||||
|
// as the console does: no published database, only database.dsn in the config.
|
||||||
|
func TestBackfillHandlesOpensFromConfigWhenUnpublished(t *testing.T) {
|
||||||
|
_, gdb := sessionApp(t, "0s")
|
||||||
|
user := insertUser(t, gdb, "backfill-unpublished@example.test", "correct-horse")
|
||||||
|
if err := gdb.Model(user).Update("handle", nil).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
if err := app.Config.Set("database.dsn", sessionDSN); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Opening a database also loads the encryption keys from app.key.
|
||||||
|
if err := app.Config.Set("app.key", base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := app.Lookup[*gorm.DB](); ok {
|
||||||
|
t.Fatal("setup: a database is published")
|
||||||
|
}
|
||||||
|
cmd := commandNamed(t, (&Plugin{app: app}).Commands(), "user:backfill-handles")
|
||||||
|
var buf bytes.Buffer
|
||||||
|
out := bonfire.NewOutput(nil, &buf, &buf)
|
||||||
|
if err := cmd.Run(context.Background(), emailArg{}, out); err != nil {
|
||||||
|
t.Fatalf("run: %v (output %q)", err, buf.String())
|
||||||
|
}
|
||||||
|
var fresh models.User
|
||||||
|
if err := gdb.First(&fresh, user.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fresh.Handle == nil || *fresh.Handle == "" {
|
||||||
|
t.Fatal("the user still has no handle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func commandNamed(t *testing.T, cmds []bonfire.Command, name string) bonfire.Command {
|
func commandNamed(t *testing.T, cmds []bonfire.Command, name string) bonfire.Command {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, candidate := range cmds {
|
for _, candidate := range cmds {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -30,6 +29,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/modules/postcard"
|
"git.golem15.com/golem15/summercms/modules/postcard"
|
||||||
"git.golem15.com/golem15/summercms/modules/surf"
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
"git.golem15.com/golem15/summercms/modules/wire"
|
"git.golem15.com/golem15/summercms/modules/wire"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"gocloud.dev/blob"
|
"gocloud.dev/blob"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -44,12 +44,33 @@ var errNoAvatar = errors.New("golem15.user: no avatar")
|
|||||||
const invalidLoginBody = "Invalid email or password"
|
const invalidLoginBody = "Invalid email or password"
|
||||||
|
|
||||||
// Login authenticates email and password and returns a JWT plus the user payload.
|
// Login authenticates email and password and returns a JWT plus the user payload.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// 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
|
||||||
|
// account that is also deactivated is refused before the restore, so it
|
||||||
|
// stays deactivated and gets no mail. With the key off such an account signs
|
||||||
|
// in as before. Activation gates login, deactivation does not. The throttle
|
||||||
|
// counts failed attempts per user and client address, which is
|
||||||
|
// surf.ClientIP with http.trusted_proxies, as for /register. With
|
||||||
|
// golem15.user.jwt.token_cookie on, the token goes into the auth.cookie
|
||||||
|
// session cookie instead of the body.
|
||||||
func Login(app *backpack.App) http.HandlerFunc {
|
func Login(app *backpack.App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gdb, secret, ok := sessionDeps(w, app)
|
gdb, secret, ok := sessionDeps(w, app)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cc, err := sessionCookie(app)
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.Error("golem15.user: token cookie config", "err", err)
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
fields, err := readFields(r)
|
fields, err := readFields(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
@@ -63,7 +84,7 @@ func Login(app *backpack.App) http.HandlerFunc {
|
|||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ip := remoteIP(r)
|
ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))
|
||||||
if user != nil {
|
if user != nil {
|
||||||
if err := classes.RejectIfThrottled(ctx, gdb, user, ip); err != nil {
|
if err := classes.RejectIfThrottled(ctx, gdb, user, ip); err != nil {
|
||||||
if classes.IsLoginRejected(err) {
|
if classes.IsLoginRejected(err) {
|
||||||
@@ -75,13 +96,14 @@ func Login(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if user == nil || !bouncer.CheckPassword(user.Password, password) {
|
if user == nil || !bouncer.CheckPassword(user.Password, password) {
|
||||||
if user != nil {
|
refuseLogin(ctx, w, r, app, gdb, user, ip)
|
||||||
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, false); err != nil && !classes.IsLoginRejected(err) {
|
return
|
||||||
writeOpaque500(w)
|
}
|
||||||
return
|
// The activation gate runs after the password check, so a wrong
|
||||||
}
|
// password and a not-activated account cost the same and answer the
|
||||||
}
|
// same, and before the restore, so a refused account is not revived.
|
||||||
writeInvalidLogin(w, r, app)
|
if _, _, _, requireActivation := registrationSettings(app); requireActivation && !user.IsActivated {
|
||||||
|
refuseLogin(ctx, w, r, app, gdb, user, ip)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user.DeletedAt.Valid {
|
if user.DeletedAt.Valid {
|
||||||
@@ -110,7 +132,7 @@ func Login(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
// A failed last_seen write is logged and never fails the login.
|
// A failed last_seen write is logged and never fails the login.
|
||||||
if err := classes.TouchLastSeen(ctx, gdb, user.ID); err != nil {
|
if err := classes.TouchLastSeen(ctx, gdb, user.ID); err != nil {
|
||||||
slog.Default().Warn("golem15.user: stamp last_seen on login", "user_id", user.ID, "err", err)
|
pluginLog.Warn("golem15.user: stamp last_seen on login", "user_id", user.ID, "err", err)
|
||||||
}
|
}
|
||||||
token, err := mintFor(app, secret, user, requestURL(r))
|
token, err := mintFor(app, secret, user, requestURL(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,21 +144,26 @@ func Login(app *backpack.App) http.HandlerFunc {
|
|||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload})
|
writeSession(w, app, cc, token, map[string]any{"user": payload})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout forever-blacklists the presented bearer token.
|
// Logout ends the request's session through classes.EndSession: every
|
||||||
|
// session token it carries (the bearer header, or the token and auth_token
|
||||||
|
// cookies the jwt guard also reads) is blacklisted for the rest of its
|
||||||
|
// refresh window, so the next request with it is refused and /refresh
|
||||||
|
// cannot revive it, and the session cookies are expired, on a 401 too.
|
||||||
|
// It answers 200 {"message":"Logged out"} when the token the guard reads
|
||||||
|
// verifies (an expired one too while it can still be refreshed) and names a
|
||||||
|
// user, 401 otherwise, and 500 when the blacklist write fails.
|
||||||
func Logout(app *backpack.App) http.HandlerFunc {
|
func Logout(app *backpack.App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, secret, ok := sessionDeps(w, app)
|
if _, _, ok := sessionDeps(w, app); !ok {
|
||||||
if !ok {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
raw := bearerToken(r)
|
sub, err := classes.EndSession(r.Context(), app, w, r)
|
||||||
sub, _, exp, jti, err := bouncer.VerifyClaims(raw, secret)
|
if err != nil {
|
||||||
if err != nil || jti == "" {
|
writeOpaque500(w)
|
||||||
writeUnauthorized(w)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, err := strconv.ParseUint(sub, 10, 64)
|
id, err := strconv.ParseUint(sub, 10, 64)
|
||||||
@@ -149,12 +176,6 @@ func Logout(app *backpack.App) http.HandlerFunc {
|
|||||||
writeUnauthorized(w)
|
writeUnauthorized(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if bl, ok := app.Lookup[bouncer.BlacklistStore](); ok && bl != nil {
|
|
||||||
if err := bl.Add(r.Context(), jti, exp, time.Now()); err != nil {
|
|
||||||
writeOpaque500(w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"message": "Logged out"})
|
writeJSON(w, http.StatusOK, map[string]any{"message": "Logged out"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,18 +196,44 @@ func Fetch(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh slides a still-refreshable bearer token.
|
// Refresh slides a still-refreshable session token of a user who may still
|
||||||
|
// sign in. The token is read as the plugin's jwt guard reads it: the bearer
|
||||||
|
// header, then the token and auth_token cookies. When it came from the cookie
|
||||||
|
// named auth.cookie.name with no bearer header, that cookie is rewritten with
|
||||||
|
// the rotated token (bouncer.LoadCookieConfig attributes, HttpOnly, Max-Age
|
||||||
|
// golem15.user.jwt.refresh_ttl) and the body is {}, whoever wrote the
|
||||||
|
// cookie. A bearer-carried or other-cookie refresh answers the rotated token
|
||||||
|
// in the body and writes no cookie; the old token is blacklisted after
|
||||||
|
// golem15.user.jwt.blacklist_grace, so that caller stores the new token
|
||||||
|
// itself. Refusals write and forget no cookie, and a broken auth.cookie
|
||||||
|
// block answers a cookie-carried refresh with the opaque 500 before anything
|
||||||
|
// is rotated. Deactivated and banned users and tokens issued before the
|
||||||
|
// user's tokens_valid_after are refused with the token-failure 401 body.
|
||||||
func Refresh(app *backpack.App) http.HandlerFunc {
|
func Refresh(app *backpack.App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gdb, secret, ok := sessionDeps(w, app)
|
gdb, secret, ok := sessionDeps(w, app)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
raw := bearerToken(r)
|
raw := classes.SessionToken(r)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "Token not found"})
|
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "Token not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The auth.cookie block is read before bouncer.Refresh, so a broken
|
||||||
|
// block rotates and blacklists nothing; a bearer refresh never reads it.
|
||||||
|
var rotate *bouncer.CookieConfig
|
||||||
|
if name := classes.SessionTokenCookie(r); name != "" {
|
||||||
|
cc, err := tokenCookieConfig(app)
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.Error("golem15.user: refresh: auth.cookie config", "err", err)
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cc.Name == name {
|
||||||
|
rotate = &cc
|
||||||
|
}
|
||||||
|
}
|
||||||
var bl bouncer.BlacklistStore
|
var bl bouncer.BlacklistStore
|
||||||
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
|
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
|
||||||
bl = store
|
bl = store
|
||||||
@@ -199,29 +246,114 @@ func Refresh(app *backpack.App) http.HandlerFunc {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The refresh never loads the user row: the subject comes from the
|
// bouncer.Refresh checked the token only (signature, audience,
|
||||||
// new token. A failed last_seen write is logged and never fails it.
|
// refresh window, blacklist). The subject check loads the user: a
|
||||||
if sub, _, _, _, err := bouncer.VerifyClaims(next, secret); err == nil {
|
// missing or deactivated user (who signs in again, which restores the
|
||||||
if id, err := strconv.ParseUint(sub, 10, 64); err == nil && id != 0 {
|
// account), a token issued before tokens_valid_after, and a banned
|
||||||
if err := classes.TouchLastSeen(r.Context(), gdb, uint(id)); err != nil {
|
// user are refused with the same 401 body. Suspension is not
|
||||||
slog.Default().Warn("golem15.user: stamp last_seen on refresh", "user_id", id, "err", err)
|
// checked: it is the failed-password throttle, and refusing refresh
|
||||||
}
|
// on it would let anyone who knows an email log its owner out.
|
||||||
}
|
userID, refused, err := refreshSubjectRefused(r.Context(), app, gdb, raw)
|
||||||
|
if err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"token": next})
|
if refused {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]any{
|
||||||
|
"error": "Could not refresh token",
|
||||||
|
"msg": bouncer.ErrSubjectRejected.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A failed last_seen write is logged and never fails the refresh.
|
||||||
|
if err := classes.TouchLastSeen(r.Context(), gdb, userID); err != nil {
|
||||||
|
pluginLog.Warn("golem15.user: stamp last_seen on refresh", "user_id", userID, "err", err)
|
||||||
|
}
|
||||||
|
writeSession(w, app, rotate, next, map[string]any{})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuthProviders reports that this app configures no social providers.
|
// refreshSubjectRefused reads the subject and iat of a token bouncer.Refresh
|
||||||
|
// has already verified and reports whether its user may not slide the
|
||||||
|
// session: not found by a scoped load (missing or deactivated), issued
|
||||||
|
// before the user's tokens_valid_after, or banned. Only a database failure
|
||||||
|
// is an error.
|
||||||
|
func refreshSubjectRefused(ctx context.Context, app *backpack.App, gdb *gorm.DB, raw string) (uint, bool, error) {
|
||||||
|
claims := jwt.MapClaims{}
|
||||||
|
if _, _, err := jwt.NewParser().ParseUnverified(raw, claims); err != nil {
|
||||||
|
return 0, true, nil
|
||||||
|
}
|
||||||
|
var id uint64
|
||||||
|
switch v := claims["sub"].(type) {
|
||||||
|
case string:
|
||||||
|
n, err := strconv.ParseUint(strings.TrimSpace(v), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, true, nil
|
||||||
|
}
|
||||||
|
id = n
|
||||||
|
case float64:
|
||||||
|
if v <= 0 || v != float64(uint64(v)) {
|
||||||
|
return 0, true, nil
|
||||||
|
}
|
||||||
|
id = uint64(v)
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
return 0, true, nil
|
||||||
|
}
|
||||||
|
iat, err := claims.GetIssuedAt()
|
||||||
|
if err != nil || iat == nil {
|
||||||
|
return 0, true, nil
|
||||||
|
}
|
||||||
|
var user models.User
|
||||||
|
result := gdb.WithContext(ctx).Where("id = ?", id).Limit(1).Find(&user)
|
||||||
|
if result.Error != nil {
|
||||||
|
return 0, false, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return 0, true, nil
|
||||||
|
}
|
||||||
|
if user.TokensValidAfter != nil && iat.Time.Before(*user.TokensValidAfter) {
|
||||||
|
return 0, true, nil
|
||||||
|
}
|
||||||
|
banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, app), gdb.WithContext(ctx), []uint{user.ID})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
if banned[user.ID] {
|
||||||
|
return 0, true, nil
|
||||||
|
}
|
||||||
|
return user.ID, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthProviders lists the social sign-in providers a social-login plugin
|
||||||
|
// has enabled, as {success: true, providers: [{name, label}]}. The plugin
|
||||||
|
// publishes a classes.OAuthProviderSource, looked up on every request
|
||||||
|
// because it boots after this plugin; without one the list is empty.
|
||||||
func OAuthProviders(app *backpack.App) http.HandlerFunc {
|
func OAuthProviders(app *backpack.App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
providers := []any{}
|
||||||
|
if src, ok := oauthProviderSource(app); ok {
|
||||||
|
for _, p := range src.EnabledOAuthProviders(r.Context()) {
|
||||||
|
providers = append(providers, map[string]any{"name": p.Name, "label": p.Label})
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"success": true,
|
"success": true,
|
||||||
"providers": wire.Slice([]any{}),
|
"providers": wire.Slice(providers),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// oauthProviderSource returns the social-login plugin's published provider
|
||||||
|
// source, if any.
|
||||||
|
func oauthProviderSource(app *backpack.App) (classes.OAuthProviderSource, bool) {
|
||||||
|
if app == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
src, ok := app.Lookup[classes.OAuthProviderSource]()
|
||||||
|
return src, ok && src != nil
|
||||||
|
}
|
||||||
|
|
||||||
const forgotPasswordBody = "If that email exists, a reset link has been sent."
|
const forgotPasswordBody = "If that email exists, a reset link has been sent."
|
||||||
|
|
||||||
// ForgotPassword always returns the same 200 body. A real account also gets a reset mail.
|
// ForgotPassword always returns the same 200 body. A real account also gets a reset mail.
|
||||||
@@ -250,14 +382,14 @@ func ForgotPassword(app *backpack.App) http.HandlerFunc {
|
|||||||
ctx := codeContext(r.Context(), app)
|
ctx := codeContext(r.Context(), app)
|
||||||
user, err := classes.LookupByEmail(ctx, gdb, strField(fields, "email"))
|
user, err := classes.LookupByEmail(ctx, gdb, strField(fields, "email"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Default().Error("golem15.user: forgot-password lookup", "err", err)
|
pluginLog.Error("golem15.user: forgot-password lookup", "err", err)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody})
|
writeJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user != nil {
|
if user != nil {
|
||||||
code, err := classes.IssueResetCode(ctx, gdb, user)
|
code, err := classes.IssueResetCode(ctx, gdb, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Default().Error("golem15.user: issue reset code", "err", err)
|
pluginLog.Error("golem15.user: issue reset code", "err", err)
|
||||||
} else {
|
} else {
|
||||||
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.restore"), user.Email, mailVars(displayName(user), resetLink(app, user.ID, code), packed))
|
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.restore"), user.Email, mailVars(displayName(user), resetLink(app, user.ID, code), packed))
|
||||||
@@ -310,10 +442,17 @@ func ResetPassword(app *backpack.App) http.HandlerFunc {
|
|||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The reset code was mailed to the account's address, so the write
|
||||||
|
// that stores the new password also records the proof of that
|
||||||
|
// 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.
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := gdb.WithContext(r.Context()).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{
|
if err := gdb.WithContext(r.Context()).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{
|
||||||
"password": hashed,
|
"password": hashed,
|
||||||
"tokens_valid_after": now,
|
"tokens_valid_after": now.Truncate(time.Second),
|
||||||
|
"email_verified_at": now,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
@@ -358,12 +497,20 @@ func Activate(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ActivateByCode activates from a public "{id}!{code}" value and mints a token.
|
// ActivateByCode activates from a public "{id}!{code}" value and mints a token.
|
||||||
|
// With golem15.user.jwt.token_cookie on, the token goes into the auth.cookie
|
||||||
|
// session cookie instead of the body.
|
||||||
func ActivateByCode(app *backpack.App) http.HandlerFunc {
|
func ActivateByCode(app *backpack.App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gdb, secret, ok := sessionDeps(w, app)
|
gdb, secret, ok := sessionDeps(w, app)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cc, err := sessionCookie(app)
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.Error("golem15.user: token cookie config", "err", err)
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
fields, err := readFields(r)
|
fields, err := readFields(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
@@ -409,11 +556,7 @@ func ActivateByCode(app *backpack.App) http.HandlerFunc {
|
|||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeSession(w, app, cc, token, map[string]any{"message": "Account activated", "user": payload})
|
||||||
"message": "Account activated",
|
|
||||||
"token": token,
|
|
||||||
"user": payload,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,17 +591,29 @@ func Update(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
name := strField(fields, "name")
|
name := strField(fields, "name")
|
||||||
surname := strField(fields, "surname")
|
surname := strField(fields, "surname")
|
||||||
if err := gdb.WithContext(r.Context()).Model(user).Updates(map[string]any{
|
email := strField(fields, "email")
|
||||||
|
columns := map[string]any{
|
||||||
"name": name,
|
"name": name,
|
||||||
"surname": surname,
|
"surname": surname,
|
||||||
"email": strField(fields, "email"),
|
"email": email,
|
||||||
}).Error; err != nil {
|
}
|
||||||
|
// The proof of the address follows the address (D-27): a new email
|
||||||
|
// is unproven until its owner proves it. A change of letter case
|
||||||
|
// only keeps it, because email matching is case-insensitive.
|
||||||
|
emailChanged := !strings.EqualFold(email, user.Email)
|
||||||
|
if emailChanged {
|
||||||
|
columns["email_verified_at"] = nil
|
||||||
|
}
|
||||||
|
if err := gdb.WithContext(r.Context()).Model(user).Updates(columns).Error; err != nil {
|
||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.Name = &name
|
user.Name = &name
|
||||||
user.Surname = &surname
|
user.Surname = &surname
|
||||||
user.Email = strField(fields, "email")
|
user.Email = email
|
||||||
|
if emailChanged {
|
||||||
|
user.EmailVerifiedAt = nil
|
||||||
|
}
|
||||||
payload, err := apiArray(r.Context(), app, user)
|
payload, err := apiArray(r.Context(), app, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
@@ -469,24 +624,29 @@ func Update(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ChangePassword replaces a self-set password and invalidates older tokens.
|
// ChangePassword replaces a self-set password and invalidates older tokens.
|
||||||
// The token that made this call stays valid.
|
// The token is read as the plugin's jwt guard reads it (the bearer header,
|
||||||
|
// then the token and auth_token cookies), and the token that made this call,
|
||||||
|
// header or cookie, stays valid.
|
||||||
func ChangePassword(app *backpack.App) http.HandlerFunc {
|
func ChangePassword(app *backpack.App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gdb, secret, ok := sessionDeps(w, app)
|
gdb, secret, ok := sessionDeps(w, app)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
raw := bearerToken(r)
|
raw := classes.SessionToken(r)
|
||||||
sub, iat, _, _, err := bouncer.VerifyClaims(raw, secret)
|
sub, iat, _, _, err := bouncer.VerifyClaims(raw, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeUnauthorized(w)
|
writeUnauthorized(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var bl bouncer.BlacklistStore
|
principal, err := sessionGuard(app, secret).Authenticate(r)
|
||||||
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
|
if err != nil || principal == nil {
|
||||||
bl = store
|
writeUnauthorized(w)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil {
|
// The guard and SessionToken read the same token; a differing
|
||||||
|
// subject fails closed.
|
||||||
|
if strconv.FormatUint(uint64(principal.ID), 10) != sub {
|
||||||
writeUnauthorized(w)
|
writeUnauthorized(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -501,7 +661,7 @@ func ChangePassword(app *backpack.App) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !user.HasSelfSetPassword {
|
if !user.HasSelfSetPassword {
|
||||||
slog.Default().Error("golem15.user: change-password requires a self-set password", "user_id", user.ID)
|
pluginLog.Error("golem15.user: change-password requires a self-set password", "user_id", user.ID)
|
||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -612,12 +772,20 @@ func MarketingConsent(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register creates an account and follows activate_mode: auto, user, or admin.
|
// Register creates an account and follows activate_mode: auto, user, or admin.
|
||||||
|
// With golem15.user.jwt.token_cookie on, the token of an account it signs in
|
||||||
|
// goes into the auth.cookie session cookie instead of the body.
|
||||||
func Register(app *backpack.App) http.HandlerFunc {
|
func Register(app *backpack.App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gdb, _, ok := sessionDeps(w, app)
|
gdb, _, ok := sessionDeps(w, app)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cc, err := sessionCookie(app)
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.Error("golem15.user: token cookie config", "err", err)
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
allow, throttle, mode, require := registrationSettings(app)
|
allow, throttle, mode, require := registrationSettings(app)
|
||||||
ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))
|
ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))
|
||||||
if !allow {
|
if !allow {
|
||||||
@@ -648,13 +816,20 @@ func Register(app *backpack.App) http.HandlerFunc {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = FireRegisterEvent(r.Context(), app, user, fields)
|
// The row is committed: a failing listener is logged and never undoes
|
||||||
|
// or fails the registration, as in Winter.
|
||||||
|
if err := FireRegisterEvent(r.Context(), app, user, fields); err != nil {
|
||||||
|
pluginLog.Error("golem15.user: register listener failed", "user_id", user.ID, "err", err)
|
||||||
|
}
|
||||||
|
if err := FireCreatedEvent(r.Context(), app, user); err != nil {
|
||||||
|
pluginLog.Error("golem15.user: created listener failed", "user_id", user.ID, "err", err)
|
||||||
|
}
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
if mode == "user" {
|
if mode == "user" {
|
||||||
vars := mailVars(displayName(user), "", "")
|
vars := mailVars(displayName(user), "", "")
|
||||||
code, err := classes.IssueActivationCode(ctx, gdb, user)
|
code, err := classes.IssueActivationCode(ctx, gdb, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Default().Error("golem15.user: issue activation code", "err", err)
|
pluginLog.Error("golem15.user: issue activation code", "err", err)
|
||||||
} else {
|
} else {
|
||||||
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||||
vars = mailVars(displayName(user), activationLink(app, user.ID, code), packed)
|
vars = mailVars(displayName(user), activationLink(app, user.ID, code), packed)
|
||||||
@@ -674,13 +849,30 @@ func Register(app *backpack.App) http.HandlerFunc {
|
|||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload})
|
writeSession(w, app, cc, token, map[string]any{"user": payload})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{})
|
writeJSON(w, http.StatusOK, map[string]any{})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegistrationSettings returns the registration settings /register
|
||||||
|
// applies: allow is golem15.user.registration.allow_registration (default
|
||||||
|
// true), throttle is golem15.user.registration.use_register_throttle
|
||||||
|
// (default true), mode is golem15.user.activation.activate_mode (auto, user
|
||||||
|
// or admin; default auto) and require is
|
||||||
|
// golem15.user.activation.require_activation (default true).
|
||||||
|
func RegistrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool) {
|
||||||
|
return registrationSettings(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrationsFromIP counts the users created from ip in the last 60
|
||||||
|
// minutes (created_ip_address). /register refuses a registration when the
|
||||||
|
// throttle is on and the count is 3 or more. A query error counts as 0.
|
||||||
|
func RegistrationsFromIP(ctx context.Context, gdb *gorm.DB, ip string) int64 {
|
||||||
|
return registrationsFromIP(ctx, gdb, ip)
|
||||||
|
}
|
||||||
|
|
||||||
func registrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool) {
|
func registrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool) {
|
||||||
allow, throttle, require = true, true, true
|
allow, throttle, require = true, true, true
|
||||||
mode = "auto"
|
mode = "auto"
|
||||||
@@ -739,38 +931,41 @@ func appConfig(app *backpack.App) *compass.Config {
|
|||||||
return app.Config
|
return app.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authenticate resolves the signed-in user of r for the session routes that
|
||||||
|
// run without jwt.auth: /fetch, /update, /activate, /marketing-consent,
|
||||||
|
// /avatar and /avatar/remove. It reads the token as the plugin's jwt guard
|
||||||
|
// does (the bearer header, then the token and auth_token cookies), applies
|
||||||
|
// the blacklist and the user's tokens_valid_after cutoff, and answers 401
|
||||||
|
// otherwise.
|
||||||
func authenticate(w http.ResponseWriter, r *http.Request, app *backpack.App) (*models.User, bool) {
|
func authenticate(w http.ResponseWriter, r *http.Request, app *backpack.App) (*models.User, bool) {
|
||||||
gdb, secret, ok := sessionDeps(w, app)
|
gdb, secret, ok := sessionDeps(w, app)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
raw := bearerToken(r)
|
principal, err := sessionGuard(app, secret).Authenticate(r)
|
||||||
sub, _, _, _, err := bouncer.VerifyClaims(raw, secret)
|
if err != nil || principal == nil || principal.ID == 0 {
|
||||||
if err != nil {
|
|
||||||
writeUnauthorized(w)
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
id, err := strconv.ParseUint(sub, 10, 64)
|
|
||||||
if err != nil || id == 0 {
|
|
||||||
writeUnauthorized(w)
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
var bl bouncer.BlacklistStore
|
|
||||||
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
|
|
||||||
bl = store
|
|
||||||
}
|
|
||||||
if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil {
|
|
||||||
writeUnauthorized(w)
|
writeUnauthorized(w)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
var user models.User
|
var user models.User
|
||||||
if err := gdb.WithContext(r.Context()).Where("id = ?", id).Take(&user).Error; err != nil {
|
if err := gdb.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error; err != nil {
|
||||||
writeUnauthorized(w)
|
writeUnauthorized(w)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
return &user, true
|
return &user, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sessionGuard is the plugin's jwt guard as plugin.go Boot registers it: the
|
||||||
|
// bearer header, then the cookies of classes.SessionCookieNames, checked
|
||||||
|
// against the published bouncer.BlacklistStore when there is one.
|
||||||
|
func sessionGuard(app *backpack.App, secret string) bouncer.Guard {
|
||||||
|
var bl bouncer.BlacklistStore
|
||||||
|
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
|
||||||
|
bl = store
|
||||||
|
}
|
||||||
|
return bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, classes.SessionCookieNames()...)
|
||||||
|
}
|
||||||
|
|
||||||
func sessionDeps(w http.ResponseWriter, app *backpack.App) (*gorm.DB, string, bool) {
|
func sessionDeps(w http.ResponseWriter, app *backpack.App) (*gorm.DB, string, bool) {
|
||||||
if app == nil {
|
if app == nil {
|
||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
@@ -830,6 +1025,13 @@ func mintFor(app *backpack.App, secret string, user *models.User, issuer string)
|
|||||||
return token, err
|
return token, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshTTL is the refresh window of a session token:
|
||||||
|
// golem15.user.jwt.refresh_ttl minutes, default 20160 (two weeks). A cookie
|
||||||
|
// carrying the token should live this long.
|
||||||
|
func RefreshTTL(app *backpack.App) time.Duration {
|
||||||
|
return refreshTTL(app)
|
||||||
|
}
|
||||||
|
|
||||||
func refreshTTL(app *backpack.App) time.Duration {
|
func refreshTTL(app *backpack.App) time.Duration {
|
||||||
mins := 20160
|
mins := 20160
|
||||||
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.refresh_ttl") > 0 {
|
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.refresh_ttl") > 0 {
|
||||||
@@ -875,11 +1077,11 @@ func sendTemplateVars(ctx context.Context, app *backpack.App, template, to strin
|
|||||||
}
|
}
|
||||||
mailer, ok := app.Lookup[postcard.Mailer]()
|
mailer, ok := app.Lookup[postcard.Mailer]()
|
||||||
if !ok || mailer == nil {
|
if !ok || mailer == nil {
|
||||||
slog.Default().Error("golem15.user: mailer is not configured", "template", template)
|
pluginLog.Error("golem15.user: mailer is not configured", "template", template)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := mailer.Send(ctx, postcard.Message{Template: template, To: []string{to}, Vars: vars}); err != nil {
|
if err := mailer.Send(ctx, postcard.Message{Template: template, To: []string{to}, Vars: vars}); err != nil {
|
||||||
slog.Default().Error("golem15.user: send mail", "template", template, "err", err)
|
pluginLog.Error("golem15.user: send mail", "template", template, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,16 +1192,6 @@ func jsonNumber(v any) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func bearerToken(r *http.Request) string {
|
|
||||||
h := strings.TrimSpace(r.Header.Get("Authorization"))
|
|
||||||
token, ok := strings.CutPrefix(h, "Bearer ")
|
|
||||||
token = strings.TrimSpace(token)
|
|
||||||
if !ok || token == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestURL(r *http.Request) string {
|
func requestURL(r *http.Request) string {
|
||||||
scheme := "http"
|
scheme := "http"
|
||||||
if r.TLS != nil {
|
if r.TLS != nil {
|
||||||
@@ -1011,6 +1203,14 @@ func requestURL(r *http.Request) string {
|
|||||||
return scheme + "://" + r.Host + r.URL.Path
|
return scheme + "://" + r.Host + r.URL.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoteIP returns the host part of r.RemoteAddr (r.RemoteAddr itself when
|
||||||
|
// it has no port, "" when it is empty), with no proxy handling. /login and
|
||||||
|
// /register key their throttles on surf.ClientIP with the trusted proxies;
|
||||||
|
// RemoteIP stays for callers that want the connection address.
|
||||||
|
func RemoteIP(r *http.Request) string {
|
||||||
|
return remoteIP(r)
|
||||||
|
}
|
||||||
|
|
||||||
func remoteIP(r *http.Request) string {
|
func remoteIP(r *http.Request) string {
|
||||||
if r.RemoteAddr == "" {
|
if r.RemoteAddr == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -1022,6 +1222,47 @@ func remoteIP(r *http.Request) string {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tokenCookieConfig is the auth.cookie block the plugin writes the session
|
||||||
|
// cookie with: bouncer.LoadCookieConfig of the application config, or its
|
||||||
|
// defaults when there is no config.
|
||||||
|
func tokenCookieConfig(app *backpack.App) (bouncer.CookieConfig, error) {
|
||||||
|
if app == nil || app.Config == nil {
|
||||||
|
return bouncer.LoadCookieConfig(nil)
|
||||||
|
}
|
||||||
|
return bouncer.LoadCookieConfig(app.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionCookie is the golem15.sociallogin token_cookie twin: with
|
||||||
|
// golem15.user.jwt.token_cookie on it returns the auth.cookie config the
|
||||||
|
// session routes write their token cookie with, or the error that block
|
||||||
|
// gives. A nil config means the token goes into the body; auth.cookie is
|
||||||
|
// then not read.
|
||||||
|
func sessionCookie(app *backpack.App) (*bouncer.CookieConfig, error) {
|
||||||
|
if app == nil || app.Config == nil || !app.Config.Bool("golem15.user.jwt.token_cookie") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
cc, err := tokenCookieConfig(app)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSession answers 200 with a session token, the user plugin's twin of
|
||||||
|
// golem15.sociallogin's writeSession. With cc nil the token goes into
|
||||||
|
// body["token"], the key set the session routes have always answered. With
|
||||||
|
// cc set it goes only into the HttpOnly cookie cc writes (Max-Age
|
||||||
|
// golem15.user.jwt.refresh_ttl), so the token never reaches JavaScript, and
|
||||||
|
// body is answered without it.
|
||||||
|
func writeSession(w http.ResponseWriter, app *backpack.App, cc *bouncer.CookieConfig, token string, body map[string]any) {
|
||||||
|
if cc == nil {
|
||||||
|
body["token"] = token
|
||||||
|
} else {
|
||||||
|
http.SetCookie(w, cc.TokenCookie(token, refreshTTL(app)))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, body)
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
w.Header().Set("Cache-Control", "no-cache, private")
|
w.Header().Set("Cache-Control", "no-cache, private")
|
||||||
wire.WriteJSON(w, status, v)
|
wire.WriteJSON(w, status, v)
|
||||||
@@ -1034,6 +1275,18 @@ func writeInvalidLogin(w http.ResponseWriter, r *http.Request, app *backpack.App
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refuseLogin is the one refusal /login gives after the throttle check: it
|
||||||
|
// records a failed attempt for user (a nil user, an unknown email, records
|
||||||
|
// nothing) and answers the 401 invalid-login body. The wrong-password and
|
||||||
|
// the not-activated branches share it, so their answers cannot be told apart.
|
||||||
|
func refuseLogin(ctx context.Context, w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, user *models.User, ip string) {
|
||||||
|
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, false); err != nil && !classes.IsLoginRejected(err) {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeInvalidLogin(w, r, app)
|
||||||
|
}
|
||||||
|
|
||||||
func writeUnauthorized(w http.ResponseWriter) {
|
func writeUnauthorized(w http.ResponseWriter) {
|
||||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthorized"})
|
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthorized"})
|
||||||
}
|
}
|
||||||
@@ -1131,7 +1384,7 @@ func UploadAvatar(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
bucket, ok := app.Lookup[*blob.Bucket]()
|
bucket, ok := app.Lookup[*blob.Bucket]()
|
||||||
if !ok || bucket == nil {
|
if !ok || bucket == nil {
|
||||||
slog.Default().Error("golem15.user: avatar bucket is not configured")
|
pluginLog.Error("golem15.user: avatar bucket is not configured")
|
||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1282,17 +1535,9 @@ func applyAvatar(ctx context.Context, app *backpack.App, user *models.User, payl
|
|||||||
}
|
}
|
||||||
payload["has_avatar"] = true
|
payload["has_avatar"] = true
|
||||||
payload["avatar"] = f.DiskName
|
payload["avatar"] = f.DiskName
|
||||||
bucket, ok := app.Lookup[*blob.Bucket]()
|
if url, err := classes.AvatarURL(ctx, app, user, 128); err == nil && url != "" {
|
||||||
if !ok || bucket == nil {
|
payload["avatar_url"] = url
|
||||||
return
|
|
||||||
}
|
}
|
||||||
url, err := f.Thumb(ctx, bucket, 128, 128, "auto")
|
|
||||||
if err != nil {
|
|
||||||
slog.Default().Error("golem15.user: avatar thumb", "err", err)
|
|
||||||
payload["avatar_url"] = "/" + attach.BlobKey(f.DiskName)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payload["avatar_url"] = url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func avatarExt(mime string) (string, bool) {
|
func avatarExt(mime string) (string, bool) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"git.golem15.com/golem15/summercms/modules/wire"
|
"git.golem15.com/golem15/summercms/modules/wire"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -106,6 +107,8 @@ func APITokenDestroy(app *backpack.App) http.HandlerFunc {
|
|||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// An MCP session opened with this token ends.
|
||||||
|
classes.FireSubjectChanged(r.Context(), app, classes.SubjectGuardUser, user.ID, pact.MCPSubjectRevoked)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"revoked": true}})
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"revoked": true}})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
118
controllers/backend_link_controller.go
Normal file
118
controllers/backend_link_controller.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkNotFound is the one answer for an unknown, used or expired code and
|
||||||
|
// for an administrator who is no longer available: the page cannot tell
|
||||||
|
// them apart.
|
||||||
|
func linkNotFound(w http.ResponseWriter) {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendLinkShow serves GET /_user/api/v1/backend-link/{code}, the site's
|
||||||
|
// "link this account to the administrator?" page: the administrator's login
|
||||||
|
// 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.
|
||||||
|
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"))
|
||||||
|
if err != nil {
|
||||||
|
if isLinkGone(err) {
|
||||||
|
linkNotFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOpaque500(w)
|
||||||
|
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),
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func BackendLinkConfirm(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := bouncer.User(r.Context())
|
||||||
|
if !ok || principal == nil || principal.ID == 0 {
|
||||||
|
writeUnauthorized(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secret, err := classes.JWTSecret(app)
|
||||||
|
if err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
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)
|
||||||
|
if err != nil || sub != strconv.FormatUint(uint64(principal.ID), 10) {
|
||||||
|
writeUnauthorized(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields, err := readFields(r)
|
||||||
|
if err != nil {
|
||||||
|
linkNotFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
links := backendLinks(app)
|
||||||
|
_, err = links.ConfirmLinkRequest(r.Context(), strField(fields, "code"), principal.ID, iat)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case errors.Is(err, classes.ErrStaleSession):
|
||||||
|
writeJSON(w, http.StatusConflict, map[string]any{"error": "stale_session"})
|
||||||
|
return
|
||||||
|
case errors.Is(err, classes.ErrAlreadyLinked):
|
||||||
|
writeJSON(w, http.StatusConflict, map[string]any{"error": "already_linked"})
|
||||||
|
return
|
||||||
|
case isLinkGone(err):
|
||||||
|
linkNotFound(w)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
linked, found, err := links.LinkedBackendUser(r.Context(), principal.ID)
|
||||||
|
if err != nil || !found {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||||
|
"backend_user": map[string]any{"login": linked.Login, "name": linked.DisplayName},
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLinkGone(err error) bool {
|
||||||
|
return errors.Is(err, classes.ErrLinkRequestNotFound) ||
|
||||||
|
errors.Is(err, classes.ErrLinkRequestUsed) ||
|
||||||
|
errors.Is(err, classes.ErrLinkRequestExpired) ||
|
||||||
|
errors.Is(err, classes.ErrBackendUserUnavailable) ||
|
||||||
|
errors.Is(err, classes.ErrFrontendUserUnavailable)
|
||||||
|
}
|
||||||
7
controllers/logger.go
Normal file
7
controllers/logger.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import "git.golem15.com/golem15/summercms/modules/diary"
|
||||||
|
|
||||||
|
// pluginLog is the plugin's logger: its records carry component=user, so
|
||||||
|
// config/log.yaml can raise or lower them (log.components.user).
|
||||||
|
var pluginLog = diary.For("user")
|
||||||
@@ -13,13 +13,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// OAuthIdentitiesOptions configures host-mounted list/unlink handlers.
|
// OAuthIdentitiesOptions configures host-mounted list/unlink handlers.
|
||||||
// Providers defaults to google, facebook, github when nil or empty.
|
// Providers is the unlink allow-list. When nil or empty, the allow-list is
|
||||||
|
// read on every request: the KnownOAuthProviders of a published
|
||||||
|
// classes.OAuthProviderSource, else google, facebook, github and microsoft.
|
||||||
// WriteNotFound is injected by the host so 404 bodies match that app.
|
// WriteNotFound is injected by the host so 404 bodies match that app.
|
||||||
type OAuthIdentitiesOptions struct {
|
type OAuthIdentitiesOptions struct {
|
||||||
Providers []string
|
Providers []string
|
||||||
WriteNotFound func(http.ResponseWriter, *http.Request)
|
WriteNotFound func(http.ResponseWriter, *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultOAuthProviders is the unlink allow-list without options and without
|
||||||
|
// a published provider source.
|
||||||
|
var defaultOAuthProviders = []string{"google", "facebook", "github", "microsoft"}
|
||||||
|
|
||||||
|
// oauthGuardedDelete is the PHP unlink rule (User.php unlinkOAuthProvider):
|
||||||
|
// the identity goes only when the user has a self-set password or another
|
||||||
|
// identity remains.
|
||||||
|
const oauthGuardedDelete = `DELETE FROM golem15_user_oauth_identities
|
||||||
|
WHERE user_id = ? AND provider = ?
|
||||||
|
AND (EXISTS (SELECT 1 FROM users u WHERE u.id = golem15_user_oauth_identities.user_id AND u.has_self_set_password)
|
||||||
|
OR (SELECT COUNT(*) FROM golem15_user_oauth_identities oi WHERE oi.user_id = golem15_user_oauth_identities.user_id) > 1)`
|
||||||
|
|
||||||
// OAuthIdentitiesIndex lists the caller's linked OAuth providers.
|
// OAuthIdentitiesIndex lists the caller's linked OAuth providers.
|
||||||
func OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc {
|
func OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -50,19 +64,18 @@ func OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuthIdentitiesDestroy unlinks one provider for the caller.
|
// OAuthIdentitiesDestroy unlinks one provider for the caller, under the PHP
|
||||||
|
// rule: it succeeds (204) when the user has a self-set password or another
|
||||||
|
// identity remains, and answers 409 with the last_method_blocked phrase
|
||||||
|
// otherwise. An unknown provider or a missing identity goes to WriteNotFound.
|
||||||
func OAuthIdentitiesDestroy(app *backpack.App, opts OAuthIdentitiesOptions) http.HandlerFunc {
|
func OAuthIdentitiesDestroy(app *backpack.App, opts OAuthIdentitiesOptions) http.HandlerFunc {
|
||||||
providers := opts.Providers
|
|
||||||
if len(providers) == 0 {
|
|
||||||
providers = []string{"google", "facebook", "github"}
|
|
||||||
}
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
db, user, ok := oauthIdentityDeps(w, r, app)
|
db, user, ok := oauthIdentityDeps(w, r, app)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
provider := r.PathValue("provider")
|
provider := r.PathValue("provider")
|
||||||
if !slices.Contains(providers, provider) {
|
if !slices.Contains(unlinkProviders(app, opts), provider) {
|
||||||
writeOAuthIdentityNotFound(w, r, opts)
|
writeOAuthIdentityNotFound(w, r, opts)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -78,28 +91,49 @@ func OAuthIdentitiesDestroy(app *backpack.App, opts OAuthIdentitiesOptions) http
|
|||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var n int64
|
// D-13: the guarded DELETE alone is not race-safe on Postgres. Under
|
||||||
if err := db.WithContext(r.Context()).
|
// READ COMMITTED two unlinks of different identities each see the
|
||||||
Model(&models.OAuthIdentity{}).
|
// other's row in their COUNT and do not wait for each other, so both
|
||||||
Where("user_id = ?", user.ID).
|
// succeed and a password-less user loses every sign-in method. The
|
||||||
Count(&n).Error; err != nil {
|
// lock on the users row serialises them: the second DELETE runs after
|
||||||
|
// the first commits and sees one identity left.
|
||||||
|
var deleted int64
|
||||||
|
err = db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, user.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res := tx.Exec(oauthGuardedDelete, user.ID, provider)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
deleted = res.RowsAffected
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n == 1 {
|
if deleted == 0 {
|
||||||
writeJSON(w, http.StatusConflict, map[string]any{
|
writeJSON(w, http.StatusConflict, map[string]any{
|
||||||
"error": oauthLastMethodBlocked(r, app),
|
"error": oauthLastMethodBlocked(r, app),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := db.WithContext(r.Context()).Delete(&row).Error; err != nil {
|
|
||||||
writeOpaque500(w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unlinkProviders is the unlink allow-list for one request.
|
||||||
|
func unlinkProviders(app *backpack.App, opts OAuthIdentitiesOptions) []string {
|
||||||
|
if len(opts.Providers) > 0 {
|
||||||
|
return opts.Providers
|
||||||
|
}
|
||||||
|
if src, ok := oauthProviderSource(app); ok {
|
||||||
|
return src.KnownOAuthProviders()
|
||||||
|
}
|
||||||
|
return defaultOAuthProviders
|
||||||
|
}
|
||||||
|
|
||||||
func writeOAuthIdentityNotFound(w http.ResponseWriter, r *http.Request, opts OAuthIdentitiesOptions) {
|
func writeOAuthIdentityNotFound(w http.ResponseWriter, r *http.Request, opts OAuthIdentitiesOptions) {
|
||||||
if opts.WriteNotFound != nil {
|
if opts.WriteNotFound != nil {
|
||||||
opts.WriteNotFound(w, r)
|
opts.WriteNotFound(w, r)
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ const membersRelation = "members"
|
|||||||
// manager. The screen and every write need golem15.users.access_users, as in
|
// manager. The screen and every write need golem15.users.access_users, as in
|
||||||
// PHP.
|
// PHP.
|
||||||
//
|
//
|
||||||
// The members manager sets and clears users.organisation_id and nothing
|
// The members manager sets and clears users.organisation_id, and clears
|
||||||
// else. It never writes users_groups.
|
// users.organisation_role with it (a role belongs to one organisation). It
|
||||||
|
// never writes users_groups.
|
||||||
type organisationsAdminController struct {
|
type organisationsAdminController struct {
|
||||||
app appAccess
|
app appAccess
|
||||||
}
|
}
|
||||||
@@ -73,12 +74,15 @@ func (c organisationsAdminController) requireSlugFormat(ctx context.Context, mod
|
|||||||
// AdminRelationContracts declares the members relation manager: the users
|
// AdminRelationContracts declares the members relation manager: the users
|
||||||
// whose organisation_id is the organisation. Linking sets the column and
|
// whose organisation_id is the organisation. Linking sets the column and
|
||||||
// unlinking clears it; the column is nullable, so a user is never deleted.
|
// unlinking clears it; the column is nullable, so a user is never deleted.
|
||||||
|
// Both also clear organisation_role (ClearOnChange), so a role never moves
|
||||||
|
// to another organisation or outlives the membership.
|
||||||
func (organisationsAdminController) AdminRelationContracts() []cabana.RelationContract {
|
func (organisationsAdminController) AdminRelationContracts() []cabana.RelationContract {
|
||||||
return []cabana.RelationContract{{
|
return []cabana.RelationContract{{
|
||||||
Name: membersRelation,
|
Name: membersRelation,
|
||||||
Kind: cabana.RelationHasMany,
|
Kind: cabana.RelationHasMany,
|
||||||
NewRelated: func() any { return &models.User{} },
|
NewRelated: func() any { return &models.User{} },
|
||||||
ForeignKey: "organisation_id",
|
ForeignKey: "organisation_id",
|
||||||
|
ClearOnChange: []string{"organisation_role"},
|
||||||
Columns: map[string]string{
|
Columns: map[string]string{
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"email": "email",
|
"email": "email",
|
||||||
|
|||||||
@@ -22,19 +22,30 @@ import (
|
|||||||
|
|
||||||
// RegisterOptions tunes RegisterUser. Activate creates the account already
|
// RegisterOptions tunes RegisterUser. Activate creates the account already
|
||||||
// activated (activate_mode auto, or a caller that activates explicitly).
|
// activated (activate_mode auto, or a caller that activates explicitly).
|
||||||
// IP, when set, is recorded as created_ip_address and last_ip_address.
|
// IP, when set, is recorded as created_ip_address and last_ip_address; it is
|
||||||
|
// the only source of those columns, so with an empty IP they stay NULL
|
||||||
|
// whatever the fields say.
|
||||||
type RegisterOptions struct {
|
type RegisterOptions struct {
|
||||||
Activate bool
|
Activate bool
|
||||||
IP string
|
IP string
|
||||||
|
// PasswordLess creates the account with has_self_set_password=false: its
|
||||||
|
// password is random and was never known to the user (social sign-in,
|
||||||
|
// PHP password_bootstrap_source oauth_registration). The zero value
|
||||||
|
// keeps true.
|
||||||
|
PasswordLess bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterUser validates fields against the user model's rules, hashes the
|
// RegisterUser validates fields against the user model's rules, hashes the
|
||||||
// password with golem15.user.password.bcrypt_cost (default 10), fills the
|
// password with golem15.user.password.bcrypt_cost (default 10), fills only
|
||||||
// fillable columns and creates the users row on db, which may be a
|
// the models.User RegisterFillable columns (name, surname, email, password,
|
||||||
// transaction. Validation failures are returned as Laravel's errors object
|
// preferred_locale, marketing_consent) and creates the users row on db,
|
||||||
// (attribute to messages) with a nil user and nothing written; they are not
|
// which may be a transaction. Any other key in fields is ignored: an
|
||||||
// written to a response. fields is not modified. The register event is not
|
// organisation membership and role are set with an explicit update after
|
||||||
// fired: call FireRegisterEvent once the row is committed.
|
// creation, as an application's onboarding does, and the IP columns come
|
||||||
|
// only from opts.IP. Validation failures are returned as Laravel's errors
|
||||||
|
// object (attribute to messages) with a nil user and nothing written; they
|
||||||
|
// are not written to a response. fields is not modified. The register event
|
||||||
|
// is not fired: call FireRegisterEvent once the row is committed.
|
||||||
func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields map[string]any, opts RegisterOptions) (*models.User, map[string][]string, error) {
|
func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields map[string]any, opts RegisterOptions) (*models.User, map[string][]string, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return nil, nil, errors.New("golem15.user: gorm handle is missing")
|
return nil, nil, errors.New("golem15.user: gorm handle is missing")
|
||||||
@@ -59,16 +70,19 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
input["password"] = hashed
|
input["password"] = hashed
|
||||||
if opts.IP != "" {
|
|
||||||
input["created_ip_address"] = opts.IP
|
|
||||||
input["last_ip_address"] = opts.IP
|
|
||||||
}
|
|
||||||
user := &models.User{}
|
user := &models.User{}
|
||||||
production := app == nil || app.Config == nil || !app.Config.Bool("app.debug")
|
production := app == nil || app.Config == nil || !app.Config.Bool("app.debug")
|
||||||
if err := lagoon.Fill(user, user.Fillable(), input, production); err != nil {
|
if err := lagoon.Fill(user, user.RegisterFillable(), input, production); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
user.HasSelfSetPassword = true
|
// The IP columns are server-set after the fill, so a body value can never
|
||||||
|
// reach them.
|
||||||
|
if opts.IP != "" {
|
||||||
|
created, last := opts.IP, opts.IP
|
||||||
|
user.CreatedIPAddress = &created
|
||||||
|
user.LastIPAddress = &last
|
||||||
|
}
|
||||||
|
user.HasSelfSetPassword = !opts.PasswordLess
|
||||||
if opts.Activate {
|
if opts.Activate {
|
||||||
user.IsActivated = true
|
user.IsActivated = true
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -77,13 +91,51 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
|
|||||||
if err := db.WithContext(ctx).Create(user).Error; err != nil {
|
if err := db.WithContext(ctx).Create(user).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
if err := classes.AssignHandle(ctx, db, user); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
return user, nil, nil
|
return user, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FinishLogin runs the steps password Login takes after it has
|
||||||
|
// authenticated a user, 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. 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.
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
if user == nil || user.ID == 0 {
|
||||||
|
return errors.New("golem15.user: finishing a login needs a stored user")
|
||||||
|
}
|
||||||
|
if user.DeletedAt.Valid {
|
||||||
|
if err := db.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.DeletedAt = gorm.DeletedAt{}
|
||||||
|
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
||||||
|
}
|
||||||
|
if ip != "" {
|
||||||
|
if err := db.WithContext(ctx).Unscoped().Model(user).Update("last_ip_address", ip).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
addr := ip
|
||||||
|
user.LastIPAddress = &addr
|
||||||
|
}
|
||||||
|
if err := classes.TouchLastSeen(ctx, db, user.ID); err != nil {
|
||||||
|
pluginLog.Warn("golem15.user: stamp last_seen on login", "user_id", user.ID, "err", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// FireRegisterEvent fires classes.RegisterEvent for user with Payload set
|
// FireRegisterEvent fires classes.RegisterEvent for user with Payload set
|
||||||
// to a copy of fields without password and password_confirmation, and
|
// to a copy of fields without password and password_confirmation, and
|
||||||
// returns the listeners' error. /register ignores that error, as Winter
|
// returns the listeners' error. /register logs that error and continues, as
|
||||||
// does not stop a registration for a failing listener.
|
// Winter does not stop a registration for a failing listener. Payload is
|
||||||
|
// untrusted client input: it carries every extra key the client sent.
|
||||||
func FireRegisterEvent(ctx context.Context, app *backpack.App, user *models.User, fields map[string]any) error {
|
func FireRegisterEvent(ctx context.Context, app *backpack.App, user *models.User, fields map[string]any) error {
|
||||||
if app == nil || app.Events == nil {
|
if app == nil || app.Events == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -97,6 +149,36 @@ func FireRegisterEvent(ctx context.Context, app *backpack.App, user *models.User
|
|||||||
return app.Events.Fire(ctx, &classes.RegisterEvent{User: user, Payload: payload})
|
return app.Events.Fire(ctx, &classes.RegisterEvent{User: user, Payload: payload})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FireCreatedEvent fires classes.CreatedEvent (user.Created) for user and
|
||||||
|
// returns the listeners' error. /register logs that error and continues, as
|
||||||
|
// it does for FireRegisterEvent.
|
||||||
|
func FireCreatedEvent(ctx context.Context, app *backpack.App, user *models.User) error {
|
||||||
|
if app == nil || app.Events == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return app.Events.Fire(ctx, &classes.CreatedEvent{User: user})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FireDeletingEvent fires classes.DeletingEvent (user.Deleting) for user
|
||||||
|
// inside the delete transaction. FormAfterDelete returns the listeners'
|
||||||
|
// error so a failed cascade rolls the delete back.
|
||||||
|
func FireDeletingEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB) error {
|
||||||
|
if app == nil || app.Events == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return app.Events.Fire(ctx, &classes.DeletingEvent{User: user, Tx: tx})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FireDeactivatingEvent fires classes.DeactivatingEvent (user.Deactivating)
|
||||||
|
// for user inside the deactivation transaction and returns the listeners'
|
||||||
|
// error, so a failed listener rolls the deactivation back.
|
||||||
|
func FireDeactivatingEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB) error {
|
||||||
|
if app == nil || app.Events == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return app.Events.Fire(ctx, &classes.DeactivatingEvent{User: user, Tx: tx})
|
||||||
|
}
|
||||||
|
|
||||||
// IssueToken mints the session JWT for user exactly as /register and
|
// IssueToken mints the session JWT for user exactly as /register and
|
||||||
// /login do: the plugin's JWT secret, golem15.user.jwt.ttl and the request
|
// /login do: the plugin's JWT secret, golem15.user.jwt.ttl and the request
|
||||||
// URL as the issuer.
|
// URL as the issuer.
|
||||||
|
|||||||
39
controllers/token_me.go
Normal file
39
controllers/token_me.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenMe serves GET /_user/api/v1/token/me behind user.api_token and
|
||||||
|
// user.scope:read: the token owner's user payload, the same {"user": ...}
|
||||||
|
// body GET /fetch answers for a session. It is the target of the MCP tool
|
||||||
|
// user_me. The guard answers 401 for a missing or unusable token and the
|
||||||
|
// scope middleware 403 for a token without read.
|
||||||
|
func TokenMe(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := bouncer.User(r.Context())
|
||||||
|
if !ok || principal == nil || principal.ID == 0 {
|
||||||
|
writeUnauthorized(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gdb, _, ok := sessionDeps(w, app)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var user models.User
|
||||||
|
if err := gdb.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error; err != nil {
|
||||||
|
writeUnauthorized(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := apiArray(r.Context(), app, &user)
|
||||||
|
if err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user": payload})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,16 +121,30 @@ func (c usergroupsAdminController) FormBeforeUpdate(ctx context.Context, model a
|
|||||||
// FormBeforeDelete refuses the delete of a privileged group without
|
// FormBeforeDelete refuses the delete of a privileged group without
|
||||||
// golem15.users.manage_privileged_groups: it would take the privilege from
|
// golem15.users.manage_privileged_groups: it would take the privilege from
|
||||||
// every member. The record is the stored row, loaded in the delete's
|
// every member. The record is the stored row, loaded in the delete's
|
||||||
// transaction.
|
// transaction. A delete that goes ahead reads the members there; each gets
|
||||||
|
// a pact.MCPSubjectChanged permissions event after the delete commits,
|
||||||
|
// since the group's grants are gone (FormAfterDelete removes the
|
||||||
|
// memberships).
|
||||||
func (c usergroupsAdminController) FormBeforeDelete(ctx context.Context, model any) error {
|
func (c usergroupsAdminController) FormBeforeDelete(ctx context.Context, model any) error {
|
||||||
group, ok := model.(*models.UserGroup)
|
group, ok := model.(*models.UserGroup)
|
||||||
if !ok || group == nil {
|
if !ok || group == nil {
|
||||||
return errors.New("user groups admin: form model is not a user group")
|
return errors.New("user groups admin: form model is not a user group")
|
||||||
}
|
}
|
||||||
if !classes.IsPrivilegedCode(classes.PrivilegedGroupCodes(c.app.config()), group.Code) {
|
if classes.IsPrivilegedCode(classes.PrivilegedGroupCodes(c.app.config()), group.Code) {
|
||||||
|
if err := c.requirePrivilegedPermission(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return c.requirePrivilegedPermission(ctx)
|
members, err := groupMemberIDs(ctx, tx, group.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.permissionsChanged(ctx, tx, members)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormAfterDelete removes the memberships of the deleted group in the same
|
// FormAfterDelete removes the memberships of the deleted group in the same
|
||||||
@@ -147,6 +163,49 @@ func (usergroupsAdminController) FormAfterDelete(ctx context.Context, model any)
|
|||||||
Where("user_group_id = ?", group.ID).Delete(&models.UsersGroup{}).Error
|
Where("user_group_id = ?", group.ID).Delete(&models.UsersGroup{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormAfterUpdate tells the MCP endpoint that the members of the group may
|
||||||
|
// do something else now (the form saves the group's permissions): one
|
||||||
|
// pact.MCPSubjectChanged permissions event per member, the members read in
|
||||||
|
// the save's transaction and the events fired after it commits.
|
||||||
|
func (c usergroupsAdminController) FormAfterUpdate(ctx context.Context, model any) error {
|
||||||
|
group, ok := model.(*models.UserGroup)
|
||||||
|
if !ok || group == nil {
|
||||||
|
return errors.New("user groups admin: form model is not a user group")
|
||||||
|
}
|
||||||
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("user groups admin: an update needs its transaction")
|
||||||
|
}
|
||||||
|
members, err := groupMemberIDs(ctx, tx, group.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.permissionsChanged(ctx, tx, members)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// permissionsChanged fires one permissions subject event per user after tx
|
||||||
|
// commits.
|
||||||
|
func (c usergroupsAdminController) permissionsChanged(ctx context.Context, tx *gorm.DB, userIDs []uint) {
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app := c.app.application()
|
||||||
|
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||||
|
for _, id := range userIDs {
|
||||||
|
classes.FireSubjectChanged(ctx, app, classes.SubjectGuardUser, id, pact.MCPSubjectPermissions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupMemberIDs reads the ids of the group's members through tx.
|
||||||
|
func groupMemberIDs(ctx context.Context, tx *gorm.DB, groupID uint) ([]uint, error) {
|
||||||
|
ids := []uint{}
|
||||||
|
err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
|
||||||
|
Model(&models.UsersGroup{}).Where("user_group_id = ?", groupID).Order("user_id").Pluck("user_id", &ids).Error
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
|
||||||
// requirePrivilegedPermission answers nil when the administrator on ctx
|
// requirePrivilegedPermission answers nil when the administrator on ctx
|
||||||
// holds golem15.users.manage_privileged_groups, else the 403 of the screen.
|
// holds golem15.users.manage_privileged_groups, else the 403 of the screen.
|
||||||
// A context without a principal is refused.
|
// A context without a principal is refused.
|
||||||
@@ -184,7 +243,9 @@ func sameCode(a, b *string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AdminPermissionOptions offers the rows of
|
// AdminPermissionOptions offers the rows of
|
||||||
// golem15_user_frontend_permissions, by tab and code. None is locked.
|
// golem15_user_frontend_permissions, by tab and code, then the frontend
|
||||||
|
// permission codes activated plugins declare (classes.FrontendPermissionCatalog).
|
||||||
|
// None is locked.
|
||||||
func (c usergroupsAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
|
func (c usergroupsAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
|
||||||
if field != permissionsField {
|
if field != permissionsField {
|
||||||
return nil, fmt.Errorf("user groups admin: unknown permission field %q", field)
|
return nil, fmt.Errorf("user groups admin: unknown permission field %q", field)
|
||||||
@@ -193,19 +254,7 @@ func (c usergroupsAdminController) AdminPermissionOptions(ctx context.Context, f
|
|||||||
if db == nil {
|
if db == nil {
|
||||||
return nil, errors.New("user groups admin: gorm handle is missing")
|
return nil, errors.New("user groups admin: gorm handle is missing")
|
||||||
}
|
}
|
||||||
rows, err := classes.FrontendPermissionOptions(ctx, db)
|
return classes.FrontendPermissionCatalog(ctx, c.app.application(), db)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
options := make([]cabana.PermissionOption, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab}
|
|
||||||
if row.Comment != nil {
|
|
||||||
option.Comment = *row.Comment
|
|
||||||
}
|
|
||||||
options = append(options, option)
|
|
||||||
}
|
|
||||||
return options, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminPermissionValues returns the group's stored permissions, read with
|
// AdminPermissionValues returns the group's stored permissions, read with
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
@@ -211,7 +212,8 @@ func (c usersAdminController) guardPrivilegedMember(ctx context.Context, userID
|
|||||||
// FormBeforeUpdate starts with the takeover guard: a changed email or a
|
// FormBeforeUpdate starts with the takeover guard: a changed email or a
|
||||||
// submitted password on a member of a privileged group needs
|
// submitted password on a member of a privileged group needs
|
||||||
// golem15.users.manage_privileged_groups. Everything else an update does
|
// golem15.users.manage_privileged_groups. Everything else an update does
|
||||||
// comes after it.
|
// comes after it, including clearing the proof of the address
|
||||||
|
// (email_verified_at) when the email changes.
|
||||||
func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||||
user, ok := model.(*models.User)
|
user, ok := model.(*models.User)
|
||||||
if !ok || user == nil {
|
if !ok || user == nil {
|
||||||
@@ -223,19 +225,49 @@ func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) e
|
|||||||
if err := c.requireFreeEmail(ctx, user); err != nil {
|
if err := c.requireFreeEmail(ctx, user); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// The proof of the address follows the address (D-27): a changed email
|
||||||
|
// is unproven until its owner proves it; a change of letter case only
|
||||||
|
// keeps it. cabana's Save writes the cleared column.
|
||||||
|
stored, err := c.storedEmail(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(stored, user.Email) {
|
||||||
|
user.EmailVerifiedAt = nil
|
||||||
|
}
|
||||||
changed, err := c.storePassword(ctx, user)
|
changed, err := c.storePassword(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if changed {
|
if changed {
|
||||||
// An admin password reset ends every session of the user, as the
|
// An admin password reset ends every session of the user, as the
|
||||||
// user's own change-password does.
|
// user's own change-password does. The cutoff is truncated to whole
|
||||||
now := time.Now()
|
// 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)
|
||||||
user.TokensValidAfter = &now
|
user.TokensValidAfter = &now
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormAfterUpdate tells the MCP endpoint that what the user may do may have
|
||||||
|
// changed (their permissions or group memberships are saved by this form):
|
||||||
|
// one pact.MCPSubjectChanged permissions event for the user, fired after the
|
||||||
|
// save commits. Every MCP call re-checks anyway; the event only refreshes
|
||||||
|
// the tool list sooner.
|
||||||
|
func (c usersAdminController) FormAfterUpdate(ctx context.Context, model any) error {
|
||||||
|
user, ok := model.(*models.User)
|
||||||
|
if !ok || user == nil {
|
||||||
|
return errors.New("users admin: form model is not a user")
|
||||||
|
}
|
||||||
|
tx, _ := cabana.TxFromContext(ctx)
|
||||||
|
app := c.application()
|
||||||
|
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||||
|
classes.FireSubjectChanged(ctx, app, classes.SubjectGuardUser, user.ID, pact.MCPSubjectPermissions)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// FormBeforeCreate checks the email against deactivated users too and stores
|
// FormBeforeCreate checks the email against deactivated users too and stores
|
||||||
// the hash of the submitted password. A user created here starts not
|
// the hash of the submitted password. A user created here starts not
|
||||||
// activated: is_activated is never written from the form, and only the
|
// activated: is_activated is never written from the form, and only the
|
||||||
@@ -302,23 +334,33 @@ func (c usersAdminController) storePassword(ctx context.Context, user *models.Us
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormAfterCreate sends the invitation when the send_invite checkbox was
|
// FormAfterCreate assigns a unique handle, fires user.Created
|
||||||
// set: an activation code is issued inside the create's transaction, and
|
// (CreatedEventName) inside the create transaction, and sends the
|
||||||
// exactly one golem15.user::mail.invite message with the activation link
|
// invitation when the send_invite checkbox was set: an activation code is
|
||||||
// leaves after the commit. A create that rolls back sends nothing.
|
// issued inside the same transaction, and exactly one
|
||||||
|
// golem15.user::mail.invite message with the activation link leaves after
|
||||||
|
// the commit. A create that rolls back sends nothing.
|
||||||
func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) error {
|
func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) error {
|
||||||
user, ok := model.(*models.User)
|
user, ok := model.(*models.User)
|
||||||
if !ok || user == nil {
|
if !ok || user == nil {
|
||||||
return errors.New("users admin: form model is not a user")
|
return errors.New("users admin: form model is not a user")
|
||||||
}
|
}
|
||||||
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
|
||||||
if invite, _ := values["send_invite"].(bool); !invite {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
tx, ok := cabana.TxFromContext(ctx)
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("users admin: a create needs its transaction")
|
return errors.New("users admin: a create needs its transaction")
|
||||||
}
|
}
|
||||||
|
if err := classes.AssignHandle(ctx, tx.Session(&gorm.Session{NewDB: true}), user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if app := c.application(); app != nil && app.Events != nil {
|
||||||
|
if err := app.Events.Fire(ctx, &classes.CreatedEvent{User: user, Tx: tx}); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", classes.CreatedEventName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
||||||
|
if invite, _ := values["send_invite"].(bool); !invite {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
code, err := classes.IssueActivationCode(ctx, tx.Session(&gorm.Session{NewDB: true}), user)
|
code, err := classes.IssueActivationCode(ctx, tx.Session(&gorm.Session{NewDB: true}), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -342,7 +384,9 @@ const groupsField = "groups"
|
|||||||
// AdminFieldRelations binds the organisation picker and the groups field.
|
// AdminFieldRelations binds the organisation picker and the groups field.
|
||||||
// organisation_id is a protected column, so the contract opts in to writing
|
// organisation_id is a protected column, so the contract opts in to writing
|
||||||
// it through this one field; the submitted id is still checked against the
|
// it through this one field; the submitted id is still checked against the
|
||||||
// options. groups is the users_groups pivot: a save that carries the field
|
// options. A save that changes the organisation (to another one or to none)
|
||||||
|
// clears organisation_role (ClearOnChange) unless the same save writes it.
|
||||||
|
// groups is the users_groups pivot: a save that carries the field
|
||||||
// replaces the user's memberships, and AdminRelationLocks decides which of
|
// replaces the user's memberships, and AdminRelationLocks decides which of
|
||||||
// them the administrator may change.
|
// them the administrator may change.
|
||||||
func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
|
func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||||
@@ -354,6 +398,7 @@ func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract
|
|||||||
ForeignKey: "organisation_id",
|
ForeignKey: "organisation_id",
|
||||||
LabelColumn: "name",
|
LabelColumn: "name",
|
||||||
WritableForeignKey: true,
|
WritableForeignKey: true,
|
||||||
|
ClearOnChange: []string{"organisation_role"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Field: groupsField,
|
Field: groupsField,
|
||||||
@@ -400,7 +445,9 @@ func (c usersAdminController) AdminRelationLocks(ctx context.Context, field stri
|
|||||||
const permissionsField = "permissions"
|
const permissionsField = "permissions"
|
||||||
|
|
||||||
// AdminPermissionOptions offers the rows of
|
// AdminPermissionOptions offers the rows of
|
||||||
// golem15_user_frontend_permissions, by tab and code. None is locked.
|
// golem15_user_frontend_permissions, by tab and code, then the frontend
|
||||||
|
// permission codes activated plugins declare (classes.FrontendPermissionCatalog).
|
||||||
|
// None is locked.
|
||||||
func (c usersAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
|
func (c usersAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
|
||||||
if field != permissionsField {
|
if field != permissionsField {
|
||||||
return nil, fmt.Errorf("users admin: unknown permission field %q", field)
|
return nil, fmt.Errorf("users admin: unknown permission field %q", field)
|
||||||
@@ -409,19 +456,7 @@ func (c usersAdminController) AdminPermissionOptions(ctx context.Context, field
|
|||||||
if db == nil {
|
if db == nil {
|
||||||
return nil, errors.New("users admin: gorm handle is missing")
|
return nil, errors.New("users admin: gorm handle is missing")
|
||||||
}
|
}
|
||||||
rows, err := classes.FrontendPermissionOptions(ctx, db)
|
return classes.FrontendPermissionCatalog(ctx, c.application(), db)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
options := make([]cabana.PermissionOption, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab}
|
|
||||||
if row.Comment != nil {
|
|
||||||
option.Comment = *row.Comment
|
|
||||||
}
|
|
||||||
options = append(options, option)
|
|
||||||
}
|
|
||||||
return options, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminPermissionValues returns the user's own stored permissions.
|
// AdminPermissionValues returns the user's own stored permissions.
|
||||||
@@ -452,26 +487,36 @@ func (usersAdminController) AdminSetPermissionValues(_ context.Context, field st
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// guardCredentials compares the email the save would write with the stored
|
// storedEmail reads the stored email of the user being updated, before the
|
||||||
// one and looks for a submitted password, then asks guardPrivilegedMember
|
// form's values are saved.
|
||||||
// for the fields that would change.
|
func (c usersAdminController) storedEmail(ctx context.Context, user *models.User) (string, error) {
|
||||||
func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error {
|
|
||||||
db := requestDB(ctx, c.db)
|
db := requestDB(ctx, c.db)
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return errors.New("users admin: gorm handle is missing")
|
return "", errors.New("users admin: gorm handle is missing")
|
||||||
}
|
}
|
||||||
var stored []string
|
var stored []string
|
||||||
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
||||||
Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error
|
Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if len(stored) != 1 {
|
if len(stored) != 1 {
|
||||||
return errors.New("users admin: the user being updated was not found")
|
return "", errors.New("users admin: the user being updated was not found")
|
||||||
|
}
|
||||||
|
return stored[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardCredentials compares the email the save would write with the stored
|
||||||
|
// one and looks for a submitted password, then asks guardPrivilegedMember
|
||||||
|
// for the fields that would change.
|
||||||
|
func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error {
|
||||||
|
stored, err := c.storedEmail(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
const message = usersLang + "privileged_member_forbidden"
|
const message = usersLang + "privileged_member_forbidden"
|
||||||
details := map[string]any{}
|
details := map[string]any{}
|
||||||
if stored[0] != user.Email {
|
if stored != user.Email {
|
||||||
details["email"] = []string{message}
|
details["email"] = []string{message}
|
||||||
}
|
}
|
||||||
if values, ok := cabana.VirtualFieldsFromContext(ctx); ok {
|
if values, ok := cabana.VirtualFieldsFromContext(ctx); ok {
|
||||||
@@ -512,6 +557,9 @@ func (c usersAdminController) FormAfterDelete(ctx context.Context, model any) er
|
|||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("users admin: a delete needs its transaction")
|
return errors.New("users admin: a delete needs its transaction")
|
||||||
}
|
}
|
||||||
|
if err := FireDeletingEvent(ctx, c.application(), user, tx); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", classes.DeletingEventName, err)
|
||||||
|
}
|
||||||
if err := classes.ForceDeleteCleanup(ctx, tx, c.bucket(), user); err != nil {
|
if err := classes.ForceDeleteCleanup(ctx, tx, c.bucket(), user); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -581,13 +629,35 @@ func (c usersAdminController) AdminBulkActions() []pact.AdminBulkAction {
|
|||||||
}
|
}
|
||||||
return []pact.AdminBulkAction{
|
return []pact.AdminBulkAction{
|
||||||
bulk("activate", classes.ActivateUsers),
|
bulk("activate", classes.ActivateUsers),
|
||||||
bulk("deactivate", classes.DeactivateUsers),
|
bulk("deactivate", c.deactivateUsers),
|
||||||
bulk("restore", classes.RestoreUsers),
|
bulk("restore", classes.RestoreUsers),
|
||||||
bulk("ban", classes.BanUsers),
|
bulk("ban", classes.BanUsers),
|
||||||
bulk("unban", classes.UnbanUsers),
|
bulk("unban", classes.UnbanUsers),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deactivateUsers runs classes.DeactivateUsers unchanged and then fires
|
||||||
|
// user.Deactivating, in the action's transaction, for every selected user
|
||||||
|
// that was not deactivated before. A listener error rolls the action back.
|
||||||
|
func (c usersAdminController) deactivateUsers(ctx context.Context, tx *gorm.DB, users []*models.User) (int, error) {
|
||||||
|
pending := make([]*models.User, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
if user != nil && user.ID != 0 && !user.DeletedAt.Valid {
|
||||||
|
pending = append(pending, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed, err := classes.DeactivateUsers(ctx, tx, users)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, user := range pending {
|
||||||
|
if err := FireDeactivatingEvent(ctx, c.application(), user, tx); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
// throttleState reads whether one user is banned and whether they are
|
// throttleState reads whether one user is banned and whether they are
|
||||||
// suspended, through the write's transaction when there is one.
|
// suspended, through the write's transaction when there is one.
|
||||||
func (c usersAdminController) throttleState(ctx context.Context, userID uint) (banned, suspended bool, err error) {
|
func (c usersAdminController) throttleState(ctx context.Context, userID uint) (banned, suspended bool, err error) {
|
||||||
|
|||||||
110
deactivating_event_test.go
Normal file
110
deactivating_event_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeactivatingEvent pins user.Deactivating (D-05): the admin deactivate
|
||||||
|
// bulk action fires one event per newly deactivated user inside the
|
||||||
|
// action's transaction, already deactivated users fire none, a listener
|
||||||
|
// error rolls the whole action back, and the event name is the constant.
|
||||||
|
func TestDeactivatingEvent(t *testing.T) {
|
||||||
|
if classes.DeactivatingEventName != "user.Deactivating" {
|
||||||
|
t.Fatalf("DeactivatingEventName = %q", classes.DeactivatingEventName)
|
||||||
|
}
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
token := env.admin(t, permAccessUsers)
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var fired []uint
|
||||||
|
var failFor uint
|
||||||
|
var txSawDeactivation = map[uint]bool{}
|
||||||
|
env.app.Events.Listen[*classes.DeactivatingEvent]("test.deactivating", func(ctx context.Context, e *classes.DeactivatingEvent) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if e.User == nil || e.Tx == nil {
|
||||||
|
return errors.New("event without user or transaction")
|
||||||
|
}
|
||||||
|
fired = append(fired, e.User.ID)
|
||||||
|
// Inside the action's transaction the row is already deactivated.
|
||||||
|
var deleted int64
|
||||||
|
if err := e.Tx.Session(&gorm.Session{NewDB: true}).Unscoped().Model(&models.User{}).
|
||||||
|
Where("id = ? AND deleted_at IS NOT NULL", e.User.ID).Count(&deleted).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
txSawDeactivation[e.User.ID] = deleted == 1
|
||||||
|
if e.User.ID == failFor {
|
||||||
|
return errors.New("listener refuses")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
reset := func() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
fired, txSawDeactivation = nil, map[uint]bool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
a, b := env.seedUser(t, "deact-a", nil), env.seedUser(t, "deact-b", nil)
|
||||||
|
if rec := env.bulk(t, token, "deactivate", a, b); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("deactivate: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if len(fired) != 2 || !txSawDeactivation[a.ID] || !txSawDeactivation[b.ID] {
|
||||||
|
t.Fatalf("events %v, seen in the transaction %v", fired, txSawDeactivation)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
for _, u := range []*models.User{a, b} {
|
||||||
|
if row := env.reload(t, u.ID); row == nil || !row.DeletedAt.Valid {
|
||||||
|
t.Fatalf("user %d not deactivated", u.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already deactivated users fire nothing; a mixed selection fires only
|
||||||
|
// for the newly deactivated one.
|
||||||
|
reset()
|
||||||
|
c := env.seedUser(t, "deact-c", nil)
|
||||||
|
if rec := env.bulk(t, token, "deactivate", a, c); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("mixed deactivate: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if len(fired) != 1 || fired[0] != c.ID {
|
||||||
|
t.Fatalf("mixed selection fired %v, want only %d", fired, c.ID)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
// A listener error rolls the whole action back.
|
||||||
|
reset()
|
||||||
|
d, e := env.seedUser(t, "deact-d", nil), env.seedUser(t, "deact-e", nil)
|
||||||
|
failFor = e.ID
|
||||||
|
if rec := env.bulk(t, token, "deactivate", d, e); rec.Code < 400 {
|
||||||
|
t.Fatalf("deactivate with a refusing listener: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
failFor = 0
|
||||||
|
for _, u := range []*models.User{d, e} {
|
||||||
|
if row := env.reload(t, u.ID); row == nil || row.DeletedAt.Valid {
|
||||||
|
t.Fatalf("user %d deactivated although a listener refused", u.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other bulk actions fire nothing.
|
||||||
|
reset()
|
||||||
|
if rec := env.bulk(t, token, "restore", a); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("restore: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := env.bulk(t, token, "activate", d); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("activate: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if len(fired) != 0 {
|
||||||
|
t.Fatalf("restore and activate fired %v", fired)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
377
email_verified_test.go
Normal file
377
email_verified_test.go
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/postcard"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// proofOf reads users.email_verified_at of the user id.
|
||||||
|
func proofOf(t *testing.T, gdb *gorm.DB, id uint) *time.Time {
|
||||||
|
t.Helper()
|
||||||
|
var got *time.Time
|
||||||
|
if err := gdb.Raw(`SELECT email_verified_at FROM users WHERE id = ?`, id).Row().Scan(&got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// userIDByEmail returns the id of the user with email.
|
||||||
|
func userIDByEmail(t *testing.T, gdb *gorm.DB, email string) uint {
|
||||||
|
t.Helper()
|
||||||
|
var u models.User
|
||||||
|
if err := gdb.Unscoped().Where("lower(email) = lower(?)", email).Take(&u).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// setProof stores a proof time on the user id and returns it.
|
||||||
|
func setProof(t *testing.T, gdb *gorm.DB, id uint) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
at := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
if err := gdb.Exec(`UPDATE users SET email_verified_at = ? WHERE id = ?`, at, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertStamped requires got to be set between before and after (with a
|
||||||
|
// millisecond of slack for the database's microsecond precision).
|
||||||
|
func assertStamped(t *testing.T, got *time.Time, before, after time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("email_verified_at is NULL, want the time of the proof")
|
||||||
|
}
|
||||||
|
if got.Before(before.Add(-time.Millisecond)) || got.After(after.Add(time.Millisecond)) {
|
||||||
|
t.Fatalf("email_verified_at = %v, want between %v and %v", got, before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoProofKey requires a response body without the proof column.
|
||||||
|
func assertNoProofKey(t *testing.T, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if strings.Contains(body, "email_verified_at") {
|
||||||
|
t.Fatalf("the payload carries email_verified_at: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmailVerifiedAt pins the user API side of D-26 and D-27: only the
|
||||||
|
// mailed activation code and a completed reset-code password reset record
|
||||||
|
// the proof of the address, an email change clears it, and the column is
|
||||||
|
// never mass-assigned or part of a payload.
|
||||||
|
func TestEmailVerifiedAt(t *testing.T) {
|
||||||
|
app, gdb := sessionApp(t, "0s")
|
||||||
|
disableRegisterThrottle(t, app)
|
||||||
|
if err := app.Publish[postcard.Mailer](&captureMail{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stamp := adminStamp()
|
||||||
|
email := func(label string) string { return "proof-" + label + "-" + stamp + "@example.test" }
|
||||||
|
mode := func(t *testing.T, m string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := app.Config.Set("golem15.user.activation.activate_mode", m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
register := func(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", body)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("register %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoProofKey(t, rec.Body.String())
|
||||||
|
return rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("register_auto_no_proof", func(t *testing.T) {
|
||||||
|
mode(t, "auto")
|
||||||
|
addr := email("register-auto")
|
||||||
|
body := register(t, `{"email":"`+addr+`","password":"correct-horse"}`)
|
||||||
|
if !strings.Contains(body, `"token":`) {
|
||||||
|
t.Fatalf("auto-mode register answered %s", body)
|
||||||
|
}
|
||||||
|
id := userIDByEmail(t, gdb, addr)
|
||||||
|
var activated bool
|
||||||
|
if err := gdb.Raw(`SELECT is_activated FROM users WHERE id = ?`, id).Row().Scan(&activated); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !activated || proofOf(t, gdb, id) != nil {
|
||||||
|
t.Fatalf("auto-mode account: activated=%v proof=%v", activated, proofOf(t, gdb, id))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("register_admin_mode_no_proof", func(t *testing.T) {
|
||||||
|
mode(t, "admin")
|
||||||
|
t.Cleanup(func() { mode(t, "auto") })
|
||||||
|
addr := email("register-admin")
|
||||||
|
if body := register(t, `{"email":"`+addr+`","password":"correct-horse"}`); body != `{}` {
|
||||||
|
t.Fatalf("admin-mode register answered %s", body)
|
||||||
|
}
|
||||||
|
if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil {
|
||||||
|
t.Fatalf("admin-mode account has proof %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("register_ignores_submitted_proof", func(t *testing.T) {
|
||||||
|
mode(t, "auto")
|
||||||
|
addr := email("register-submitted")
|
||||||
|
register(t, `{"email":"`+addr+`","password":"correct-horse","email_verified_at":"2020-01-01T00:00:00Z"}`)
|
||||||
|
if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil {
|
||||||
|
t.Fatalf("a submitted email_verified_at was stored: %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("activate_by_code_stamps", func(t *testing.T) {
|
||||||
|
user := insertInactiveUser(t, gdb, email("activate-by-code"), "correct-horse")
|
||||||
|
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := time.Now()
|
||||||
|
rec := postJSON(t, controllers.ActivateByCode(app), "/_user/api/v1/activate-by-code",
|
||||||
|
`{"code":"`+strconv.FormatUint(uint64(user.ID), 10)+"!"+code+`"}`)
|
||||||
|
after := time.Now()
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("activate-by-code %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoProofKey(t, rec.Body.String())
|
||||||
|
assertStamped(t, proofOf(t, gdb, user.ID), before, after)
|
||||||
|
})
|
||||||
|
t.Run("activate_stamps", func(t *testing.T) {
|
||||||
|
addr := email("activate")
|
||||||
|
token := inactiveSessionToken(t, app, gdb, addr)
|
||||||
|
user := &models.User{ID: userIDByEmail(t, gdb, addr)}
|
||||||
|
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := time.Now()
|
||||||
|
rec := bearerJSON(t, controllers.Activate(app), "/_user/api/v1/activate", token, `{"code":"`+code+`"}`)
|
||||||
|
after := time.Now()
|
||||||
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"is_activated":true`) {
|
||||||
|
t.Fatalf("activate %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoProofKey(t, rec.Body.String())
|
||||||
|
assertStamped(t, proofOf(t, gdb, user.ID), before, after)
|
||||||
|
})
|
||||||
|
t.Run("activate_wrong_code_no_proof", func(t *testing.T) {
|
||||||
|
addr := email("activate-wrong")
|
||||||
|
token := inactiveSessionToken(t, app, gdb, addr)
|
||||||
|
user := &models.User{ID: userIDByEmail(t, gdb, addr)}
|
||||||
|
if _, err := classes.IssueActivationCode(t.Context(), gdb, user); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := bearerJSON(t, controllers.Activate(app), "/_user/api/v1/activate", token, `{"code":"wrong"}`)
|
||||||
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"is_activated":false`) {
|
||||||
|
t.Fatalf("activate %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoProofKey(t, rec.Body.String())
|
||||||
|
if got := proofOf(t, gdb, user.ID); got != nil {
|
||||||
|
t.Fatalf("a wrong activation code recorded proof %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("reset_password_stamps", func(t *testing.T) {
|
||||||
|
user := insertUser(t, gdb, email("reset"), "correct-horse")
|
||||||
|
code, err := classes.IssueResetCode(t.Context(), gdb, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := time.Now()
|
||||||
|
rec := postJSON(t, controllers.ResetPassword(app), "/_user/api/v1/reset-password",
|
||||||
|
`{"code":"`+strconv.FormatUint(uint64(user.ID), 10)+"!"+code+`","password":"new-password","password_confirmation":"new-password"}`)
|
||||||
|
after := time.Now()
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Password has been reset"}` {
|
||||||
|
t.Fatalf("reset %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertStamped(t, proofOf(t, gdb, user.ID), before, after)
|
||||||
|
})
|
||||||
|
t.Run("reset_wrong_code_no_proof", func(t *testing.T) {
|
||||||
|
user := insertUser(t, gdb, email("reset-wrong"), "correct-horse")
|
||||||
|
if _, err := classes.IssueResetCode(t.Context(), gdb, user); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, code := range []string{strconv.FormatUint(uint64(user.ID), 10) + "!nope", "not-a-code"} {
|
||||||
|
rec := postJSON(t, controllers.ResetPassword(app), "/_user/api/v1/reset-password",
|
||||||
|
`{"code":"`+code+`","password":"new-password","password_confirmation":"new-password"}`)
|
||||||
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("reset with %q: %d %s", code, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := proofOf(t, gdb, user.ID); got != nil {
|
||||||
|
t.Fatalf("a refused reset recorded proof %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("change_password_no_proof", func(t *testing.T) {
|
||||||
|
addr := email("change-password")
|
||||||
|
token := loginToken(t, app, gdb, addr)
|
||||||
|
// change-password replaces a self-set password only.
|
||||||
|
if err := gdb.Exec(`UPDATE users SET has_self_set_password = TRUE WHERE id = ?`, userIDByEmail(t, gdb, addr)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token,
|
||||||
|
`{"current_password":"correct-horse","password":"brand-new-password","password_confirmation":"brand-new-password"}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("change-password %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoProofKey(t, rec.Body.String())
|
||||||
|
if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil {
|
||||||
|
t.Fatalf("change-password recorded proof %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
update := func(t *testing.T, token, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := bearerJSON(t, controllers.Update(app), "/_user/api/v1/update", token, body)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoProofKey(t, rec.Body.String())
|
||||||
|
return rec.Body.String()
|
||||||
|
}
|
||||||
|
t.Run("update_email_change_clears", func(t *testing.T) {
|
||||||
|
addr := email("update-change")
|
||||||
|
token := loginToken(t, app, gdb, addr)
|
||||||
|
id := userIDByEmail(t, gdb, addr)
|
||||||
|
setProof(t, gdb, id)
|
||||||
|
moved := email("update-changed-to")
|
||||||
|
update(t, token, `{"name":"Ann","surname":"Owner","email":"`+moved+`"}`)
|
||||||
|
if got := proofOf(t, gdb, id); got != nil {
|
||||||
|
t.Fatalf("an email change kept the proof %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("update_same_email_keeps", func(t *testing.T) {
|
||||||
|
addr := email("update-same")
|
||||||
|
token := loginToken(t, app, gdb, addr)
|
||||||
|
id := userIDByEmail(t, gdb, addr)
|
||||||
|
want := setProof(t, gdb, id)
|
||||||
|
update(t, token, `{"name":"Ann","surname":"Owner","email":"`+addr+`"}`)
|
||||||
|
if got := proofOf(t, gdb, id); got == nil || !got.Equal(want) {
|
||||||
|
t.Fatalf("the same email changed the proof to %v", got)
|
||||||
|
}
|
||||||
|
update(t, token, `{"name":"Ann","surname":"Owner","email":"`+strings.ToUpper(addr)+`"}`)
|
||||||
|
if got := proofOf(t, gdb, id); got == nil || !got.Equal(want) {
|
||||||
|
t.Fatalf("a case-only change dropped the proof: %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("update_ignores_submitted_proof", func(t *testing.T) {
|
||||||
|
addr := email("update-submitted")
|
||||||
|
token := loginToken(t, app, gdb, addr)
|
||||||
|
update(t, token, `{"name":"Ann","surname":"Owner","email":"`+addr+`","email_verified_at":"2020-01-01T00:00:00Z"}`)
|
||||||
|
if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil {
|
||||||
|
t.Fatalf("a submitted email_verified_at was stored: %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminEmailVerifiedAt pins the admin side of D-26 and D-27: an
|
||||||
|
// administrator's create and Activate action record no proof, the
|
||||||
|
// invitation link (the mailed activation code) does, an email change on the
|
||||||
|
// form clears it, and the admin JSON never carries it.
|
||||||
|
func TestAdminEmailVerifiedAt(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
token := env.admin(t, permAccessUsers)
|
||||||
|
stamp := adminStamp()
|
||||||
|
const password = "proof-admin-password-1"
|
||||||
|
create := func(t *testing.T, email string, extra map[string]any) uint {
|
||||||
|
t.Helper()
|
||||||
|
body := map[string]any{"name": "Proof", "email": email, "password": password, "password_confirmation": password}
|
||||||
|
for k, v := range extra {
|
||||||
|
body[k] = v
|
||||||
|
}
|
||||||
|
return createdID(t, env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, body))
|
||||||
|
}
|
||||||
|
proven := func(t *testing.T, label string) *models.User {
|
||||||
|
t.Helper()
|
||||||
|
at := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
return env.seedUser(t, label, func(u *models.User) { u.EmailVerifiedAt = &at })
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("admin_create_no_proof", func(t *testing.T) {
|
||||||
|
id := create(t, "admin-proof-create-"+stamp+"@example.test", nil)
|
||||||
|
if got := env.reload(t, id); got == nil || got.EmailVerifiedAt != nil {
|
||||||
|
t.Fatalf("admin-created user = %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("admin_activate_action_no_proof", func(t *testing.T) {
|
||||||
|
user := env.seedUser(t, "admin-proof-activate", func(u *models.User) { u.IsActivated = false })
|
||||||
|
if rec := env.bulk(t, token, "activate", user); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("activate action %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
got := env.reload(t, user.ID)
|
||||||
|
if !got.IsActivated || got.EmailVerifiedAt != nil {
|
||||||
|
t.Fatalf("after the activate action: activated=%v proof=%v", got.IsActivated, got.EmailVerifiedAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("admin_invite_link_stamps", func(t *testing.T) {
|
||||||
|
invited := "admin-proof-invite-" + stamp + "@example.test"
|
||||||
|
id := create(t, invited, map[string]any{"send_invite": true})
|
||||||
|
if got := env.reload(t, id); got.EmailVerifiedAt != nil {
|
||||||
|
t.Fatalf("the invitation recorded proof before the link: %v", got.EmailVerifiedAt)
|
||||||
|
}
|
||||||
|
mails := env.mails(invited)
|
||||||
|
if len(mails) != 1 {
|
||||||
|
t.Fatalf("invitations sent = %d", len(mails))
|
||||||
|
}
|
||||||
|
start := strings.Index(mails[0].Text, "https://app.example.test/activate?code=")
|
||||||
|
if start < 0 {
|
||||||
|
t.Fatalf("no activation link in %q", mails[0].Text)
|
||||||
|
}
|
||||||
|
link := mails[0].Text[start:]
|
||||||
|
link = link[:strings.IndexAny(link, ")\n ")]
|
||||||
|
parsed, err := url.Parse(link)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := time.Now()
|
||||||
|
rec := env.call(t, http.MethodPost, "/_user/api/v1/activate-by-code", "", map[string]string{"code": parsed.Query().Get("code")})
|
||||||
|
after := time.Now()
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("activate-by-code %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
got := env.reload(t, id)
|
||||||
|
if !got.IsActivated {
|
||||||
|
t.Fatal("the invitation link did not activate")
|
||||||
|
}
|
||||||
|
assertStamped(t, got.EmailVerifiedAt, before, after)
|
||||||
|
})
|
||||||
|
t.Run("admin_email_change_clears", func(t *testing.T) {
|
||||||
|
user := proven(t, "admin-proof-change")
|
||||||
|
moved := "admin-proof-moved-" + stamp + "@example.test"
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "Moved", "email": moved})
|
||||||
|
got := env.reload(t, user.ID)
|
||||||
|
if got.Email != moved || got.EmailVerifiedAt != nil {
|
||||||
|
t.Fatalf("after the email change: email=%s proof=%v", got.Email, got.EmailVerifiedAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("admin_save_keeps_proof", func(t *testing.T) {
|
||||||
|
user := proven(t, "admin-proof-keep")
|
||||||
|
want := *user.EmailVerifiedAt
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "Renamed", "email": user.Email})
|
||||||
|
if got := env.reload(t, user.ID); got.EmailVerifiedAt == nil || !got.EmailVerifiedAt.Equal(want) {
|
||||||
|
t.Fatalf("a save with the same email changed the proof to %v", got.EmailVerifiedAt)
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "Renamed", "email": strings.ToUpper(user.Email)})
|
||||||
|
if got := env.reload(t, user.ID); got.EmailVerifiedAt == nil || !got.EmailVerifiedAt.Equal(want) {
|
||||||
|
t.Fatalf("a case-only change dropped the proof: %v", got.EmailVerifiedAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("admin_json_hides_proof", func(t *testing.T) {
|
||||||
|
user := proven(t, "admin-proof-json")
|
||||||
|
show := env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, ""), token, nil)
|
||||||
|
assertNoProofKey(t, show.Body.String())
|
||||||
|
form := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil)
|
||||||
|
assertNoProofKey(t, form.Body.String())
|
||||||
|
list := env.users(t, token, "search="+url.QueryEscape(user.Email))
|
||||||
|
if !list.has(user.ID) {
|
||||||
|
t.Fatal("the proven user is not in the list")
|
||||||
|
}
|
||||||
|
assertNoProofKey(t, list.Raw)
|
||||||
|
})
|
||||||
|
}
|
||||||
112
frontend_permission_catalog_smoke_test.go
Normal file
112
frontend_permission_catalog_smoke_test.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The frontend permission codes the acmeBlogPermissions test plugin declares.
|
||||||
|
const (
|
||||||
|
acmeBlogManage = "acme.blog.manage"
|
||||||
|
// acmeBlogSeeded is also a seeded row of golem15_user_frontend_permissions
|
||||||
|
// in the smoke, with another label: the seeded row wins.
|
||||||
|
acmeBlogSeeded = "acme.blog.seeded"
|
||||||
|
)
|
||||||
|
|
||||||
|
// acmeBlogPermissions is a plugin that only declares frontend permission
|
||||||
|
// codes (pact.HasFrontendPermissions). It is registered for the test binary
|
||||||
|
// and activated only by the tests that name it.
|
||||||
|
type acmeBlogPermissions struct{}
|
||||||
|
|
||||||
|
func (acmeBlogPermissions) ID() string { return "acme.blog" }
|
||||||
|
func (acmeBlogPermissions) Requires() []string { return nil }
|
||||||
|
func (acmeBlogPermissions) Register(*backpack.App) error { return nil }
|
||||||
|
func (acmeBlogPermissions) Boot(*backpack.App) error { return nil }
|
||||||
|
|
||||||
|
func (acmeBlogPermissions) FrontendPermissions() []pact.FrontendPermission {
|
||||||
|
return []pact.FrontendPermission{
|
||||||
|
{Code: acmeBlogManage, Label: "Manage blog posts", Tab: "Blog", Comment: "Create, edit and delete posts."},
|
||||||
|
{Code: acmeBlogSeeded, Label: "Plugin label", Tab: "Blog"},
|
||||||
|
{Code: acmeBlogManage, Label: "Declared twice", Tab: "Blog"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { party.Register(acmeBlogPermissions{}) }
|
||||||
|
|
||||||
|
type offeredPermission struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Tab string `json:"tab"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// permissionOptions reads the options of the permissions field of a form
|
||||||
|
// schema.
|
||||||
|
func (e *adminEnv) permissionOptions(t *testing.T, token, path string) []offeredPermission {
|
||||||
|
t.Helper()
|
||||||
|
var form struct {
|
||||||
|
Fields []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Options []offeredPermission `json:"permissionOptions"`
|
||||||
|
} `json:"fields"`
|
||||||
|
}
|
||||||
|
adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, adminAPI(path+"/schema/form"), token, nil), &form)
|
||||||
|
for _, field := range form.Fields {
|
||||||
|
if field.Name == "permissions" {
|
||||||
|
return field.Options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("%s has no permissions field", path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFrontendPermissionCatalogSmoke: both permission editors offer the
|
||||||
|
// seeded rows first, then the codes an activated plugin declares; a seeded
|
||||||
|
// row wins over a declared code and a code is offered once.
|
||||||
|
func TestFrontendPermissionCatalogSmoke(t *testing.T) {
|
||||||
|
env := newAdminEnvWith(t, []string{"acme.blog"})
|
||||||
|
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)
|
||||||
|
for _, path := range []string{adminUsersPath, adminGroupsPath} {
|
||||||
|
options := env.permissionOptions(t, token, path)
|
||||||
|
if int64(len(options)) != seeded+1 {
|
||||||
|
t.Fatalf("%s offers %d options, want the %d seeded rows and one declared code: %+v", path, len(options), seeded, options)
|
||||||
|
}
|
||||||
|
last := options[len(options)-1]
|
||||||
|
if last != (offeredPermission{Code: acmeBlogManage, Label: "Manage blog posts", Tab: "Blog", Comment: "Create, edit and delete posts."}) {
|
||||||
|
t.Fatalf("%s: last option = %+v, want the declared acme.blog.manage", path, last)
|
||||||
|
}
|
||||||
|
found := 0
|
||||||
|
for _, option := range options[:len(options)-1] {
|
||||||
|
if option.Code == acmeBlogManage {
|
||||||
|
t.Fatalf("%s offers %s twice", path, acmeBlogManage)
|
||||||
|
}
|
||||||
|
if option.Code == acmeBlogSeeded {
|
||||||
|
found++
|
||||||
|
if option.Label != "Seeded label" {
|
||||||
|
t.Fatalf("%s: duplicate code label = %q, want the seeded row's", path, option.Label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found != 1 {
|
||||||
|
t.Fatalf("%s offers %s %d times", path, acmeBlogSeeded, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the declaring plugin the editors offer the seeded rows only.
|
||||||
|
plain := newAdminEnv(t)
|
||||||
|
plainToken := plain.admin(t, permAccessUsers)
|
||||||
|
if options := plain.permissionOptions(t, plainToken, adminUsersPath); int64(len(options)) != seeded {
|
||||||
|
t.Fatalf("without acme.blog: %d options, want %d", len(options), seeded)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
go.mod
65
go.mod
@@ -3,7 +3,7 @@ module git.golem15.com/golem15/sm-user-plugin
|
|||||||
go 1.27.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.golem15.com/golem15/summercms v0.1.9
|
git.golem15.com/golem15/summercms v0.4.4
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
@@ -14,11 +14,50 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
charm.land/bubbles/v2 v2.2.1 // indirect
|
||||||
|
charm.land/bubbletea/v2 v2.1.0 // indirect
|
||||||
|
charm.land/fang/v2 v2.0.1 // indirect
|
||||||
|
charm.land/huh/v2 v2.0.3 // indirect
|
||||||
|
charm.land/lipgloss/v2 v2.0.6 // indirect
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect
|
||||||
|
github.com/aws/smithy-go v1.26.0 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/catppuccin/go v0.2.0 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/containerd/errdefs v1.0.0 // indirect
|
github.com/containerd/errdefs v1.0.0 // indirect
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
@@ -28,6 +67,7 @@ require (
|
|||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-connections v0.7.0 // indirect
|
github.com/docker/go-connections v0.7.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.10.1 // indirect
|
github.com/ebitengine/purego v0.10.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
@@ -40,8 +80,11 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.30.4 // indirect
|
github.com/go-playground/validator/v10 v10.30.4 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/google/wire v0.7.0 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
@@ -57,9 +100,14 @@ require (
|
|||||||
github.com/knadh/koanf/v2 v2.3.6 // indirect
|
github.com/knadh/koanf/v2 v2.3.6 // indirect
|
||||||
github.com/leodido/go-urn v1.5.0 // indirect
|
github.com/leodido/go-urn v1.5.0 // indirect
|
||||||
github.com/lib/pq v1.12.3 // indirect
|
github.com/lib/pq v1.12.3 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||||
github.com/magiconair/properties v1.8.10 // indirect
|
github.com/magiconair/properties v1.8.10 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||||
|
github.com/maypok86/otter/v2 v2.3.0 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/go-archive v0.2.0 // indirect
|
github.com/moby/go-archive v0.2.0 // indirect
|
||||||
@@ -70,16 +118,26 @@ require (
|
|||||||
github.com/moby/sys/user v0.4.0 // indirect
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/mango v0.1.0 // indirect
|
||||||
|
github.com/muesli/mango-cobra v1.2.0 // indirect
|
||||||
|
github.com/muesli/mango-pflag v0.1.0 // indirect
|
||||||
|
github.com/muesli/roff v0.1.0 // indirect
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
|
github.com/redis/go-redis/v9 v9.23.0 // indirect
|
||||||
github.com/riverqueue/river v0.47.0 // indirect
|
github.com/riverqueue/river v0.47.0 // indirect
|
||||||
github.com/riverqueue/river/riverdriver v0.47.0 // indirect
|
github.com/riverqueue/river/riverdriver v0.47.0 // indirect
|
||||||
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 // indirect
|
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 // indirect
|
||||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect
|
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect
|
||||||
github.com/riverqueue/river/rivershared v0.47.0 // indirect
|
github.com/riverqueue/river/rivershared v0.47.0 // indirect
|
||||||
github.com/riverqueue/river/rivertype v0.47.0 // indirect
|
github.com/riverqueue/river/rivertype v0.47.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
github.com/spf13/cobra v1.10.2 // indirect
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
@@ -92,6 +150,8 @@ require (
|
|||||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/wneessen/go-mail v0.8.1 // indirect
|
github.com/wneessen/go-mail v0.8.1 // indirect
|
||||||
|
github.com/xo/terminfo v1.0.0 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
github.com/yuin/goldmark v1.8.6 // indirect
|
github.com/yuin/goldmark v1.8.6 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
@@ -101,14 +161,17 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
|
go.uber.org/atomic v1.12.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/crypto v0.57.0 // indirect
|
golang.org/x/crypto v0.57.0 // indirect
|
||||||
golang.org/x/image v0.46.0 // indirect
|
golang.org/x/image v0.46.0 // indirect
|
||||||
golang.org/x/net v0.58.0 // indirect
|
golang.org/x/net v0.58.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/term v0.46.0 // indirect
|
golang.org/x/term v0.46.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
google.golang.org/api v0.272.0 // indirect
|
google.golang.org/api v0.272.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||||
|
|||||||
116
go.sum
116
go.sum
@@ -1,5 +1,15 @@
|
|||||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||||
|
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||||
|
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||||
|
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||||
|
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||||
|
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
|
||||||
|
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
|
||||||
|
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||||
|
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||||
|
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||||
|
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||||
@@ -28,8 +38,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55
|
|||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||||
@@ -68,10 +84,52 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3
|
|||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
||||||
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||||
|
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
|
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||||
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||||
|
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
|
||||||
|
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
|
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||||
|
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
|
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||||
|
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
@@ -96,6 +154,8 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf
|
|||||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||||
@@ -139,6 +199,14 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
|
|||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo=
|
||||||
|
github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI=
|
||||||
|
github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk=
|
||||||
|
github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg=
|
||||||
|
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
|
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||||
|
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -149,6 +217,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA
|
|||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||||
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
|
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
|
||||||
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
|
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
|
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
|
||||||
@@ -167,6 +237,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||||
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
||||||
github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
|
github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
|
||||||
@@ -183,16 +255,26 @@ github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
|||||||
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||||
|
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
|
||||||
|
github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8=
|
||||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
@@ -213,6 +295,18 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
|
|||||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
|
||||||
|
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
|
||||||
|
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
|
||||||
|
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
|
||||||
|
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
|
||||||
|
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
|
||||||
|
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
||||||
|
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
@@ -224,6 +318,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s=
|
||||||
|
github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0=
|
||||||
github.com/riverqueue/river v0.47.0 h1:j8HOEyiOE8gRRhVS2wllKams372TH1WWiH6xCakXHqc=
|
github.com/riverqueue/river v0.47.0 h1:j8HOEyiOE8gRRhVS2wllKams372TH1WWiH6xCakXHqc=
|
||||||
github.com/riverqueue/river v0.47.0/go.mod h1:Wgmwx475ZBd8lQnNrJgyG2DWH7BfyNiSAtv0rC9bJBQ=
|
github.com/riverqueue/river v0.47.0/go.mod h1:Wgmwx475ZBd8lQnNrJgyG2DWH7BfyNiSAtv0rC9bJBQ=
|
||||||
github.com/riverqueue/river/riverdriver v0.47.0 h1:qU8VkjdMl9plqeRg57SxsDUM/i/eECaSYejZ7HynC60=
|
github.com/riverqueue/river/riverdriver v0.47.0 h1:qU8VkjdMl9plqeRg57SxsDUM/i/eECaSYejZ7HynC60=
|
||||||
@@ -236,9 +332,15 @@ github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOt
|
|||||||
github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418=
|
github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418=
|
||||||
github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0=
|
github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0=
|
||||||
github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw=
|
github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
@@ -277,10 +379,18 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
|
|||||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||||
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
|
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
|
||||||
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
|
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
|
||||||
|
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||||
|
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
|
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
|
||||||
@@ -301,6 +411,8 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk
|
|||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||||
|
go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
|
||||||
|
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
@@ -310,6 +422,8 @@ gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q=
|
|||||||
gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM=
|
gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM=
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||||
@@ -332,6 +446,8 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
|||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
|
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ plugin:
|
|||||||
access_settings: Manage User Settings
|
access_settings: Manage User Settings
|
||||||
impersonate_user: Impersonate Users
|
impersonate_user: Impersonate Users
|
||||||
manage_privileged_groups: Manage privileged group membership
|
manage_privileged_groups: Manage privileged group membership
|
||||||
|
manage_backend_links: Link frontend accounts to administrators
|
||||||
users:
|
users:
|
||||||
menu_label: Users
|
menu_label: Users
|
||||||
new_user: New User
|
new_user: New User
|
||||||
@@ -115,3 +116,23 @@ organisation:
|
|||||||
empty_organisation: — No organisation —
|
empty_organisation: — No organisation —
|
||||||
oauth:
|
oauth:
|
||||||
last_method_blocked: This is the only remaining way to sign in. Link another method before disconnecting this one.
|
last_method_blocked: This is the only remaining way to sign in. Link another method before disconnecting this one.
|
||||||
|
backend_link:
|
||||||
|
section: Frontend account
|
||||||
|
section_comment: Link your administrator account to your account on the site, so tools you use there can act as this administrator. You sign in on the site to prove the account is yours.
|
||||||
|
status_linked: "Linked to :email"
|
||||||
|
status_unlinked: Not linked
|
||||||
|
link: Link frontend account
|
||||||
|
link_started: Open the link and sign in on the site with the account to link. The link works once, for a few minutes.
|
||||||
|
unlink: Unlink frontend account
|
||||||
|
unlink_confirm: Unlink your frontend account? Tools used through it will no longer act as this administrator.
|
||||||
|
unlinked: The frontend account is unlinked.
|
||||||
|
prompt_email: Frontend account email
|
||||||
|
prompt_comment: The exact email of the frontend user to link to this administrator.
|
||||||
|
set: Link frontend account
|
||||||
|
set_confirm: Link a frontend account to this administrator? Tools that user runs will act as this administrator.
|
||||||
|
set_success: The frontend account is linked.
|
||||||
|
remove: Unlink frontend account
|
||||||
|
remove_confirm: Unlink the frontend account from this administrator?
|
||||||
|
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.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ plugin:
|
|||||||
access_settings: Zarządzaj ustawieniami użytkownika
|
access_settings: Zarządzaj ustawieniami użytkownika
|
||||||
impersonate_user: Wcielaj się w użytkowników
|
impersonate_user: Wcielaj się w użytkowników
|
||||||
manage_privileged_groups: Zarządzaj członkostwem w grupach uprzywilejowanych
|
manage_privileged_groups: Zarządzaj członkostwem w grupach uprzywilejowanych
|
||||||
|
manage_backend_links: Łącz konta na stronie z administratorami
|
||||||
users:
|
users:
|
||||||
menu_label: Użytkownicy
|
menu_label: Użytkownicy
|
||||||
new_user: Nowy użytkownik
|
new_user: Nowy użytkownik
|
||||||
@@ -115,3 +116,23 @@ organisation:
|
|||||||
empty_organisation: — Brak organizacji —
|
empty_organisation: — Brak organizacji —
|
||||||
oauth:
|
oauth:
|
||||||
last_method_blocked: To jedyna droga logowania na to konto. Najpierw podłącz inną, zanim odetniesz tę.
|
last_method_blocked: To jedyna droga logowania na to konto. Najpierw podłącz inną, zanim odetniesz tę.
|
||||||
|
backend_link:
|
||||||
|
section: Konto na stronie
|
||||||
|
section_comment: Połącz konto administratora ze swoim kontem na stronie, aby narzędzia używane tam mogły działać jako ten administrator. Aby potwierdzić, że konto jest Twoje, zaloguj się na stronie.
|
||||||
|
status_linked: "Połączone z :email"
|
||||||
|
status_unlinked: Niepołączone
|
||||||
|
link: Połącz konto na stronie
|
||||||
|
link_started: Otwórz link i zaloguj się na stronie kontem, które chcesz połączyć. Link działa raz, przez kilka minut.
|
||||||
|
unlink: Odłącz konto na stronie
|
||||||
|
unlink_confirm: Odłączyć konto na stronie? Narzędzia używane przez nie przestaną działać jako ten administrator.
|
||||||
|
unlinked: Konto na stronie zostało odłączone.
|
||||||
|
prompt_email: Email konta na stronie
|
||||||
|
prompt_comment: Dokładny email użytkownika strony, którego połączyć z tym administratorem.
|
||||||
|
set: Połącz konto na stronie
|
||||||
|
set_confirm: Połączyć konto na stronie z tym administratorem? Narzędzia uruchamiane przez tego użytkownika będą działać jako ten administrator.
|
||||||
|
set_success: Konto na stronie zostało połączone.
|
||||||
|
remove: Odłącz konto na stronie
|
||||||
|
remove_confirm: Odłączyć konto na stronie od tego administratora?
|
||||||
|
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.
|
||||||
|
|||||||
7
logger.go
Normal file
7
logger.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import "git.golem15.com/golem15/summercms/modules/diary"
|
||||||
|
|
||||||
|
// pluginLog is the plugin's logger: its records carry component=user, so
|
||||||
|
// config/log.yaml can raise or lower them (log.components.user).
|
||||||
|
var pluginLog = diary.For("user")
|
||||||
106
login_client_ip_test.go
Normal file
106
login_client_ip_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLoginThrottleClientIP pins the /login throttle key: the client address
|
||||||
|
// surf.ClientIP derives with http.trusted_proxies, as /register and the
|
||||||
|
// user-api rate limit use, and never an X-Forwarded-For header from an
|
||||||
|
// untrusted peer.
|
||||||
|
func TestLoginThrottleClientIP(t *testing.T) {
|
||||||
|
t.Run("behind a trusted proxy", func(t *testing.T) {
|
||||||
|
e := newAdminEnv(t, func(cfg *compass.Config) {
|
||||||
|
if err := cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
user := e.seedUser(t, "throttle-proxy", nil)
|
||||||
|
const proxy = "10.0.0.2:5000"
|
||||||
|
for i := range 5 {
|
||||||
|
rec := e.loginFrom(t, proxy, "203.0.113.10", user.Email, "wrong-password")
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
|
||||||
|
t.Fatalf("failed login %d = %d %s", i+1, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThrottleRow(t, e, user, "203.0.113.10")
|
||||||
|
if rec := e.loginFrom(t, proxy, "203.0.113.10", user.Email, "correct-horse"); rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
|
||||||
|
t.Fatalf("suspended client login = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
rec := e.loginFrom(t, proxy, "203.0.113.20", user.Email, "correct-horse")
|
||||||
|
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("other client behind the proxy = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("untrusted X-Forwarded-For is ignored", func(t *testing.T) {
|
||||||
|
e := newAdminEnv(t)
|
||||||
|
user := e.seedUser(t, "throttle-direct", nil)
|
||||||
|
const peer = "192.0.2.50:4000"
|
||||||
|
for _, forged := range []string{"203.0.113.1", "203.0.113.2", "203.0.113.3", "203.0.113.4", "203.0.113.5"} {
|
||||||
|
if rec := e.loginFrom(t, peer, forged, user.Email, "wrong-password"); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("failed login with X-Forwarded-For %s = %d %s", forged, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThrottleRow(t, e, user, "192.0.2.50")
|
||||||
|
if rec := e.loginFrom(t, peer, "203.0.113.99", user.Email, "correct-horse"); rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
|
||||||
|
t.Fatalf("forged X-Forwarded-For escaped the suspension: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := e.loginFrom(t, "192.0.2.51:4000", "", user.Email, "correct-horse"); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login from another address = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertThrottleRow: the account has exactly one user_throttle row, keyed on
|
||||||
|
// ip, with five attempts and suspended.
|
||||||
|
func assertThrottleRow(t *testing.T, e *adminEnv, user *models.User, ip string) {
|
||||||
|
t.Helper()
|
||||||
|
var rows []models.Throttle
|
||||||
|
if err := e.gdb.Where("user_id = ?", user.ID).Find(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("throttle rows = %d, want 1: %+v", len(rows), rows)
|
||||||
|
}
|
||||||
|
row := rows[0]
|
||||||
|
got := "<nil>"
|
||||||
|
if row.IPAddress != nil {
|
||||||
|
got = *row.IPAddress
|
||||||
|
}
|
||||||
|
if got != ip || row.Attempts != 5 || !row.IsSuspended {
|
||||||
|
t.Fatalf("throttle row ip=%s attempts=%d suspended=%v, want ip=%s attempts=5 suspended", got, row.Attempts, row.IsSuspended, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginFrom posts a login from a fixed connection address, with an
|
||||||
|
// X-Forwarded-For header when forwardedFor is set. send cannot model a
|
||||||
|
// reverse proxy: it gives every request its own address.
|
||||||
|
func (e *adminEnv) loginFrom(t *testing.T, remoteAddr, forwardedFor, email, password string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(map[string]string{"email": email, "password": password})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/login", bytes.NewReader(raw))
|
||||||
|
req.Host = "example.com"
|
||||||
|
req.RemoteAddr = remoteAddr
|
||||||
|
req.Header.Set("Accept-Language", "en")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if forwardedFor != "" {
|
||||||
|
req.Header.Set("X-Forwarded-For", forwardedFor)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
308
logout_test.go
Normal file
308
logout_test.go
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
loggedOutBody = `{"message":"Logged out"}`
|
||||||
|
unauthorizedRaw = `{"error":true,"message":"Unauthorized"}`
|
||||||
|
blacklistedBody = `{"error":"Could not refresh token","msg":"The token has been blacklisted"}`
|
||||||
|
userTokensPath = "/_user/api/v1/tokens"
|
||||||
|
userLogoutPath = "/_user/api/v1/logout"
|
||||||
|
userRefreshPath = "/_user/api/v1/refresh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLogoutEndsTheSession: POST /logout ends the session however the token
|
||||||
|
// travels (the bearer header, or only the token or auth_token cookie the
|
||||||
|
// jwt guard also reads), so the next request with the same token, through
|
||||||
|
// the header or either cookie, is 401 on the jwt.auth routes, at /fetch and
|
||||||
|
// at /refresh. It clears the
|
||||||
|
// session cookies with the attributes the token cookie is written with, and
|
||||||
|
// the revocation lasts for the token's refresh window: a logged-out token is
|
||||||
|
// not refreshable after its access lifetime, and an expired but still
|
||||||
|
// refreshable token is revoked by a logout too.
|
||||||
|
func TestLogoutEndsTheSession(t *testing.T) {
|
||||||
|
e := newAdminEnv(t)
|
||||||
|
|
||||||
|
t.Run("bearer", func(t *testing.T) {
|
||||||
|
token := e.sessionLogin(t, "logout-bearer")
|
||||||
|
if code := e.guarded(t, token, ""); code != http.StatusOK {
|
||||||
|
t.Fatalf("jwt.auth before logout = %d", code)
|
||||||
|
}
|
||||||
|
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
|
||||||
|
assertLoggedOut(t, rec)
|
||||||
|
e.assertSessionEnded(t, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, name := range []string{"token", "auth_token"} {
|
||||||
|
t.Run("cookie "+name, func(t *testing.T) {
|
||||||
|
token := e.sessionLogin(t, "logout-"+name)
|
||||||
|
if code := e.guarded(t, token, name); code != http.StatusOK {
|
||||||
|
t.Fatalf("jwt.auth with the %s cookie before logout = %d", name, code)
|
||||||
|
}
|
||||||
|
rec := e.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": name + "=" + token})
|
||||||
|
assertLoggedOut(t, rec)
|
||||||
|
e.assertSessionEnded(t, token)
|
||||||
|
want := e.cookieConfig(t)
|
||||||
|
assertForgets(t, rec, want, want.Name)
|
||||||
|
if name != want.Name {
|
||||||
|
assertForgets(t, rec, want, name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("bearer logout forgets the token cookie too", func(t *testing.T) {
|
||||||
|
token := e.sessionLogin(t, "logout-bearer-cookie")
|
||||||
|
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
|
||||||
|
assertLoggedOut(t, rec)
|
||||||
|
want := e.cookieConfig(t)
|
||||||
|
assertForgets(t, rec, want, want.Name)
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == "auth_token" {
|
||||||
|
t.Fatalf("a request without the auth_token cookie got a forget cookie for it: %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refused token still forgets the cookie", func(t *testing.T) {
|
||||||
|
rec := e.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": "token=not-a-token"})
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
|
||||||
|
t.Fatalf("logout with a bad cookie = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertForgets(t, rec, e.cookieConfig(t), "token")
|
||||||
|
missing := e.call(t, http.MethodPost, userLogoutPath, "", nil)
|
||||||
|
if missing.Code != http.StatusUnauthorized || missing.Body.String() != unauthorizedRaw {
|
||||||
|
t.Fatalf("logout without a token = %d %s", missing.Code, missing.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("revocation outlives the access token", func(t *testing.T) {
|
||||||
|
token := e.sessionLogin(t, "logout-window")
|
||||||
|
claims := parseToken(t, token)
|
||||||
|
jti, _ := claims["jti"].(string)
|
||||||
|
iat, _ := claims.GetIssuedAt()
|
||||||
|
exp, _ := claims.GetExpirationTime()
|
||||||
|
assertLoggedOut(t, e.call(t, http.MethodPost, userLogoutPath, token, nil))
|
||||||
|
var expiresAt time.Time
|
||||||
|
if err := e.gdb.Raw(`SELECT expires_at FROM jwt_blacklist WHERE jti = ?`, jti).Scan(&expiresAt).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The later of exp and iat plus the refresh window, plus a minute.
|
||||||
|
want := iat.Add(controllers.RefreshTTL(e.app)).Add(time.Minute)
|
||||||
|
if d := expiresAt.Sub(want); d < -time.Second || d > time.Second {
|
||||||
|
t.Fatalf("blacklist row expires at %v, want %v (refresh window plus a minute)", expiresAt, want)
|
||||||
|
}
|
||||||
|
// The sweep that runs once the access token has expired: the same
|
||||||
|
// predicate as bouncer.PostgresBlacklist.Sweep, limited to this jti
|
||||||
|
// so other tests' rows stay.
|
||||||
|
if err := e.gdb.Exec(`DELETE FROM jwt_blacklist WHERE jti = ? AND expires_at < ?`, jti, exp.Add(2*time.Minute)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := e.refresh(t, token)
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
|
||||||
|
t.Fatalf("refresh of a logged-out token after its access lifetime = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("expired but refreshable token", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "logout-expired", nil)
|
||||||
|
iat := time.Now().Add(-2 * time.Hour)
|
||||||
|
token := userAudienceToken(t, strconv.FormatUint(uint64(user.ID), 10), "logout-expired-"+adminStamp(), iat, iat.Add(time.Hour))
|
||||||
|
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
|
||||||
|
assertLoggedOut(t, rec)
|
||||||
|
if refreshed := e.refresh(t, token); refreshed.Code != http.StatusUnauthorized || refreshed.Body.String() != blacklistedBody {
|
||||||
|
t.Fatalf("refresh after logging out an expired token = %d %s", refreshed.Code, refreshed.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("every token the request carries", func(t *testing.T) {
|
||||||
|
bearerToken := e.sessionLogin(t, "logout-both-bearer")
|
||||||
|
cookieToken := e.sessionLogin(t, "logout-both-cookie")
|
||||||
|
rec := e.send(t, http.MethodPost, userLogoutPath, bearerToken, nil, "application/json", map[string]string{"Cookie": "token=" + cookieToken})
|
||||||
|
assertLoggedOut(t, rec)
|
||||||
|
e.assertSessionEnded(t, bearerToken)
|
||||||
|
e.assertSessionEnded(t, cookieToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refused tokens", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "logout-refused", nil)
|
||||||
|
sub := strconv.FormatUint(uint64(user.ID), 10)
|
||||||
|
closedAt := time.Now().Add(-controllers.RefreshTTL(e.app) - time.Hour)
|
||||||
|
for name, token := range map[string]string{
|
||||||
|
// The refresh window closed: nothing left to revoke.
|
||||||
|
"closed window": userAudienceToken(t, sub, "logout-closed-"+adminStamp(), closedAt, closedAt.Add(time.Hour)),
|
||||||
|
// No audience (a PHP token) is accepted only while unexpired.
|
||||||
|
"expired without audience": craftToken(t, sub, "logout-legacy-old-"+adminStamp(), time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour)),
|
||||||
|
// No jti: it cannot be blacklisted.
|
||||||
|
"no jti": userAudienceToken(t, sub, "", time.Now(), time.Now().Add(time.Hour)),
|
||||||
|
// A user that does not exist.
|
||||||
|
"unknown user": userAudienceToken(t, "999999999", "logout-ghost-"+adminStamp(), time.Now(), time.Now().Add(time.Hour)),
|
||||||
|
} {
|
||||||
|
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
|
||||||
|
t.Fatalf("%s: logout = %d %s", name, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertForgets(t, rec, e.cookieConfig(t), "token")
|
||||||
|
}
|
||||||
|
legacy := craftToken(t, sub, "logout-legacy-"+adminStamp(), time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
|
||||||
|
if code := e.guarded(t, legacy, ""); code != http.StatusOK {
|
||||||
|
t.Fatalf("jwt.auth with an unexpired token without audience = %d", code)
|
||||||
|
}
|
||||||
|
assertLoggedOut(t, e.call(t, http.MethodPost, userLogoutPath, legacy, nil))
|
||||||
|
if code := e.guarded(t, legacy, ""); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("jwt.auth after logging out a token without audience = %d", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("configured cookie attributes and refresh window", func(t *testing.T) {
|
||||||
|
custom := newAdminEnv(t, func(c *compass.Config) {
|
||||||
|
for key, value := range map[string]any{
|
||||||
|
"auth.cookie.domain": "example.com",
|
||||||
|
"auth.cookie.same_site": "strict",
|
||||||
|
"auth.cookie.path": "/",
|
||||||
|
"golem15.user.jwt.refresh_ttl": 120,
|
||||||
|
} {
|
||||||
|
if err := c.Set(key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
token := custom.sessionLogin(t, "logout-custom")
|
||||||
|
rec := custom.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": "token=" + token})
|
||||||
|
assertLoggedOut(t, rec)
|
||||||
|
want := custom.cookieConfig(t)
|
||||||
|
if want.Domain != "example.com" || want.SameSite != http.SameSiteStrictMode {
|
||||||
|
t.Fatalf("config not applied: %+v", want)
|
||||||
|
}
|
||||||
|
assertForgets(t, rec, want, "token")
|
||||||
|
custom.assertSessionEnded(t, token)
|
||||||
|
// Logout keeps the row for the window /refresh uses.
|
||||||
|
claims := parseToken(t, token)
|
||||||
|
jti, _ := claims["jti"].(string)
|
||||||
|
iat, _ := claims.GetIssuedAt()
|
||||||
|
var expiresAt time.Time
|
||||||
|
if err := custom.gdb.Raw(`SELECT expires_at FROM jwt_blacklist WHERE jti = ?`, jti).Scan(&expiresAt).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if window := controllers.RefreshTTL(custom.app); window != 120*time.Minute {
|
||||||
|
t.Fatalf("refresh window = %v", window)
|
||||||
|
}
|
||||||
|
if d := expiresAt.Sub(iat.Add(121 * time.Minute)); d < -time.Second || d > time.Second {
|
||||||
|
t.Fatalf("blacklist row expires at %v, want iat plus 121 minutes", expiresAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionLogin seeds a frontend user, signs in through POST /login and
|
||||||
|
// returns the session token.
|
||||||
|
func (e *adminEnv) sessionLogin(t *testing.T, label string) string {
|
||||||
|
t.Helper()
|
||||||
|
user := e.seedUser(t, label, nil)
|
||||||
|
code, token := e.userLogin(t, user.Email, "correct-horse")
|
||||||
|
if code != http.StatusOK || token == "" {
|
||||||
|
t.Fatalf("login %s = %d", label, code)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// guarded calls GET /tokens, a jwt.auth route, with token as the bearer
|
||||||
|
// header (cookie "") or only in the named cookie, and returns the status.
|
||||||
|
func (e *adminEnv) guarded(t *testing.T, token, cookie string) int {
|
||||||
|
t.Helper()
|
||||||
|
if cookie == "" {
|
||||||
|
return e.call(t, http.MethodGet, userTokensPath, token, nil).Code
|
||||||
|
}
|
||||||
|
return e.send(t, http.MethodGet, userTokensPath, "", nil, "application/json", map[string]string{"Cookie": cookie + "=" + token}).Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertSessionEnded: token no longer authenticates, through any carrier
|
||||||
|
// (the bearer header, or only the token or auth_token cookie), at jwt.auth
|
||||||
|
// and /fetch, and cannot be refreshed through any of them.
|
||||||
|
func (e *adminEnv) assertSessionEnded(t *testing.T, token string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, carrier := range []string{"", "token", "auth_token"} {
|
||||||
|
if code := e.guarded(t, token, carrier); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("jwt.auth after logout (cookie %q) = %d", carrier, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code := e.fetchCode(t, token); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("fetch after logout = %d", code)
|
||||||
|
}
|
||||||
|
if rec := e.refresh(t, token); rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
|
||||||
|
t.Fatalf("refresh after logout = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, name := range []string{"token", "auth_token"} {
|
||||||
|
cookie := map[string]string{"Cookie": name + "=" + token}
|
||||||
|
if rec := e.send(t, http.MethodGet, "/_user/api/v1/fetch", "", nil, "application/json", cookie); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("fetch after logout (cookie %q) = %d", name, rec.Code)
|
||||||
|
}
|
||||||
|
if rec := e.send(t, http.MethodPost, userRefreshPath, "", nil, "application/json", cookie); rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
|
||||||
|
t.Fatalf("refresh after logout (cookie %q) = %d %s", name, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *adminEnv) cookieConfig(t *testing.T) bouncer.CookieConfig {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := bouncer.LoadCookieConfig(e.app.Config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertLoggedOut(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != loggedOutBody {
|
||||||
|
t.Fatalf("logout = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertForgets: the response deletes the cookie name with the path,
|
||||||
|
// domain, SameSite, Secure and HttpOnly of the token cookie cfg writes.
|
||||||
|
func assertForgets(t *testing.T, rec *httptest.ResponseRecorder, cfg bouncer.CookieConfig, name string) {
|
||||||
|
t.Helper()
|
||||||
|
set := cfg.TokenCookie("x", time.Hour)
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.Value != "" || c.MaxAge >= 0 || c.Path != set.Path || c.Domain != set.Domain ||
|
||||||
|
c.SameSite != set.SameSite || c.Secure != set.Secure || c.HttpOnly != set.HttpOnly {
|
||||||
|
t.Fatalf("forget cookie %s does not match the token cookie %s", c, set)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("no forget cookie for %s in %v", name, rec.Result().Header.Values("Set-Cookie"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// userAudienceToken signs a session token the way bouncer.Mint does (user
|
||||||
|
// audience, prv), with a chosen iat and exp.
|
||||||
|
func userAudienceToken(t *testing.T, sub, jti string, iat, exp time.Time) string {
|
||||||
|
t.Helper()
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": sub,
|
||||||
|
"aud": []string{bouncer.AudienceUser},
|
||||||
|
"iss": "http://example.com/_user/api/v1/login",
|
||||||
|
"iat": iat.Unix(),
|
||||||
|
"nbf": iat.Unix(),
|
||||||
|
"exp": exp.Unix(),
|
||||||
|
"jti": jti,
|
||||||
|
"prv": prvHash,
|
||||||
|
}
|
||||||
|
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(sessionSecret))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return signed
|
||||||
|
}
|
||||||
24
mcp.go
Normal file
24
mcp.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ pact.HasMCP = (*Plugin)(nil)
|
||||||
|
|
||||||
|
// MCP declares the plugin's MCP tools (D-18). me (exposed as user_me) runs
|
||||||
|
// GET /_user/api/v1/token/me as the signed-in frontend user under the
|
||||||
|
// "user" guard and answers the user payload.
|
||||||
|
func (p *Plugin) MCP() pact.MCP {
|
||||||
|
return pact.MCP{Tools: []pact.MCPTool{{
|
||||||
|
Name: "me",
|
||||||
|
Title: "Current user",
|
||||||
|
Description: "Returns the profile of the signed-in frontend user.",
|
||||||
|
Guard: "user",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/_user/api/v1/token/me",
|
||||||
|
Input: struct{}{},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
191
mcp_guard_smoke_test.go
Normal file
191
mcp_guard_smoke_test.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mintUserToken stores a personal API token of the user with the scopes and
|
||||||
|
// returns its secret and row.
|
||||||
|
func (e *adminEnv) mintUserToken(t *testing.T, user *models.User, scopes ...string) (string, *models.APIToken) {
|
||||||
|
t.Helper()
|
||||||
|
secret, token, err := classes.MintAPIToken(classes.DefaultAPITokenPrefix, user.ID, "mcp "+adminStamp(), scopes, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := e.gdb.Create(token).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return secret, token
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantGroup stores the codes as the group's permissions.
|
||||||
|
func (e *adminEnv) grantGroup(t *testing.T, group *models.UserGroup, codes ...string) {
|
||||||
|
t.Helper()
|
||||||
|
parts := make([]string, 0, len(codes))
|
||||||
|
for _, code := range codes {
|
||||||
|
parts = append(parts, strconv.Quote(code)+":1")
|
||||||
|
}
|
||||||
|
if err := e.gdb.Exec(`UPDATE user_groups SET permissions = ? WHERE id = ?`, "{"+strings.Join(parts, ",")+"}", group.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUserMCPGuardSmoke: the "user" guard provider is registered and
|
||||||
|
// published as the personal token resolver; it reads the plugin's route
|
||||||
|
// middleware, allows a tool with exactly the checks the routes run (scopes
|
||||||
|
// from the session, permission codes from the user's groups, every code
|
||||||
|
// required), dispatches with the group grants and an in-memory token
|
||||||
|
// credential, and resolves usr_ tokens. GET /token/me, the user_me target,
|
||||||
|
// answers the user payload to a read token only, and revoking a token fires
|
||||||
|
// the revoked subject event.
|
||||||
|
func TestUserMCPGuardSmoke(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
spy := env.spySubjects()
|
||||||
|
ctx := context.Background()
|
||||||
|
guards, ok := env.app.Lookup[*pact.MCPGuards]()
|
||||||
|
if !ok || guards == nil {
|
||||||
|
t.Fatal("*pact.MCPGuards is not published")
|
||||||
|
}
|
||||||
|
guard, ok := guards.Lookup("user")
|
||||||
|
if !ok || guard.MCPGuardName() != "user" {
|
||||||
|
t.Fatalf("user guard = %v %v", guard, ok)
|
||||||
|
}
|
||||||
|
resolver, ok := env.app.Lookup[pact.MCPPersonalTokenResolver]()
|
||||||
|
if !ok || resolver == nil {
|
||||||
|
t.Fatal("pact.MCPPersonalTokenResolver is not published")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !guard.MCPTokenGuarded([]string{"throttle:user-api", "user.api_token", "user.scope:read"}) || guard.MCPTokenGuarded([]string{"throttle:user-api", "jwt.auth"}) {
|
||||||
|
t.Fatal("MCPTokenGuarded must answer true only with user.api_token")
|
||||||
|
}
|
||||||
|
req, err := guard.MCPRequirements([]string{"throttle:user-api", "user.api_token", "user.scope:read", "user.scope:write", "user.permission:acme.posts.edit"})
|
||||||
|
if err != nil || !slices.Equal(req.Scopes, []string{"read", "write"}) || !slices.Equal(req.Permissions, []string{"acme.posts.edit"}) {
|
||||||
|
t.Fatalf("MCPRequirements = %+v %v", req, err)
|
||||||
|
}
|
||||||
|
if _, err := guard.MCPRequirements([]string{"user.api_token", "user.bogus"}); err == nil {
|
||||||
|
t.Fatal("an unknown user.* middleware must be an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := adminStamp()
|
||||||
|
codeA, codeB, codeOwn := "mcp."+stamp+".a", "mcp."+stamp+".b", "mcp."+stamp+".own"
|
||||||
|
user := env.seedUser(t, "mcp", func(u *models.User) { u.Permissions = models.PermissionSet{codeOwn: 1} })
|
||||||
|
groupA, groupB := env.seedGroup(t, "MCP A "+stamp, "mcp-a-"+stamp), env.seedGroup(t, "MCP B "+stamp, "mcp-b-"+stamp)
|
||||||
|
env.grantGroup(t, groupA, codeA)
|
||||||
|
env.grantGroup(t, groupB, codeB)
|
||||||
|
env.join(t, user, groupA, groupB)
|
||||||
|
subject := pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: []string{"read"}}
|
||||||
|
allows := func(req pact.MCPRequirements, perms ...string) bool {
|
||||||
|
t.Helper()
|
||||||
|
ok, err := guard.MCPAllows(ctx, subject, req, perms)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
if allows(pact.MCPRequirements{Scopes: []string{"read", "write"}}) {
|
||||||
|
t.Fatal("a scope the session lacks must refuse")
|
||||||
|
}
|
||||||
|
if !allows(pact.MCPRequirements{Scopes: []string{"read"}, Permissions: []string{codeA}}, codeB) {
|
||||||
|
t.Fatal("codes granted by the user's groups must allow")
|
||||||
|
}
|
||||||
|
if allows(pact.MCPRequirements{Scopes: []string{"read"}, Permissions: []string{codeA, "mcp." + stamp + ".missing"}}) {
|
||||||
|
t.Fatal("every code is required: one missing code must refuse")
|
||||||
|
}
|
||||||
|
if allows(pact.MCPRequirements{Scopes: []string{"read"}}, codeOwn) {
|
||||||
|
t.Fatal("a user-level permission alone must not grant (the route checks group grants)")
|
||||||
|
}
|
||||||
|
if ok, err := guard.MCPAllows(ctx, pact.MCPSubject{Guard: "user", UserID: user.ID + 1_000_000, Scopes: []string{"read"}}, pact.MCPRequirements{}, nil); err != nil || ok {
|
||||||
|
t.Fatalf("an unknown user = %v %v", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch, err := guard.MCPDispatch(ctx, subject, http.MethodGet)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
principal, ok := dispatch.Principal.(*bouncer.Principal)
|
||||||
|
if !ok || principal.ID != user.ID || !principal.PermissionGrants[codeA] || !principal.PermissionGrants[codeB] || principal.PermissionGrants[codeOwn] {
|
||||||
|
t.Fatalf("dispatch principal = %+v", dispatch.Principal)
|
||||||
|
}
|
||||||
|
credential, ok := dispatch.Credential.(*models.APIToken)
|
||||||
|
if !ok || credential.ID != 0 || credential.UserID != user.ID || !credential.HasScope("read") || credential.HasScope("write") {
|
||||||
|
t.Fatalf("dispatch credential = %+v", dispatch.Credential)
|
||||||
|
}
|
||||||
|
if !slices.Equal(dispatch.Guards, []string{"api_token"}) {
|
||||||
|
t.Fatalf("dispatch guards = %v", dispatch.Guards)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, "oas_at_x"); matched || err != nil {
|
||||||
|
t.Fatalf("a foreign prefix = %v %v", matched, err)
|
||||||
|
}
|
||||||
|
readSecret, readToken := env.mintUserToken(t, user, "read", "ai")
|
||||||
|
got, matched, err := resolver.MCPResolvePersonalToken(ctx, readSecret)
|
||||||
|
if err != nil || !matched || got.Guard != "user" || got.UserID != user.ID || got.TokenID != readToken.ID || !slices.Equal(got.Scopes, []string{"read", "ai"}) {
|
||||||
|
t.Fatalf("resolve = %+v %v %v", got, matched, err)
|
||||||
|
}
|
||||||
|
var stamped models.APIToken
|
||||||
|
if err := env.gdb.First(&stamped, readToken.ID).Error; err != nil || stamped.LastUsedAt == nil {
|
||||||
|
t.Fatalf("last_used_at not stamped: %+v %v", stamped, err)
|
||||||
|
}
|
||||||
|
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, classes.DefaultAPITokenPrefix+"not-a-token"); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {
|
||||||
|
t.Fatalf("an unknown usr_ token = %v %v", matched, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /token/me: the user_me target.
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/token/me", readSecret, nil)
|
||||||
|
if !strings.Contains(rec.Body.String(), `"email":"`+user.Email+`"`) || !strings.HasPrefix(rec.Body.String(), `{"user":`) {
|
||||||
|
t.Fatalf("token/me = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
writeSecret, _ := env.mintUserToken(t, user, "write")
|
||||||
|
rec = env.expect(t, http.StatusForbidden, http.MethodGet, "/_user/api/v1/token/me", writeSecret, nil)
|
||||||
|
if !strings.Contains(rec.Body.String(), "Missing required scope: read") {
|
||||||
|
t.Fatalf("token/me with write = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
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) {
|
||||||
|
t.Fatalf("revoke fired %+v", spy.events)
|
||||||
|
}
|
||||||
|
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, readSecret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {
|
||||||
|
t.Fatalf("a revoked token = %v %v", matched, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saving a group's permissions on the admin screen fires permissions
|
||||||
|
// for each member after the save commits.
|
||||||
|
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'A', 'MCP'), (?, 'B', 'MCP')`, codeA, codeB).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
spy.reset()
|
||||||
|
manager := env.admin(t, permAccessGroups)
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, groupPath(groupA.ID), manager, map[string]any{"name": groupA.Name, "code": *groupA.Code, "permissions": map[string]int{codeA: 1, codeB: 1}})
|
||||||
|
if !spy.has("user", user.ID, pact.MCPSubjectPermissions) {
|
||||||
|
t.Fatalf("group save fired %+v", spy.events)
|
||||||
|
}
|
||||||
|
// Deleting a group fires permissions for its members once it commits.
|
||||||
|
spy.reset()
|
||||||
|
env.expect(t, http.StatusOK, http.MethodDelete, groupPath(groupB.ID), manager, nil)
|
||||||
|
if !spy.has("user", user.ID, pact.MCPSubjectPermissions) {
|
||||||
|
t.Fatalf("group delete fired %+v", spy.events)
|
||||||
|
}
|
||||||
|
// Saving the user on the Users screen fires permissions too.
|
||||||
|
spy.reset()
|
||||||
|
users := env.admin(t, permAccessUsers)
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), users, map[string]any{"name": "MCP renamed", "email": user.Email})
|
||||||
|
if !spy.has("user", user.ID, pact.MCPSubjectPermissions) {
|
||||||
|
t.Fatalf("user save fired %+v", spy.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
models/backend_link.go
Normal file
24
models/backend_link.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// BackendLink is the verified link between one frontend user and one backend
|
||||||
|
// administrator (golem15_user_backend_links). Each side appears in at most
|
||||||
|
// one link. LinkedBy is the administrator who set the link on the
|
||||||
|
// Administrators screen; it is nil for a link the frontend user confirmed
|
||||||
|
// themselves after the administrator started it on My account.
|
||||||
|
type BackendLink struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
UserID uint `gorm:"column:user_id"`
|
||||||
|
BackendUserID uint `gorm:"column:backend_user_id"`
|
||||||
|
LinkedBy *uint `gorm:"column:linked_by"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BackendLink) TableName() string { return "golem15_user_backend_links" }
|
||||||
|
|
||||||
|
func (BackendLink) Fillable() []string {
|
||||||
|
return []string{"user_id", "backend_user_id", "linked_by"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { Register(BackendLink{}) }
|
||||||
31
models/backend_link_request.go
Normal file
31
models/backend_link_request.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// BackendLinkRequest is a one-shot request an administrator starts to link
|
||||||
|
// their account to a frontend user (golem15_user_backend_link_requests).
|
||||||
|
// Only the SHA-256 of the code is stored; the plain code travels once, in
|
||||||
|
// the site URL the administrator opens.
|
||||||
|
type BackendLinkRequest struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
CodeHash string `gorm:"column:code_hash" json:"-"`
|
||||||
|
BackendUserID uint `gorm:"column:backend_user_id"`
|
||||||
|
ExpiresAt time.Time `gorm:"column:expires_at"`
|
||||||
|
UsedAt *time.Time `gorm:"column:used_at"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BackendLinkRequest) TableName() string { return "golem15_user_backend_link_requests" }
|
||||||
|
|
||||||
|
func (BackendLinkRequest) Fillable() []string {
|
||||||
|
return []string{"code_hash", "backend_user_id", "expires_at", "used_at"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BackendLinkRequest) Hidden() []string { return []string{"code_hash"} }
|
||||||
|
|
||||||
|
// IsUsableAt is false once the request was used or has expired.
|
||||||
|
func (r BackendLinkRequest) IsUsableAt(now time.Time) bool {
|
||||||
|
return r.UsedAt == nil && r.ExpiresAt.After(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { Register(BackendLinkRequest{}) }
|
||||||
@@ -33,10 +33,21 @@ type User struct {
|
|||||||
TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"`
|
TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"`
|
||||||
CreatedIPAddress *string `gorm:"column:created_ip_address"`
|
CreatedIPAddress *string `gorm:"column:created_ip_address"`
|
||||||
LastIPAddress *string `gorm:"column:last_ip_address"`
|
LastIPAddress *string `gorm:"column:last_ip_address"`
|
||||||
|
// Handle is the unique @mention identity (D-15). Generated on create,
|
||||||
|
// never mass-assigned, and omitted from user API payloads unless a
|
||||||
|
// golden requires it.
|
||||||
|
Handle *string `gorm:"column:handle" json:"-"`
|
||||||
// LastSeen is stamped by login and token refresh, at most once per five
|
// LastSeen is stamped by login and token refresh, at most once per five
|
||||||
// minutes (classes.TouchLastSeen). It is shown in the admin list and is
|
// minutes (classes.TouchLastSeen). It is shown in the admin list and is
|
||||||
// never part of a user API payload.
|
// never part of a user API payload.
|
||||||
LastSeen *time.Time `gorm:"column:last_seen" json:"-"`
|
LastSeen *time.Time `gorm:"column:last_seen" json:"-"`
|
||||||
|
// EmailVerifiedAt records when the owner proved control of Email (D-27).
|
||||||
|
// Only two flows write it: the mailed activation code
|
||||||
|
// (classes.VerifyActivationCode) and a completed reset-code password
|
||||||
|
// reset (controllers.ResetPassword). An email change through /update or
|
||||||
|
// the admin form clears it. It is never mass-assigned and never part of a
|
||||||
|
// user API payload; social sign-in matching reads it.
|
||||||
|
EmailVerifiedAt *time.Time `gorm:"column:email_verified_at" json:"-"`
|
||||||
// Permissions are the user's own frontend permissions (1 allows, -1
|
// Permissions are the user's own frontend permissions (1 allows, -1
|
||||||
// denies). classes.MergedPermissions combines them with the groups'. They
|
// denies). classes.MergedPermissions combines them with the groups'. They
|
||||||
// are written by the admin permission editor only and are never part of
|
// are written by the admin permission editor only and are never part of
|
||||||
@@ -70,8 +81,12 @@ func (User) AttachRelations() []attach.Relation {
|
|||||||
return []attach.Relation{{Name: AvatarField, Public: true}}
|
return []attach.Relation{{Name: AvatarField, Public: true}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fillable is the register/update field list. Username, PIN, and GDPR columns
|
// Fillable is the mass-assignment backstop of the admin Users form and of
|
||||||
// stay out of Phase 7 because no ported handler reads them.
|
// trusted application code: cabana intersects it with the form's writable
|
||||||
|
// fields (and an application's form extensions) and keeps organisation_id
|
||||||
|
// protected. Registration does not use it: RegisterUser fills only
|
||||||
|
// RegisterFillable. Username, PIN and GDPR columns stay out because no
|
||||||
|
// ported handler reads them.
|
||||||
func (User) Fillable() []string {
|
func (User) Fillable() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"name", "surname", "email", "password", "created_ip_address", "last_ip_address",
|
"name", "surname", "email", "password", "created_ip_address", "last_ip_address",
|
||||||
@@ -80,6 +95,20 @@ func (User) Fillable() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterFillable is the explicit allow-list of the columns a registration
|
||||||
|
// body may set (POST /register and controllers.RegisterUser): name, surname,
|
||||||
|
// email, password (stored as its hash), preferred_locale and
|
||||||
|
// marketing_consent. A registration body can never set organisation_id or
|
||||||
|
// organisation_role (membership is the administrator's or the
|
||||||
|
// application's), created_ip_address or last_ip_address (server-set from
|
||||||
|
// RegisterOptions.IP only), is_onboarded (application-owned onboarding
|
||||||
|
// state) or must_change_password (set by an administrator or the
|
||||||
|
// require-password-change command). The activation, reset, address proof,
|
||||||
|
// handle and permission columns are never fillable at all.
|
||||||
|
func (User) RegisterFillable() []string {
|
||||||
|
return []string{"name", "surname", "email", "password", "preferred_locale", "marketing_consent"}
|
||||||
|
}
|
||||||
|
|
||||||
// Hidden mirrors PHP User::$hidden for the secrets this model stores.
|
// Hidden mirrors PHP User::$hidden for the secrets this model stores.
|
||||||
func (User) Hidden() []string {
|
func (User) Hidden() []string {
|
||||||
return []string{"password", "reset_password_code", "activation_code"}
|
return []string{"password", "reset_password_code", "activation_code"}
|
||||||
|
|||||||
331
oauth_identities_rule_test.go
Normal file
331
oauth_identities_rule_test.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"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"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// guardedUnlinkSQL is the DELETE OAuthIdentitiesDestroy runs (PHP User.php
|
||||||
|
// unlinkOAuthProvider), copied so the race test drives the same statement
|
||||||
|
// under its own transactions.
|
||||||
|
const guardedUnlinkSQL = `DELETE FROM golem15_user_oauth_identities
|
||||||
|
WHERE user_id = ? AND provider = ?
|
||||||
|
AND (EXISTS (SELECT 1 FROM users u WHERE u.id = golem15_user_oauth_identities.user_id AND u.has_self_set_password)
|
||||||
|
OR (SELECT COUNT(*) FROM golem15_user_oauth_identities oi WHERE oi.user_id = golem15_user_oauth_identities.user_id) > 1)`
|
||||||
|
|
||||||
|
// ruleUser creates a user named after the test with selfSet as
|
||||||
|
// has_self_set_password and links the given providers.
|
||||||
|
func ruleUser(t *testing.T, db *gorm.DB, selfSet bool, providers ...string) *models.User {
|
||||||
|
t.Helper()
|
||||||
|
slug := strings.NewReplacer("/", "-", " ", "-").Replace(strings.ToLower(t.Name()))
|
||||||
|
email := fmt.Sprintf("%s-%d@example.test", slug, time.Now().UnixNano())
|
||||||
|
u := insertUser(t, db, email, "correct-horse")
|
||||||
|
if err := db.Model(u).Update("has_self_set_password", selfSet).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range providers {
|
||||||
|
seedOAuthIdentity(t, db, u.ID, p, p+"-"+email)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func linkedProviders(t *testing.T, db *gorm.DB, userID uint) []string {
|
||||||
|
t.Helper()
|
||||||
|
var rows []models.OAuthIdentity
|
||||||
|
if err := db.Where("user_id = ?", userID).Order("provider").Find(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, r.Provider)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlinkWith runs the destroy handler for userID and provider with opts.
|
||||||
|
func unlinkWith(t *testing.T, app *backpack.App, opts controllers.OAuthIdentitiesOptions, userID uint, provider, locale string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/_user/api/v1/oauth-identities/"+provider, nil)
|
||||||
|
req.SetPathValue("provider", provider)
|
||||||
|
req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: userID}), locale))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
controllers.OAuthIdentitiesDestroy(app, opts).ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func notFound404(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte("host 404"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOAuthIdentitiesDestroyRule is the D-13 unlink rule table: an identity
|
||||||
|
// goes when the user has a self-set password or another identity remains;
|
||||||
|
// otherwise 409 with the unchanged EN and PL bodies. Unknown providers and
|
||||||
|
// identities of other users are 404 through the host's WriteNotFound;
|
||||||
|
// microsoft is in the default allow-list; without opts.Providers a
|
||||||
|
// published source's known names decide, and opts.Providers wins over it.
|
||||||
|
func TestOAuthIdentitiesDestroyRule(t *testing.T) {
|
||||||
|
skipShortPostgres(t)
|
||||||
|
app, db := sessionApp(t, "0s")
|
||||||
|
activateOAuthIdentitiesLang(t, app)
|
||||||
|
opts := controllers.OAuthIdentitiesOptions{WriteNotFound: notFound404}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
selfSet bool
|
||||||
|
linked []string
|
||||||
|
unlink string
|
||||||
|
locale string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
remaining []string
|
||||||
|
}{
|
||||||
|
{name: "self_set_password_one_identity", selfSet: true, linked: []string{"google"}, unlink: "google", status: http.StatusNoContent, remaining: []string{}},
|
||||||
|
{name: "passwordless_one_identity_en", linked: []string{"facebook"}, unlink: "facebook", locale: "en", status: http.StatusConflict, body: `{"error":"` + lastMethodBlockedEN + `"}`, remaining: []string{"facebook"}},
|
||||||
|
{name: "passwordless_one_identity_pl", linked: []string{"github"}, unlink: "github", locale: "pl", status: http.StatusConflict, body: `{"error":"` + lastMethodBlockedPL + `"}`, remaining: []string{"github"}},
|
||||||
|
{name: "passwordless_two_identities", linked: []string{"facebook", "google"}, unlink: "google", status: http.StatusNoContent, remaining: []string{"facebook"}},
|
||||||
|
{name: "self_set_password_two_identities", selfSet: true, linked: []string{"facebook", "google"}, unlink: "facebook", status: http.StatusNoContent, remaining: []string{"google"}},
|
||||||
|
{name: "unknown_provider", selfSet: true, linked: []string{"google"}, unlink: "linkedin", status: http.StatusNotFound, body: "host 404", remaining: []string{"google"}},
|
||||||
|
{name: "not_linked", selfSet: true, linked: []string{"google"}, unlink: "github", status: http.StatusNotFound, body: "host 404", remaining: []string{"google"}},
|
||||||
|
{name: "microsoft_allowed_by_default", selfSet: true, linked: []string{"microsoft"}, unlink: "microsoft", status: http.StatusNoContent, remaining: []string{}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
u := ruleUser(t, db, tc.selfSet, tc.linked...)
|
||||||
|
locale := tc.locale
|
||||||
|
if locale == "" {
|
||||||
|
locale = "en"
|
||||||
|
}
|
||||||
|
rec := unlinkWith(t, app, opts, u.ID, tc.unlink, locale)
|
||||||
|
if rec.Code != tc.status {
|
||||||
|
t.Fatalf("status %d, want %d (body %s)", rec.Code, tc.status, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := strings.TrimRight(rec.Body.String(), "\n"); got != tc.body {
|
||||||
|
t.Fatalf("body %q, want %q", got, tc.body)
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, db, u.ID); strings.Join(got, ",") != strings.Join(tc.remaining, ",") {
|
||||||
|
t.Fatalf("remaining %v, want %v", got, tc.remaining)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("foreign_identity", func(t *testing.T) {
|
||||||
|
owner := ruleUser(t, db, true, "google")
|
||||||
|
stranger := ruleUser(t, db, true, "facebook")
|
||||||
|
rec := unlinkWith(t, app, opts, stranger.ID, "google", "en")
|
||||||
|
if rec.Code != http.StatusNotFound || rec.Body.String() != "host 404" {
|
||||||
|
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, db, owner.ID); len(got) != 1 || got[0] != "google" {
|
||||||
|
t.Fatalf("owner's identities %v", got)
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, db, stranger.ID); len(got) != 1 || got[0] != "facebook" {
|
||||||
|
t.Fatalf("stranger's identities %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("source_known_names_when_no_options", func(t *testing.T) {
|
||||||
|
srcApp, srcDB := sessionApp(t, "0s")
|
||||||
|
if err := srcApp.Publish[classes.OAuthProviderSource](fakeProviderSource{known: []string{"google", "custom"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u := ruleUser(t, srcDB, true, "custom", "github")
|
||||||
|
if rec := unlinkWith(t, srcApp, opts, u.ID, "custom", "en"); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("custom: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := unlinkWith(t, srcApp, opts, u.ID, "github", "en"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("github outside the source's names: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, srcDB, u.ID); len(got) != 1 || got[0] != "github" {
|
||||||
|
t.Fatalf("remaining %v, want [github]", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("options_win_over_source", func(t *testing.T) {
|
||||||
|
srcApp, srcDB := sessionApp(t, "0s")
|
||||||
|
if err := srcApp.Publish[classes.OAuthProviderSource](fakeProviderSource{known: []string{"google", "custom"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
withList := controllers.OAuthIdentitiesOptions{Providers: []string{"github"}, WriteNotFound: notFound404}
|
||||||
|
u := ruleUser(t, srcDB, true, "custom", "github")
|
||||||
|
if rec := unlinkWith(t, srcApp, withList, u.ID, "custom", "en"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("custom outside opts.Providers: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := unlinkWith(t, srcApp, withList, u.ID, "github", "en"); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("github in opts.Providers: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, srcDB, u.ID); len(got) != 1 || got[0] != "custom" {
|
||||||
|
t.Fatalf("remaining %v, want [custom]", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOAuthIdentitiesDestroyRace forces the D-13 race deterministically:
|
||||||
|
// two transactions unlink the two identities of a password-less user, each
|
||||||
|
// taking the users-row lock (SELECT ... FOR UPDATE) and running the guarded
|
||||||
|
// DELETE the handler runs. The second blocks until the first commits and
|
||||||
|
// then deletes nothing, in both orders; exactly one identity is left. The
|
||||||
|
// handler variants prove the handler takes the same lock.
|
||||||
|
func TestOAuthIdentitiesDestroyRace(t *testing.T) {
|
||||||
|
skipShortPostgres(t)
|
||||||
|
app, db := sessionApp(t, "0s")
|
||||||
|
activateOAuthIdentitiesLang(t, app)
|
||||||
|
|
||||||
|
for _, order := range [][2]string{{"google", "facebook"}, {"facebook", "google"}} {
|
||||||
|
first, second := order[0], order[1]
|
||||||
|
t.Run("transactions_"+first+"_first", func(t *testing.T) {
|
||||||
|
u := ruleUser(t, db, false, "google", "facebook")
|
||||||
|
tx1 := db.Begin()
|
||||||
|
if tx1.Error != nil {
|
||||||
|
t.Fatal(tx1.Error)
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if !committed {
|
||||||
|
tx1.Rollback()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := tx1.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, u.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res := tx1.Exec(guardedUnlinkSQL, u.ID, first)
|
||||||
|
if res.Error != nil || res.RowsAffected != 1 {
|
||||||
|
t.Fatalf("first delete: rows %d err %v", res.RowsAffected, res.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type outcome struct {
|
||||||
|
rows int64
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
done := make(chan outcome, 1)
|
||||||
|
go func() {
|
||||||
|
var rows int64
|
||||||
|
err := db.Transaction(func(tx2 *gorm.DB) error {
|
||||||
|
if err := tx2.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, u.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r := tx2.Exec(guardedUnlinkSQL, u.ID, second)
|
||||||
|
rows = r.RowsAffected
|
||||||
|
return r.Error
|
||||||
|
})
|
||||||
|
done <- outcome{rows, err}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case got := <-done:
|
||||||
|
t.Fatalf("the second transaction finished (rows %d, err %v) while the first held the users row lock", got.rows, got.err)
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if err := tx1.Commit().Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
select {
|
||||||
|
case got := <-done:
|
||||||
|
if got.err != nil || got.rows != 0 {
|
||||||
|
t.Fatalf("second delete after the first committed: rows %d err %v, want 0 rows", got.rows, got.err)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("the second transaction did not finish after the first committed")
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, db, u.ID); len(got) != 1 || got[0] != second {
|
||||||
|
t.Fatalf("remaining %v, want [%s]", got, second)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handler_waits_"+first+"_first", func(t *testing.T) {
|
||||||
|
u := ruleUser(t, db, false, "google", "facebook")
|
||||||
|
tx1 := db.Begin()
|
||||||
|
committed := false
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if !committed {
|
||||||
|
tx1.Rollback()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := tx1.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, u.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res := tx1.Exec(guardedUnlinkSQL, u.ID, first); res.Error != nil || res.RowsAffected != 1 {
|
||||||
|
t.Fatalf("first delete: rows %d err %v", res.RowsAffected, res.Error)
|
||||||
|
}
|
||||||
|
recCh := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
go func() {
|
||||||
|
recCh <- unlinkWith(t, app, controllers.OAuthIdentitiesOptions{WriteNotFound: notFound404}, u.ID, second, "en")
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case rec := <-recCh:
|
||||||
|
t.Fatalf("the handler answered %d while the users row was locked", rec.Code)
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if err := tx1.Commit().Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
var rec *httptest.ResponseRecorder
|
||||||
|
select {
|
||||||
|
case rec = <-recCh:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("the handler did not answer after the lock was released")
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body["error"] != lastMethodBlockedEN {
|
||||||
|
t.Fatalf("409 body %s (%v)", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, db, u.ID); len(got) != 1 || got[0] != second {
|
||||||
|
t.Fatalf("remaining %v, want [%s]", got, second)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("concurrent_handlers", func(t *testing.T) {
|
||||||
|
opts := controllers.OAuthIdentitiesOptions{WriteNotFound: notFound404}
|
||||||
|
for round := range 10 {
|
||||||
|
u := ruleUser(t, db, false, "google", "facebook")
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
start := make(chan struct{})
|
||||||
|
codes := make([]int, 2)
|
||||||
|
for i, p := range []string{"google", "facebook"} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
codes[i] = unlinkWith(t, app, opts, u.ID, p, "en").Code
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
ok, conflict := 0, 0
|
||||||
|
for _, c := range codes {
|
||||||
|
switch c {
|
||||||
|
case http.StatusNoContent:
|
||||||
|
ok++
|
||||||
|
case http.StatusConflict:
|
||||||
|
conflict++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok != 1 || conflict != 1 {
|
||||||
|
t.Fatalf("round %d: statuses %v, want one 204 and one 409", round, codes)
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, db, u.ID); len(got) != 1 {
|
||||||
|
t.Fatalf("round %d: remaining %v, want exactly one identity", round, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -109,6 +109,11 @@ func TestOAuthIdentitiesDestroyLastMethodBlockedENAndPL(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(tc.locale, func(t *testing.T) {
|
t.Run(tc.locale, func(t *testing.T) {
|
||||||
user := insertUser(t, db, tc.email, "correct-horse")
|
user := insertUser(t, db, tc.email, "correct-horse")
|
||||||
|
// The column defaults to TRUE; a user without a self-set
|
||||||
|
// password is the one the last identity is kept for.
|
||||||
|
if err := db.Model(user).Update("has_self_set_password", false).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
seedOAuthIdentity(t, db, user.ID, "facebook", "fb-only-"+tc.locale)
|
seedOAuthIdentity(t, db, user.ID, "facebook", "fb-only-"+tc.locale)
|
||||||
rec := destroyOAuthIdentity(t, app, user.ID, "facebook", tc.locale, winter404OrFatal(t))
|
rec := destroyOAuthIdentity(t, app, user.ID, "facebook", tc.locale, winter404OrFatal(t))
|
||||||
if rec.Code != http.StatusConflict {
|
if rec.Code != http.StatusConflict {
|
||||||
@@ -128,7 +133,9 @@ func TestOAuthIdentitiesDestroyLastMethodBlockedENAndPL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword(t *testing.T) {
|
// TestOAuthIdentitiesDestroyLastIdentityWithPassword: under the PHP rule a
|
||||||
|
// user with a self-set password may unlink the last identity (D-13).
|
||||||
|
func TestOAuthIdentitiesDestroyLastIdentityWithPassword(t *testing.T) {
|
||||||
app, db := sessionApp(t, "0s")
|
app, db := sessionApp(t, "0s")
|
||||||
activateOAuthIdentitiesLang(t, app)
|
activateOAuthIdentitiesLang(t, app)
|
||||||
user := insertUser(t, db, "oauth-last-password@example.test", "correct-horse")
|
user := insertUser(t, db, "oauth-last-password@example.test", "correct-horse")
|
||||||
@@ -137,6 +144,80 @@ func TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword(t *testing.T) {
|
|||||||
}
|
}
|
||||||
seedOAuthIdentity(t, db, user.ID, "google", "g-only")
|
seedOAuthIdentity(t, db, user.ID, "google", "g-only")
|
||||||
rec := destroyOAuthIdentity(t, app, user.ID, "google", "en", winter404OrFatal(t))
|
rec := destroyOAuthIdentity(t, app, user.ID, "google", "en", winter404OrFatal(t))
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec.Body.Len() != 0 {
|
||||||
|
t.Fatalf("body = %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
if remaining := countOAuthIdentities(t, db, user.ID); remaining != 0 {
|
||||||
|
t.Fatalf("identities left = %d, want 0", remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOAuthIdentitiesDestroyConcurrentSmoke: two unlinks on a password-less
|
||||||
|
// user with two identities never remove both (D-13, RESEARCH Pitfall 1).
|
||||||
|
// Transaction A holds the users row lock and has deleted google; the
|
||||||
|
// handler's unlink of facebook must wait for A, then see one identity left
|
||||||
|
// and answer 409.
|
||||||
|
func TestOAuthIdentitiesDestroyConcurrentSmoke(t *testing.T) {
|
||||||
|
app, db := sessionApp(t, "0s")
|
||||||
|
activateOAuthIdentitiesLang(t, app)
|
||||||
|
user := insertUser(t, db, "oauth-concurrent@example.test", "correct-horse")
|
||||||
|
if err := db.Model(user).Update("has_self_set_password", false).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedOAuthIdentity(t, db, user.ID, "google", "g-concurrent")
|
||||||
|
seedOAuthIdentity(t, db, user.ID, "facebook", "fb-concurrent")
|
||||||
|
|
||||||
|
txA := db.Begin()
|
||||||
|
if txA.Error != nil {
|
||||||
|
t.Fatal(txA.Error)
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if !committed {
|
||||||
|
txA.Rollback()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := txA.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, user.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res := txA.Exec(`DELETE FROM golem15_user_oauth_identities
|
||||||
|
WHERE user_id = ? AND provider = ?
|
||||||
|
AND (EXISTS (SELECT 1 FROM users u WHERE u.id = golem15_user_oauth_identities.user_id AND u.has_self_set_password)
|
||||||
|
OR (SELECT COUNT(*) FROM golem15_user_oauth_identities oi WHERE oi.user_id = golem15_user_oauth_identities.user_id) > 1)`,
|
||||||
|
user.ID, "google")
|
||||||
|
if res.Error != nil || res.RowsAffected != 1 {
|
||||||
|
t.Fatalf("transaction A delete: rows %d err %v", res.RowsAffected, res.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/_fonoteka/api/v1/oauth-identities/facebook", nil)
|
||||||
|
req.SetPathValue("provider", "facebook")
|
||||||
|
req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID}), "en"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h := controllers.OAuthIdentitiesDestroy(app, controllers.OAuthIdentitiesOptions{
|
||||||
|
WriteNotFound: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) },
|
||||||
|
})
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
t.Fatalf("the unlink answered %d while transaction A held the user row lock", rec.Code)
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if err := txA.Commit().Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("the unlink did not answer after transaction A committed")
|
||||||
|
}
|
||||||
if rec.Code != http.StatusConflict {
|
if rec.Code != http.StatusConflict {
|
||||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -145,10 +226,14 @@ func TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if body["error"] != lastMethodBlockedEN {
|
if body["error"] != lastMethodBlockedEN {
|
||||||
t.Fatalf("last_method_blocked with password = %#v", body["error"])
|
t.Fatalf("409 body = %s", rec.Body.String())
|
||||||
}
|
}
|
||||||
if remaining := countOAuthIdentities(t, db, user.ID); remaining != 1 {
|
var left []models.OAuthIdentity
|
||||||
t.Fatalf("row still present = %d", remaining)
|
if err := db.Where("user_id = ?", user.ID).Find(&left).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(left) != 1 || left[0].Provider != "facebook" {
|
||||||
|
t.Fatalf("remaining = %#v, want facebook only", left)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
76
oauth_providers_smoke_test.go
Normal file
76
oauth_providers_smoke_test.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeProviderSource is a social-login plugin's provider source.
|
||||||
|
type fakeProviderSource struct {
|
||||||
|
enabled []classes.OAuthProvider
|
||||||
|
known []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeProviderSource) EnabledOAuthProviders(context.Context) []classes.OAuthProvider {
|
||||||
|
return f.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeProviderSource) KnownOAuthProviders() []string { return f.known }
|
||||||
|
|
||||||
|
// TestOAuthProvidersSourceSmoke checks that oauth-providers keeps the empty
|
||||||
|
// body without a published source and lists the source's enabled providers,
|
||||||
|
// looked up per request, once one is published.
|
||||||
|
func TestOAuthProvidersSourceSmoke(t *testing.T) {
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
h := controllers.OAuthProviders(app)
|
||||||
|
get := func() *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/oauth-providers", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := get()
|
||||||
|
if empty.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status without a source = %d", empty.Code)
|
||||||
|
}
|
||||||
|
// The bytes the handler answered before sources existed (wire encodes
|
||||||
|
// map keys sorted; the PHP recording has the same JSON value).
|
||||||
|
if got := strings.TrimRight(empty.Body.String(), "\n"); got != `{"providers":[],"success":true}` {
|
||||||
|
t.Fatalf("body without a source = %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
src := fakeProviderSource{
|
||||||
|
enabled: []classes.OAuthProvider{{Name: "google", Label: "Google"}},
|
||||||
|
known: []string{"google", "facebook"},
|
||||||
|
}
|
||||||
|
if err := app.Publish[classes.OAuthProviderSource](src); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := get()
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status with a source = %d", rec.Code)
|
||||||
|
}
|
||||||
|
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" {
|
||||||
|
t.Fatalf("Cache-Control = %q", cc)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Providers []classes.OAuthProvider `json:"providers"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
if !body.Success || !reflect.DeepEqual(body.Providers, src.enabled) {
|
||||||
|
t.Fatalf("body with a source = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
136
oauth_providers_test.go
Normal file
136
oauth_providers_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countingProviderSource answers a mutable provider list and counts the
|
||||||
|
// lookups, so a test can prove the handler asks on every request.
|
||||||
|
type countingProviderSource struct {
|
||||||
|
enabled atomic.Pointer[[]classes.OAuthProvider]
|
||||||
|
calls atomic.Int32
|
||||||
|
ctxSeen atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ctxProbeKey struct{}
|
||||||
|
|
||||||
|
func (s *countingProviderSource) EnabledOAuthProviders(ctx context.Context) []classes.OAuthProvider {
|
||||||
|
s.calls.Add(1)
|
||||||
|
if ctx.Value(ctxProbeKey{}) == "probe" {
|
||||||
|
s.ctxSeen.Store(true)
|
||||||
|
}
|
||||||
|
if p := s.enabled.Load(); p != nil {
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *countingProviderSource) KnownOAuthProviders() []string { return nil }
|
||||||
|
|
||||||
|
// TestOAuthProviders pins GET /_user/api/v1/oauth-providers (D-15) without a
|
||||||
|
// database: the old bytes without a source, an empty list from a source
|
||||||
|
// with nothing enabled, the source's order, and a lookup on every request.
|
||||||
|
func TestOAuthProviders(t *testing.T) {
|
||||||
|
get := func(t *testing.T, h http.HandlerFunc, ctx context.Context) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/oauth-providers", nil)
|
||||||
|
if ctx != nil {
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" {
|
||||||
|
t.Fatalf("Cache-Control = %q", cc)
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
body := func(rec *httptest.ResponseRecorder) string {
|
||||||
|
return strings.TrimRight(rec.Body.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no_source_old_bytes", func(t *testing.T) {
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
|
||||||
|
t.Fatalf("body = %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("source_without_enabled_providers", func(t *testing.T) {
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
if err := app.Publish[classes.OAuthProviderSource](fakeProviderSource{known: []string{"google"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
|
||||||
|
t.Fatalf("body = %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("order_preserved", func(t *testing.T) {
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
src := fakeProviderSource{enabled: []classes.OAuthProvider{
|
||||||
|
{Name: "microsoft", Label: "Microsoft"},
|
||||||
|
{Name: "github", Label: "GitHub"},
|
||||||
|
{Name: "google", Label: "Google"},
|
||||||
|
}}
|
||||||
|
if err := app.Publish[classes.OAuthProviderSource](src); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := `{"providers":[{"label":"Microsoft","name":"microsoft"},{"label":"GitHub","name":"github"},{"label":"Google","name":"google"}],"success":true}`
|
||||||
|
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != want {
|
||||||
|
t.Fatalf("body = %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("lookup_per_request", func(t *testing.T) {
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
h := controllers.OAuthProviders(app)
|
||||||
|
if got := body(get(t, h, nil)); got != `{"providers":[],"success":true}` {
|
||||||
|
t.Fatalf("before publishing: %s", got)
|
||||||
|
}
|
||||||
|
src := &countingProviderSource{}
|
||||||
|
if err := app.Publish[classes.OAuthProviderSource](src); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := []classes.OAuthProvider{{Name: "google", Label: "Google"}}
|
||||||
|
src.enabled.Store(&first)
|
||||||
|
probe := context.WithValue(context.Background(), ctxProbeKey{}, "probe")
|
||||||
|
if got := body(get(t, h, probe)); got != `{"providers":[{"label":"Google","name":"google"}],"success":true}` {
|
||||||
|
t.Fatalf("after publishing: %s", got)
|
||||||
|
}
|
||||||
|
second := []classes.OAuthProvider{{Name: "github", Label: "GitHub"}}
|
||||||
|
src.enabled.Store(&second)
|
||||||
|
if got := body(get(t, h, nil)); got != `{"providers":[{"label":"GitHub","name":"github"}],"success":true}` {
|
||||||
|
t.Fatalf("after the source changed: %s", got)
|
||||||
|
}
|
||||||
|
if n := src.calls.Load(); n != 2 {
|
||||||
|
t.Fatalf("EnabledOAuthProviders called %d times for 2 requests", n)
|
||||||
|
}
|
||||||
|
if !src.ctxSeen.Load() {
|
||||||
|
t.Fatal("EnabledOAuthProviders did not receive the request context")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bare_publish_is_not_found", func(t *testing.T) {
|
||||||
|
// A source published without the explicit type argument is keyed on
|
||||||
|
// its concrete type; the list stays empty (the documented pitfall).
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
if err := app.Publish(fakeProviderSource{enabled: []classes.OAuthProvider{{Name: "google", Label: "Google"}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
|
||||||
|
t.Fatalf("body = %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
195
offboarding_test.go
Normal file
195
offboarding_test.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
usermiddleware "git.golem15.com/golem15/sm-user-plugin/middleware"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const refusedRefreshBody = `{"error":"Could not refresh token","msg":"User not found"}`
|
||||||
|
|
||||||
|
// offboardingCreds gives a seeded user an older session JWT (issued two
|
||||||
|
// seconds ago, so a revocation cutoff in this second cuts it) and a usr_
|
||||||
|
// personal API token.
|
||||||
|
func offboardingCreds(t *testing.T, e *adminEnv, user *models.User) (jwt, apiSecret string) {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now()
|
||||||
|
jwt = craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "off-"+adminStamp(), now.Add(-2*time.Second), now.Add(time.Hour))
|
||||||
|
secret, token, err := classes.MintAPIToken("usr_", user.ID, "offboarding", []string{"read"}, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := e.gdb.Create(token).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return jwt, secret
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiTokenWorks authenticates secret through the user.api_token guard.
|
||||||
|
func apiTokenWorks(t *testing.T, e *adminEnv, secret string) bool {
|
||||||
|
t.Helper()
|
||||||
|
guard := &usermiddleware.APITokenGuard{App: e.app}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/example", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+secret)
|
||||||
|
principal, _, err := guard.AuthenticateCredential(req)
|
||||||
|
return err == nil && principal != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *adminEnv) fetchCode(t *testing.T, token string) int {
|
||||||
|
t.Helper()
|
||||||
|
return e.call(t, http.MethodGet, "/_user/api/v1/fetch", token, nil).Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *adminEnv) refresh(t *testing.T, token string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
return e.call(t, http.MethodPost, "/_user/api/v1/refresh", token, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRefreshRefused(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != refusedRefreshBody {
|
||||||
|
t.Fatalf("refresh = %d %s, want 401 %s", rec.Code, rec.Body.String(), refusedRefreshBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOffboarding: deactivate and ban end every earlier session JWT and
|
||||||
|
// personal API token. A deactivated user reactivates by signing in and gets
|
||||||
|
// a working token; a banned one is refused. Restore and unban revive no
|
||||||
|
// token.
|
||||||
|
func TestOffboarding(t *testing.T) {
|
||||||
|
e := newAdminEnv(t)
|
||||||
|
admin := e.admin(t, controllers.PermissionAccessUsers)
|
||||||
|
|
||||||
|
t.Run("deactivate", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "off-deactivate", nil)
|
||||||
|
jwt, secret := offboardingCreds(t, e, user)
|
||||||
|
if e.fetchCode(t, jwt) != http.StatusOK || !apiTokenWorks(t, e, secret) {
|
||||||
|
t.Fatal("credentials do not work before the deactivation")
|
||||||
|
}
|
||||||
|
e.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/deactivate"), admin, map[string]any{"ids": []uint{user.ID}})
|
||||||
|
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("fetch after deactivate = %d", code)
|
||||||
|
}
|
||||||
|
if apiTokenWorks(t, e, secret) {
|
||||||
|
t.Fatal("API token works after deactivate")
|
||||||
|
}
|
||||||
|
assertRefreshRefused(t, e.refresh(t, jwt))
|
||||||
|
|
||||||
|
// Signing in right away (the same second allowed) restores the
|
||||||
|
// account, mails the reactivation and returns a working token.
|
||||||
|
code, fresh := e.userLogin(t, user.Email, "correct-horse")
|
||||||
|
if code != http.StatusOK || fresh == "" {
|
||||||
|
t.Fatalf("login after deactivate = %d", code)
|
||||||
|
}
|
||||||
|
if row := e.reload(t, user.ID); row == nil || row.DeletedAt.Valid {
|
||||||
|
t.Fatal("login did not restore the account")
|
||||||
|
}
|
||||||
|
if len(e.mails(user.Email)) != 1 {
|
||||||
|
t.Fatalf("reactivate mails = %d", len(e.mails(user.Email)))
|
||||||
|
}
|
||||||
|
if code := e.fetchCode(t, fresh); code != http.StatusOK {
|
||||||
|
t.Fatalf("fetch with the fresh token = %d", code)
|
||||||
|
}
|
||||||
|
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("old JWT after the restore = %d", code)
|
||||||
|
}
|
||||||
|
if apiTokenWorks(t, e, secret) {
|
||||||
|
t.Fatal("old API token works after the restore")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ban", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "off-ban", nil)
|
||||||
|
jwt, secret := offboardingCreds(t, e, user)
|
||||||
|
e.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/ban"), admin, map[string]any{"ids": []uint{user.ID}})
|
||||||
|
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("fetch after ban = %d", code)
|
||||||
|
}
|
||||||
|
if apiTokenWorks(t, e, secret) {
|
||||||
|
t.Fatal("API token works after ban")
|
||||||
|
}
|
||||||
|
assertRefreshRefused(t, e.refresh(t, jwt))
|
||||||
|
rec := e.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"})
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
|
||||||
|
t.Fatalf("banned login = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
e.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/unban"), admin, map[string]any{"ids": []uint{user.ID}})
|
||||||
|
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("old JWT after unban = %d", code)
|
||||||
|
}
|
||||||
|
if apiTokenWorks(t, e, secret) {
|
||||||
|
t.Fatal("old API token works after unban")
|
||||||
|
}
|
||||||
|
code, fresh := e.userLogin(t, user.Email, "correct-horse")
|
||||||
|
if code != http.StatusOK || e.fetchCode(t, fresh) != http.StatusOK {
|
||||||
|
t.Fatalf("login after unban = %d", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefreshSubjectChecks: /refresh loads the subject even without a
|
||||||
|
// revocation stamp. Deactivated, banned and cut-off subjects are refused
|
||||||
|
// with the token-failure body and stamp no last_seen; a suspended user still
|
||||||
|
// refreshes, and a normal refresh stamps last_seen.
|
||||||
|
func TestRefreshSubjectChecks(t *testing.T) {
|
||||||
|
e := newAdminEnv(t)
|
||||||
|
token := func(t *testing.T, user *models.User, iat time.Time) string {
|
||||||
|
t.Helper()
|
||||||
|
return craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "refresh-"+adminStamp(), iat, iat.Add(time.Hour))
|
||||||
|
}
|
||||||
|
lastSeen := func(t *testing.T, user *models.User) *time.Time {
|
||||||
|
t.Helper()
|
||||||
|
return e.reload(t, user.ID).LastSeen
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("deactivated", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "refresh-deactivated", nil)
|
||||||
|
if err := e.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, user.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertRefreshRefused(t, e.refresh(t, token(t, user, time.Now())))
|
||||||
|
if lastSeen(t, user) != nil {
|
||||||
|
t.Fatal("a refused refresh stamped last_seen")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("banned", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "refresh-banned", nil)
|
||||||
|
e.seedThrottle(t, user, "", true, nil)
|
||||||
|
assertRefreshRefused(t, e.refresh(t, token(t, user, time.Now())))
|
||||||
|
if lastSeen(t, user) != nil {
|
||||||
|
t.Fatal("a refused refresh stamped last_seen")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("issued before tokens_valid_after", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "refresh-cutoff", nil)
|
||||||
|
if err := e.gdb.Exec(`UPDATE users SET tokens_valid_after = ? WHERE id = ?`, time.Now().Truncate(time.Second), user.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertRefreshRefused(t, e.refresh(t, token(t, user, time.Now().Add(-10*time.Second))))
|
||||||
|
})
|
||||||
|
t.Run("suspended", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "refresh-suspended", nil)
|
||||||
|
now := time.Now()
|
||||||
|
e.seedThrottle(t, user, "203.0.113.50", false, &now)
|
||||||
|
if rec := e.refresh(t, token(t, user, time.Now())); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("suspended refresh = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("normal", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "refresh-normal", nil)
|
||||||
|
if rec := e.refresh(t, token(t, user, time.Now())); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("refresh = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if lastSeen(t, user) == nil {
|
||||||
|
t.Fatal("refresh did not stamp last_seen")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
305
password_routes_test.go
Normal file
305
password_routes_test.go
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
)
|
||||||
|
|
||||||
|
const passwordPublicRoutesKey = "golem15.user.password.public_routes"
|
||||||
|
|
||||||
|
// passwordRouteKeys are the registrations golem15.user.password.public_routes
|
||||||
|
// false leaves out.
|
||||||
|
var passwordRouteKeys = map[string]bool{
|
||||||
|
http.MethodPost + " /_user/api/v1/login": true,
|
||||||
|
http.MethodPost + " /_user/api/v1/forgot-password": true,
|
||||||
|
http.MethodPost + " /_user/api/v1/reset-password": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordRoutesOn records the routes the plugin registers on app and returns
|
||||||
|
// them with the Routes error.
|
||||||
|
func recordRoutesOn(t *testing.T, app *backpack.App) ([]recordedRoute, error) {
|
||||||
|
t.Helper()
|
||||||
|
rec := newRecordingRouter()
|
||||||
|
err := (&Plugin{app: app}).Routes(rec)
|
||||||
|
return *rec.routes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordRoutesApp is a bareApp with golem15.user.password.public_routes set to value.
|
||||||
|
func passwordRoutesApp(t *testing.T, value any) *backpack.App {
|
||||||
|
t.Helper()
|
||||||
|
app := bareApp(t)
|
||||||
|
if err := app.Config.Set(passwordPublicRoutesKey, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutPasswordRoutes returns routes without POST /login, /forgot-password and /reset-password.
|
||||||
|
func withoutPasswordRoutes(routes []recordedRoute) []recordedRoute {
|
||||||
|
out := make([]recordedRoute, 0, len(routes))
|
||||||
|
for _, route := range routes {
|
||||||
|
if !passwordRouteKeys[route.method+" "+route.path] {
|
||||||
|
out = append(out, route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertSameRoutes requires got to equal want route by route (method, path, middleware, order).
|
||||||
|
func assertSameRoutes(t *testing.T, got, want []recordedRoute) {
|
||||||
|
t.Helper()
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("%d routes, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
g, w := got[i], want[i]
|
||||||
|
if g.method != w.method || g.path != w.path {
|
||||||
|
t.Fatalf("route %d is %s %s, want %s %s", i, g.method, g.path, w.method, w.path)
|
||||||
|
}
|
||||||
|
if strings.Join(g.middleware, ",") != strings.Join(w.middleware, ",") {
|
||||||
|
t.Errorf("%s %s middleware %v, want %v", g.method, g.path, g.middleware, w.middleware)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertLikeUnknown requires a 404 with the status, body and Content-Type of unknown and no cookie.
|
||||||
|
func assertLikeUnknown(t *testing.T, got, unknown *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
if unknown.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("unknown path = %d %s, want 404", unknown.Code, unknown.Body.String())
|
||||||
|
}
|
||||||
|
if got.Code != unknown.Code || got.Body.String() != unknown.Body.String() {
|
||||||
|
t.Fatalf("answer %d %q, want %d %q", got.Code, got.Body.String(), unknown.Code, unknown.Body.String())
|
||||||
|
}
|
||||||
|
if g, u := got.Header().Get("Content-Type"), unknown.Header().Get("Content-Type"); g != u {
|
||||||
|
t.Fatalf("Content-Type %q, want %q", g, u)
|
||||||
|
}
|
||||||
|
assertNoCookie(t, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasswordPublicRoutes pins golem15.user.password.public_routes on the
|
||||||
|
// route table: unset, true and the config.yaml default register every route;
|
||||||
|
// false drops exactly POST /login, /forgot-password and /reset-password and
|
||||||
|
// nothing else, the turnstile guard staying on /register; an invalid value
|
||||||
|
// fails Routes naming the key.
|
||||||
|
func TestPasswordPublicRoutes(t *testing.T) {
|
||||||
|
base, err := recordRoutesOn(t, bareApp(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))
|
||||||
|
}
|
||||||
|
seen := 0
|
||||||
|
for _, route := range base {
|
||||||
|
if passwordRouteKeys[route.method+" "+route.path] {
|
||||||
|
seen++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seen != len(passwordRouteKeys) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
want []recordedRoute
|
||||||
|
}{
|
||||||
|
{name: "true", value: true, want: base},
|
||||||
|
{name: "true string", value: "true", want: base},
|
||||||
|
{name: "upper-case true string", value: "TRUE", want: base},
|
||||||
|
{name: "empty means default", value: "", want: base},
|
||||||
|
{name: "false", value: false, want: off},
|
||||||
|
{name: "false string", value: "false", want: off},
|
||||||
|
{name: "padded false string", value: " false ", want: off},
|
||||||
|
{name: "mixed-case false string", value: "False", want: off},
|
||||||
|
} {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got, err := recordRoutesOn(t, passwordRoutesApp(t, c.value))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Routes error %v", err)
|
||||||
|
}
|
||||||
|
assertSameRoutes(t, got, c.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("config.yaml default", func(t *testing.T) {
|
||||||
|
app := bareApp(t)
|
||||||
|
if err := app.Config.MergePlugin("golem15.user", configFS); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v, ok := app.Config.Lookup(passwordPublicRoutesKey); !ok || v != true {
|
||||||
|
t.Fatalf("config.yaml default = %#v (set %v), want true", v, ok)
|
||||||
|
}
|
||||||
|
got, err := recordRoutesOn(t, app)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Routes error %v", err)
|
||||||
|
}
|
||||||
|
assertSameRoutes(t, got, base)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("env false", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-bare\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{
|
||||||
|
"SUMMER_ENV=development",
|
||||||
|
"SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false",
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := recordRoutesOn(t, backpack.New(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Routes error %v", err)
|
||||||
|
}
|
||||||
|
assertSameRoutes(t, got, off)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("false with turnstile closed", func(t *testing.T) {
|
||||||
|
app := passwordRoutesApp(t, false)
|
||||||
|
if err := app.Config.Set("golem15.user.turnstile", "closed"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := recordRoutesOn(t, app)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Routes error %v", err)
|
||||||
|
}
|
||||||
|
want := make([]recordedRoute, len(off))
|
||||||
|
for i, route := range off {
|
||||||
|
want[i] = route
|
||||||
|
if route.method == http.MethodPost && route.path == "/_user/api/v1/register" {
|
||||||
|
want[i].middleware = append(append([]string{}, route.middleware...), "turnstile:closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertSameRoutes(t, got, want)
|
||||||
|
for _, route := range got {
|
||||||
|
if names := turnstileOf(route); len(names) > 0 && route.path != "/_user/api/v1/register" {
|
||||||
|
t.Errorf("%s %s carries %v", route.method, route.path, names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid", func(t *testing.T) {
|
||||||
|
for _, bad := range []any{"nope", "yes", "1", "0", "t", "f", 0} {
|
||||||
|
if _, err := recordRoutesOn(t, passwordRoutesApp(t, bad)); err == nil || !strings.Contains(err.Error(), passwordPublicRoutesKey) {
|
||||||
|
t.Errorf("%#v: error %v, want one naming %s", bad, err, passwordPublicRoutesKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasswordPublicRoutesAnswer404 pins that, with
|
||||||
|
// golem15.user.password.public_routes false, the dropped routes answer
|
||||||
|
// exactly like an unknown path through the assembled router, while the
|
||||||
|
// default answers are unchanged and /register, /fetch, /refresh and /logout
|
||||||
|
// keep working.
|
||||||
|
func TestPasswordPublicRoutesAnswer404(t *testing.T) {
|
||||||
|
const unknownPath = "/_user/api/v1/no-such-route"
|
||||||
|
|
||||||
|
t.Run("default answers", func(t *testing.T) {
|
||||||
|
e := newAdminEnv(t, tokenCookieSettings(t, nil))
|
||||||
|
if v, ok := e.app.Config.Lookup(passwordPublicRoutesKey); !ok || v != true {
|
||||||
|
t.Fatalf("merged default = %#v (set %v), want true", v, ok)
|
||||||
|
}
|
||||||
|
if rec := e.call(t, http.MethodPost, unknownPath, "", map[string]string{}); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("unknown path = %d %s, want 404", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
user := e.seedUser(t, "public-routes-default", nil)
|
||||||
|
if rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "wrong-horse"}); rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
|
||||||
|
t.Fatalf("login = %d %s, want 401 %s", rec.Code, rec.Body.String(), invalidLoginBody)
|
||||||
|
}
|
||||||
|
rec := e.call(t, http.MethodPost, "/_user/api/v1/forgot-password", "", map[string]string{"email": user.Email})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("forgot-password = %d %s, want 200", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertBodyKeys(t, rec, "message")
|
||||||
|
if rec := e.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{}); rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("reset-password = %d %s, want 422", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := e.call(t, http.MethodGet, userLoginPath, "", nil); rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("GET login = %d %s, want 405", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
e := newAdminEnv(t, tokenCookieSettings(t, map[string]any{passwordPublicRoutesKey: false}))
|
||||||
|
|
||||||
|
t.Run("off answers like an unknown path", func(t *testing.T) {
|
||||||
|
unknown := e.call(t, http.MethodPost, unknownPath, "", map[string]string{})
|
||||||
|
unknownGet := e.call(t, http.MethodGet, unknownPath, "", nil)
|
||||||
|
user := e.seedUser(t, "public-routes-off", nil)
|
||||||
|
|
||||||
|
login := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"})
|
||||||
|
assertLikeUnknown(t, login, unknown)
|
||||||
|
|
||||||
|
forgot := e.call(t, http.MethodPost, "/_user/api/v1/forgot-password", "", map[string]string{"email": user.Email})
|
||||||
|
assertLikeUnknown(t, forgot, unknown)
|
||||||
|
|
||||||
|
code, err := classes.IssueResetCode(t.Context(), e.gdb, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reset := e.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{
|
||||||
|
"code": userSub(user) + "!" + code,
|
||||||
|
"password": "another-horse",
|
||||||
|
"password_confirmation": "another-horse",
|
||||||
|
})
|
||||||
|
assertLikeUnknown(t, reset, unknown)
|
||||||
|
|
||||||
|
assertLikeUnknown(t, e.call(t, http.MethodGet, userLoginPath, "", nil), unknownGet)
|
||||||
|
|
||||||
|
if got := e.reload(t, user.ID); got.LastSeen != nil {
|
||||||
|
t.Fatalf("last_seen stamped %v", *got.LastSeen)
|
||||||
|
}
|
||||||
|
if got := e.reload(t, user.ID); got.Password != user.Password {
|
||||||
|
t.Fatal("the password changed")
|
||||||
|
}
|
||||||
|
if sent := e.mails(user.Email); len(sent) != 0 {
|
||||||
|
t.Fatalf("mails sent %#v", sent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("off keeps the rest", func(t *testing.T) {
|
||||||
|
rec := e.call(t, http.MethodPost, userRegisterPath, "", registerBody("public-routes-register"))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("register = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := assertBodyKeys(t, rec, "token", "user")
|
||||||
|
token, _ := body["token"].(string)
|
||||||
|
if token == "" {
|
||||||
|
t.Fatalf("register token %#v", body["token"])
|
||||||
|
}
|
||||||
|
if code := e.fetchCode(t, token); code != http.StatusOK {
|
||||||
|
t.Fatalf("fetch = %d", code)
|
||||||
|
}
|
||||||
|
refreshed := e.refresh(t, token)
|
||||||
|
if refreshed.Code != http.StatusOK {
|
||||||
|
t.Fatalf("refresh = %d %s", refreshed.Code, refreshed.Body.String())
|
||||||
|
}
|
||||||
|
rotated, _ := assertBodyKeys(t, refreshed, "token")["token"].(string)
|
||||||
|
if rotated == "" {
|
||||||
|
t.Fatal("refresh returned no token")
|
||||||
|
}
|
||||||
|
if rec := e.call(t, http.MethodPost, "/_user/api/v1/logout", rotated, nil); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("logout = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := e.call(t, http.MethodGet, "/_user/api/v1/oauth-providers", "", nil); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("oauth-providers = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
85
plugin.go
85
plugin.go
@@ -6,7 +6,6 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -71,10 +70,21 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if gdb, ok := app.Lookup[*gorm.DB](); ok {
|
// Serve publishes the database after Boot, so the GORM hooks, the
|
||||||
|
// blacklist store and its sweep are installed on lagoon.Publish, or at
|
||||||
|
// once when the database is already published.
|
||||||
|
if err := lagoon.OnDatabase(app, func(sqlDB *sql.DB, gdb *gorm.DB) error {
|
||||||
if err := classes.RegisterHooks(gdb); err != nil {
|
if err := classes.RegisterHooks(gdb); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
store := bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist")
|
||||||
|
if err := app.Publish[bouncer.BlacklistStore](store); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.startBlacklistSweep(store)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
reg, ok := app.Lookup[*bouncer.Registry]()
|
reg, ok := app.Lookup[*bouncer.Registry]()
|
||||||
if !ok || reg == nil {
|
if !ok || reg == nil {
|
||||||
@@ -83,21 +93,7 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var bl bouncer.BlacklistStore
|
if err := reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, &appBlacklist{app: app}, classes.SessionCookieNames()...)); err != nil {
|
||||||
if sqlDB, ok := app.Lookup[*sql.DB](); ok && sqlDB != nil {
|
|
||||||
bl = bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist")
|
|
||||||
} else if gdb, ok := app.Lookup[*gorm.DB](); ok && gdb != nil {
|
|
||||||
if sqlDB, err := gdb.DB(); err == nil && sqlDB != nil {
|
|
||||||
bl = bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bl != nil {
|
|
||||||
if err := app.Publish[bouncer.BlacklistStore](bl); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
p.startBlacklistSweep(bl)
|
|
||||||
}
|
|
||||||
if err := reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, "token", "auth_token")); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
prefix, err := classes.APITokenPrefix(app)
|
prefix, err := classes.APITokenPrefix(app)
|
||||||
@@ -105,10 +101,57 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
trusted := surf.TrustedProxies(app.Config)
|
trusted := surf.TrustedProxies(app.Config)
|
||||||
return reg.Register(p.ID(), "api_token", &usermiddleware.APITokenGuard{
|
if err := reg.Register(p.ID(), "api_token", &usermiddleware.APITokenGuard{
|
||||||
App: app, Prefix: prefix,
|
App: app, Prefix: prefix,
|
||||||
ClientIP: func(r *http.Request) string { return surf.ClientIP(r, trusted) },
|
ClientIP: func(r *http.Request) string { return surf.ClientIP(r, trusted) },
|
||||||
})
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.bootBackendLinks(app); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.bootMCPGuard(app); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return classes.RegisterUserAuthorizer(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// plugin's personal API tokens. Without the MCP module both stay unused.
|
||||||
|
func (p *Plugin) bootMCPGuard(app *backpack.App) error {
|
||||||
|
guards, ok := app.Lookup[*pact.MCPGuards]()
|
||||||
|
if !ok || guards == nil {
|
||||||
|
guards = pact.NewMCPGuards()
|
||||||
|
if err := app.Publish(guards); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard := &classes.MCPUserGuard{App: app}
|
||||||
|
if err := guards.Register(guard); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.Publish[pact.MCPPersonalTokenResolver](guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootBackendLinks publishes the account link service as *classes.BackendLinks
|
||||||
|
// and as the MCP linked-backend resolver, and removes a frontend user's link
|
||||||
|
// when the user is deleted or deactivated. The request lifetime is checked
|
||||||
|
// here so a bad golem15.user.backend_links.request_ttl fails the boot.
|
||||||
|
func (p *Plugin) bootBackendLinks(app *backpack.App) error {
|
||||||
|
if _, err := classes.BackendLinkRequestTTL(app); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
links := &classes.BackendLinks{App: app}
|
||||||
|
if err := app.Publish(links); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := app.Publish[pact.MCPLinkedBackendResolver](links); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
links.RegisterBackendLinkListeners(app)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) {
|
func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) {
|
||||||
@@ -135,7 +178,7 @@ func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) {
|
|||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := bl.Sweep(context.Background(), time.Now()); err != nil {
|
if err := bl.Sweep(context.Background(), time.Now()); err != nil {
|
||||||
slog.Default().Error("golem15.user: jwt blacklist sweep", "err", err)
|
pluginLog.Error("golem15.user: jwt blacklist sweep", "err", err)
|
||||||
}
|
}
|
||||||
case <-stop:
|
case <-stop:
|
||||||
return
|
return
|
||||||
@@ -203,7 +246,7 @@ func (p *Plugin) MailLayouts() map[string]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Commands() []bonfire.Command {
|
func (p *Plugin) Commands() []bonfire.Command {
|
||||||
return append([]bonfire.Command{console.RequirePasswordChange(p.withDB)}, console.APITokenCommands(p.app, p.withDB)...)
|
return append([]bonfire.Command{console.RequirePasswordChange(p.withDB), console.BackfillHandles(p.app)}, console.APITokenCommands(p.app, p.withDB)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// withDB runs fn with the application's database: the one the serve command
|
// withDB runs fn with the application's database: the one the serve command
|
||||||
|
|||||||
@@ -82,6 +82,29 @@ func TestChangePassword(t *testing.T) {
|
|||||||
t.Fatalf("same password %d %s", same.Code, same.Body.String())
|
t.Fatalf("same password %d %s", same.Code, same.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PHP OAuthPasswordBootstrapTest::test_real_password_account_still_requires_current_password:
|
||||||
|
// a self-set password still needs current_password, and a wrong one is
|
||||||
|
// refused with the unchanged invalid_login body; the password stays.
|
||||||
|
t.Run("real_password_account_still_requires_current_password", func(t *testing.T) {
|
||||||
|
missing := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token, `{"password":"another-password-2","password_confirmation":"another-password-2"}`)
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(missing.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
errs, _ := body["errors"].(map[string]any)
|
||||||
|
if missing.Code != http.StatusUnprocessableEntity || errs["current_password"] == nil {
|
||||||
|
t.Fatalf("missing current_password %d %s", missing.Code, missing.Body.String())
|
||||||
|
}
|
||||||
|
wrong := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token, `{"current_password":"definitely-wrong","password":"another-password-2","password_confirmation":"another-password-2"}`)
|
||||||
|
if wrong.Code != http.StatusUnprocessableEntity || wrong.Body.String() != `{"error":"Invalid email or password","errors":{"current_password":["Invalid email or password"]}}` {
|
||||||
|
t.Fatalf("wrong current_password %d %s", wrong.Code, wrong.Body.String())
|
||||||
|
}
|
||||||
|
again := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"change@example.test","password":"brand-new-password"}`)
|
||||||
|
if again.Code != http.StatusOK {
|
||||||
|
t.Fatalf("the password changed: login %d %s", again.Code, again.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
locked := insertUser(t, gdb, "locked-password@example.test", "correct-horse")
|
locked := insertUser(t, gdb, "locked-password@example.test", "correct-horse")
|
||||||
if err := gdb.Model(locked).UpdateColumn("has_self_set_password", false).Error; err != nil {
|
if err := gdb.Model(locked).UpdateColumn("has_self_set_password", false).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
261
refresh_cookie_test.go
Normal file
261
refresh_cookie_test.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
)
|
||||||
|
|
||||||
|
// opaque500Body is the plugin's opaque 500 answer (wire.WriteOpaque500).
|
||||||
|
const opaque500Body = `{"error":true,"message":"Internal server error"}`
|
||||||
|
|
||||||
|
// TestRefreshRewritesAuthCookie pins the cookie-carried POST /refresh: when
|
||||||
|
// the token came from the auth.cookie cookie with no bearer header, that
|
||||||
|
// cookie is rewritten with the rotated token and the body is empty, whoever
|
||||||
|
// wrote the cookie; bearer and other-cookie refreshes stay body-only;
|
||||||
|
// refusals touch no cookie; and a broken auth.cookie block fails a cookie
|
||||||
|
// refresh before it rotates anything.
|
||||||
|
func TestRefreshRewritesAuthCookie(t *testing.T) {
|
||||||
|
e := newAdminEnv(t)
|
||||||
|
|
||||||
|
t.Run("token cookie rotates", func(t *testing.T) {
|
||||||
|
old := e.sessionLogin(t, "refresh-cookie-rotates")
|
||||||
|
rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "token", old, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("refresh with the token cookie = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertBodyKeys(t, rec)
|
||||||
|
next := assertSessionCookie(t, rec, e.cookieConfig(t), 20160*60)
|
||||||
|
if next == old {
|
||||||
|
t.Fatal("the rewritten cookie carries the old token")
|
||||||
|
}
|
||||||
|
sub := parseToken(t, old)["sub"]
|
||||||
|
if got := parseToken(t, next)["sub"]; got != sub {
|
||||||
|
t.Fatalf("rotated token names %v, old one %v", got, sub)
|
||||||
|
}
|
||||||
|
fetch := e.cookieSend(t, http.MethodGet, userFetchPath, "token", next, nil)
|
||||||
|
if fetch.Code != http.StatusOK {
|
||||||
|
t.Fatalf("fetch with the rewritten cookie = %d %s", fetch.Code, fetch.Body.String())
|
||||||
|
}
|
||||||
|
if got := fetchedID(t, fetch); got != sub {
|
||||||
|
t.Fatalf("fetch served user %q, token names %v", got, sub)
|
||||||
|
}
|
||||||
|
if code := e.guarded(t, next, "token"); code != http.StatusOK {
|
||||||
|
t.Fatalf("jwt.auth with the rewritten cookie = %d", code)
|
||||||
|
}
|
||||||
|
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, "token", old, nil); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("fetch with the rotated-away token cookie = %d", rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("auth_token cookie stays body-only", func(t *testing.T) {
|
||||||
|
old := e.sessionLogin(t, "refresh-cookie-auth-token")
|
||||||
|
rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "auth_token", old, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("refresh with the auth_token cookie = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := assertBodyKeys(t, rec, "token")
|
||||||
|
if next, _ := body["token"].(string); next == "" || next == old {
|
||||||
|
t.Fatalf("refresh answered %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoCookie(t, rec)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bearer wins over the cookie", func(t *testing.T) {
|
||||||
|
a := e.sessionLogin(t, "refresh-cookie-bearer-a")
|
||||||
|
b := e.sessionLogin(t, "refresh-cookie-bearer-b")
|
||||||
|
rec := e.send(t, http.MethodPost, userRefreshPath, a, nil, "application/json", map[string]string{"Cookie": "token=" + b})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("refresh with bearer A and cookie B = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := assertBodyKeys(t, rec, "token")
|
||||||
|
next, _ := body["token"].(string)
|
||||||
|
if got, want := parseToken(t, next)["sub"], parseToken(t, a)["sub"]; got != want {
|
||||||
|
t.Fatalf("refresh rotated user %v, bearer names %v", got, want)
|
||||||
|
}
|
||||||
|
assertNoCookie(t, rec)
|
||||||
|
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, "token", b, nil); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("fetch with cookie B after a bearer refresh = %d", rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bearer only", func(t *testing.T) {
|
||||||
|
token := e.sessionLogin(t, "refresh-cookie-bearer-only")
|
||||||
|
rec := e.refresh(t, token)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("bearer refresh = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertBodyKeys(t, rec, "token")
|
||||||
|
assertNoCookie(t, rec)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("configured auth.cookie name", func(t *testing.T) {
|
||||||
|
custom := newAdminEnv(t, func(c *compass.Config) {
|
||||||
|
for key, value := range map[string]any{
|
||||||
|
"auth.cookie.name": "auth_token",
|
||||||
|
"golem15.user.jwt.refresh_ttl": 60,
|
||||||
|
} {
|
||||||
|
if err := c.Set(key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
old := custom.sessionLogin(t, "refresh-cookie-custom")
|
||||||
|
rec := custom.cookieSend(t, http.MethodPost, userRefreshPath, "auth_token", old, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("refresh with the auth_token cookie = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertBodyKeys(t, rec)
|
||||||
|
next := assertSessionCookie(t, rec, custom.cookieConfig(t), 3600)
|
||||||
|
if next == old {
|
||||||
|
t.Fatal("the rewritten cookie carries the old token")
|
||||||
|
}
|
||||||
|
if rec := custom.cookieSend(t, http.MethodGet, userFetchPath, "auth_token", next, nil); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("fetch with the rewritten auth_token cookie = %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
other := custom.sessionLogin(t, "refresh-cookie-custom-token")
|
||||||
|
rec = custom.cookieSend(t, http.MethodPost, userRefreshPath, "token", other, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("refresh with the token cookie = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertBodyKeys(t, rec, "token")
|
||||||
|
assertNoCookie(t, rec)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refusals set no cookie", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "refresh-cookie-banned", nil)
|
||||||
|
e.seedThrottle(t, user, "", true, nil)
|
||||||
|
banned := userAudienceToken(t, userSub(user), "refresh-cookie-banned-"+adminStamp(), time.Now(), time.Now().Add(time.Hour))
|
||||||
|
rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "token", banned, nil)
|
||||||
|
assertRefreshRefused(t, rec)
|
||||||
|
assertNoCookie(t, rec)
|
||||||
|
|
||||||
|
old := e.sessionLogin(t, "refresh-cookie-twice")
|
||||||
|
if rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "token", old, nil); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first refresh = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
rec = e.cookieSend(t, http.MethodPost, userRefreshPath, "token", old, nil)
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
|
||||||
|
t.Fatalf("second refresh of the same token = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoCookie(t, rec)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("broken auth.cookie", func(t *testing.T) {
|
||||||
|
broken := newAdminEnv(t, func(c *compass.Config) {
|
||||||
|
if err := c.Set("auth.cookie.same_site", "sideways"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
token := broken.sessionLogin(t, "refresh-cookie-broken")
|
||||||
|
rec := broken.cookieSend(t, http.MethodPost, userRefreshPath, "token", token, nil)
|
||||||
|
if rec.Code != http.StatusInternalServerError || rec.Body.String() != opaque500Body {
|
||||||
|
t.Fatalf("cookie refresh with a broken auth.cookie = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoCookie(t, rec)
|
||||||
|
rec = broken.refresh(t, token)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("bearer refresh after the 500 = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if next, _ := assertBodyKeys(t, rec, "token")["token"].(string); next == "" {
|
||||||
|
t.Fatalf("bearer refresh answered %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionTokenCookie is the carrier table of classes.SessionTokenCookie:
|
||||||
|
// it names the session cookie SessionToken read the token from, and "" when
|
||||||
|
// the bearer header supplied it or there is none.
|
||||||
|
func TestSessionTokenCookie(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, auth, cookie, want string
|
||||||
|
}{
|
||||||
|
{"bearer plus a token cookie", "Bearer A", "token=B", ""},
|
||||||
|
{"empty bearer plus a token cookie", "Bearer ", "token=B", "token"},
|
||||||
|
{"basic auth plus a token cookie", "Basic xyz", "token=B", "token"},
|
||||||
|
{"only auth_token", "", "auth_token=C", "auth_token"},
|
||||||
|
{"token and auth_token", "", "token=B; auth_token=C", "token"},
|
||||||
|
{"empty token cookie", "", "token=; auth_token=X", "auth_token"},
|
||||||
|
{"no credentials", "", "", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, userRefreshPath, nil)
|
||||||
|
if tc.auth != "" {
|
||||||
|
r.Header.Set("Authorization", tc.auth)
|
||||||
|
}
|
||||||
|
if tc.cookie != "" {
|
||||||
|
r.Header.Set("Cookie", tc.cookie)
|
||||||
|
}
|
||||||
|
got := classes.SessionTokenCookie(r)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("SessionTokenCookie = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
token := classes.SessionToken(r)
|
||||||
|
if got != "" {
|
||||||
|
c, err := r.Cookie(got)
|
||||||
|
if err != nil || strings.TrimSpace(c.Value) != token {
|
||||||
|
t.Fatalf("cookie %s does not carry SessionToken %q", got, token)
|
||||||
|
}
|
||||||
|
} else if token != "" && !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer "+token) {
|
||||||
|
t.Fatalf("SessionToken %q did not come from the bearer header", token)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if got := classes.SessionTokenCookie(nil); got != "" {
|
||||||
|
t.Fatalf("SessionTokenCookie(nil) = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertSessionCookie requires exactly one cookie, the session cookie cfg
|
||||||
|
// writes with Max-Age maxAge, and returns its value.
|
||||||
|
func assertSessionCookie(t *testing.T, rec *httptest.ResponseRecorder, cfg bouncer.CookieConfig, maxAge int) string {
|
||||||
|
t.Helper()
|
||||||
|
cookies := rec.Result().Cookies()
|
||||||
|
if len(cookies) != 1 {
|
||||||
|
t.Fatalf("want one session cookie, got %v", rec.Result().Header.Values("Set-Cookie"))
|
||||||
|
}
|
||||||
|
c, want := cookies[0], cfg.TokenCookie("x", time.Hour)
|
||||||
|
if c.Name != cfg.Name || c.Value == "" || c.Path != want.Path || c.Domain != want.Domain ||
|
||||||
|
c.SameSite != want.SameSite || c.Secure != want.Secure || c.HttpOnly != want.HttpOnly || c.MaxAge != maxAge {
|
||||||
|
t.Fatalf("session cookie %s does not match %s with Max-Age %d", c, want, maxAge)
|
||||||
|
}
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoCookie requires a response that sets no cookie.
|
||||||
|
func assertNoCookie(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
if cookies := rec.Result().Cookies(); len(cookies) != 0 || rec.Header().Get("Set-Cookie") != "" {
|
||||||
|
t.Fatalf("response sets cookies %v", rec.Result().Header.Values("Set-Cookie"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertBodyKeys decodes the JSON object body and requires exactly keys.
|
||||||
|
func assertBodyKeys(t *testing.T, rec *httptest.ResponseRecorder, keys ...string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body == nil {
|
||||||
|
t.Fatalf("body %s is not a JSON object: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
got := make([]string, 0, len(body))
|
||||||
|
for key := range body {
|
||||||
|
got = append(got, key)
|
||||||
|
}
|
||||||
|
slices.Sort(got)
|
||||||
|
want := slices.Clone(keys)
|
||||||
|
slices.Sort(want)
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("body keys %v, want %v: %s", got, want, rec.Body.String())
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -248,6 +249,102 @@ func TestRegisterEventPayload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRegisterListenerErrors: failing register and created listeners are
|
||||||
|
// logged and neither fail nor undo the registration.
|
||||||
|
func TestRegisterListenerErrors(t *testing.T) {
|
||||||
|
app, gdb := sessionApp(t, "0s")
|
||||||
|
disableRegisterThrottle(t, app)
|
||||||
|
app.Events.Listen[*classes.RegisterEvent]("failing-register", func(context.Context, *classes.RegisterEvent) error {
|
||||||
|
return errors.New("register listener down")
|
||||||
|
})
|
||||||
|
app.Events.Listen[*classes.CreatedEvent]("failing-created", func(context.Context, *classes.CreatedEvent) error {
|
||||||
|
return errors.New("created listener down")
|
||||||
|
})
|
||||||
|
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"listener-fail@example.test","password":"correct-horse"}`)
|
||||||
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"token"`) {
|
||||||
|
t.Fatalf("register %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := gdb.Model(&models.User{}).Where("email = ?", "listener-fail@example.test").Count(&count).Error; err != nil || count != 1 {
|
||||||
|
t.Fatalf("stored users = %d, %v", count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterIgnoresProtectedFields: a registration body sets only the
|
||||||
|
// RegisterFillable columns. Organisation membership, the IP columns and the
|
||||||
|
// account flags in the body are ignored, over HTTP and through RegisterUser,
|
||||||
|
// and the IP columns come only from the server-side address.
|
||||||
|
func TestRegisterIgnoresProtectedFields(t *testing.T) {
|
||||||
|
app, gdb := sessionApp(t, "0s")
|
||||||
|
disableRegisterThrottle(t, app)
|
||||||
|
org := &models.Organisation{Name: "Mass Assign Org", Slug: "mass-assign-org"}
|
||||||
|
if err := gdb.Create(org).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
protected := func(email string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"email": email, "password": "correct-horse", "password_confirmation": "correct-horse", "name": "Mal", "surname": "Lory",
|
||||||
|
"preferred_locale": "pl", "marketing_consent": true,
|
||||||
|
"organisation_id": org.ID, "organisation_role": "owner",
|
||||||
|
"created_ip_address": "203.0.113.9", "last_ip_address": "203.0.113.9",
|
||||||
|
"is_onboarded": true, "must_change_password": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check := func(t *testing.T, email string, wantIP *string) {
|
||||||
|
t.Helper()
|
||||||
|
var stored models.User
|
||||||
|
if err := gdb.Where("email = ?", email).Take(&stored).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.Name == nil || *stored.Name != "Mal" || stored.Surname == nil || *stored.Surname != "Lory" ||
|
||||||
|
stored.PreferredLocale == nil || *stored.PreferredLocale != "pl" || !stored.MarketingConsent {
|
||||||
|
t.Fatalf("allowed columns not stored: %+v", stored)
|
||||||
|
}
|
||||||
|
if stored.OrganisationID != nil || stored.OrganisationRole != nil {
|
||||||
|
t.Fatalf("organisation from the body: id %v role %v", stored.OrganisationID, stored.OrganisationRole)
|
||||||
|
}
|
||||||
|
if stored.IsOnboarded || stored.MustChangePassword {
|
||||||
|
t.Fatalf("account flags from the body: onboarded %v must_change %v", stored.IsOnboarded, stored.MustChangePassword)
|
||||||
|
}
|
||||||
|
for name, got := range map[string]*string{"created_ip_address": stored.CreatedIPAddress, "last_ip_address": stored.LastIPAddress} {
|
||||||
|
switch {
|
||||||
|
case wantIP == nil && got != nil:
|
||||||
|
t.Fatalf("%s = %q, want NULL", name, *got)
|
||||||
|
case wantIP != nil && (got == nil || *got != *wantIP):
|
||||||
|
t.Fatalf("%s = %v, want %q", name, got, *wantIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("http", func(t *testing.T) {
|
||||||
|
body, err := json.Marshal(protected("mass-http@example.test"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := postRegister(t, app, "192.0.2.44", string(body))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("register %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
ip := "192.0.2.44"
|
||||||
|
check(t, "mass-http@example.test", &ip)
|
||||||
|
})
|
||||||
|
t.Run("RegisterUser with IP", func(t *testing.T) {
|
||||||
|
_, errs, err := controllers.RegisterUser(t.Context(), app, gdb, protected("mass-ip@example.test"), controllers.RegisterOptions{IP: "198.51.100.7"})
|
||||||
|
if err != nil || len(errs) != 0 {
|
||||||
|
t.Fatalf("RegisterUser = %v, %v", errs, err)
|
||||||
|
}
|
||||||
|
ip := "198.51.100.7"
|
||||||
|
check(t, "mass-ip@example.test", &ip)
|
||||||
|
})
|
||||||
|
t.Run("RegisterUser without IP", func(t *testing.T) {
|
||||||
|
_, errs, err := controllers.RegisterUser(t.Context(), app, gdb, protected("mass-noip@example.test"), controllers.RegisterOptions{})
|
||||||
|
if err != nil || len(errs) != 0 {
|
||||||
|
t.Fatalf("RegisterUser = %v, %v", errs, err)
|
||||||
|
}
|
||||||
|
check(t, "mass-noip@example.test", nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegisterImportDirection(t *testing.T) {
|
func TestRegisterImportDirection(t *testing.T) {
|
||||||
cmd := exec.Command("go", "list", "-deps", "./...")
|
cmd := exec.Command("go", "list", "-deps", "./...")
|
||||||
cmd.Dir = moduleRoot(t)
|
cmd.Dir = moduleRoot(t)
|
||||||
|
|||||||
79
routes.go
79
routes.go
@@ -1,21 +1,36 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"git.golem15.com/golem15/summercms/modules/surf"
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (p *Plugin) Routes(r pact.Router) error {
|
func (p *Plugin) Routes(r pact.Router) error {
|
||||||
|
guard, err := p.turnstileGuard()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
public, err := p.publicPasswordRoutes()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) {
|
r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) {
|
||||||
g.Post("/login", controllers.Login(p.app))
|
if public {
|
||||||
|
g.Post("/login", controllers.Login(p.app), guard...)
|
||||||
|
}
|
||||||
g.Post("/logout", controllers.Logout(p.app))
|
g.Post("/logout", controllers.Logout(p.app))
|
||||||
g.Get("/fetch", controllers.Fetch(p.app))
|
g.Get("/fetch", controllers.Fetch(p.app))
|
||||||
g.Post("/refresh", controllers.Refresh(p.app))
|
g.Post("/refresh", controllers.Refresh(p.app))
|
||||||
g.Post("/register", controllers.Register(p.app))
|
g.Post("/register", controllers.Register(p.app), guard...)
|
||||||
g.Get("/oauth-providers", controllers.OAuthProviders(p.app))
|
g.Get("/oauth-providers", controllers.OAuthProviders(p.app))
|
||||||
g.Post("/forgot-password", controllers.ForgotPassword(p.app))
|
if public {
|
||||||
g.Post("/reset-password", controllers.ResetPassword(p.app))
|
g.Post("/forgot-password", controllers.ForgotPassword(p.app))
|
||||||
|
g.Post("/reset-password", controllers.ResetPassword(p.app))
|
||||||
|
}
|
||||||
g.Post("/activate", controllers.Activate(p.app))
|
g.Post("/activate", controllers.Activate(p.app))
|
||||||
g.Post("/activate-by-code", controllers.ActivateByCode(p.app))
|
g.Post("/activate-by-code", controllers.ActivateByCode(p.app))
|
||||||
g.Post("/update", controllers.Update(p.app))
|
g.Post("/update", controllers.Update(p.app))
|
||||||
@@ -27,6 +42,62 @@ func (p *Plugin) Routes(r pact.Router) error {
|
|||||||
g.Get("/tokens", controllers.APITokenIndex(p.app), "jwt.auth")
|
g.Get("/tokens", controllers.APITokenIndex(p.app), "jwt.auth")
|
||||||
g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth")
|
g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth")
|
||||||
g.Where("id", "[0-9]+")
|
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/confirm", controllers.BackendLinkConfirm(p.app), "jwt.auth")
|
||||||
|
g.Get("/token/me", controllers.TokenMe(p.app), "user.api_token", "user.scope:read")
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// turnstileGuard returns the middleware that golem15.user.turnstile adds to
|
||||||
|
// POST /login and /register. The key is opt-in: empty (the default) adds
|
||||||
|
// nothing, "open" and "closed" select the framework's turnstile:{mode}
|
||||||
|
// middleware (summercms v0.3.0 and later), and any other value is refused so
|
||||||
|
// a typo cannot silently leave the forms unprotected.
|
||||||
|
func (p *Plugin) turnstileGuard() ([]string, error) {
|
||||||
|
if p.app == nil || p.app.Config == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
switch mode := strings.TrimSpace(p.app.Config.String("golem15.user.turnstile")); mode {
|
||||||
|
case "":
|
||||||
|
return nil, nil
|
||||||
|
case "open", "closed":
|
||||||
|
return []string{"turnstile:" + mode}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("golem15.user: golem15.user.turnstile must be empty, open or closed, got %q", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicPasswordRoutes reports golem15.user.password.public_routes, which
|
||||||
|
// decides whether POST /login, /forgot-password and /reset-password are
|
||||||
|
// registered. The default is true. False leaves them unregistered, so they
|
||||||
|
// answer the router's own 404 like any unknown path. The key is read when the
|
||||||
|
// routes are built, so a change needs a restart. It takes a bool or the
|
||||||
|
// string true or false (any case, surrounding spaces trimmed; empty or unset
|
||||||
|
// means true); any other value is refused so a typo cannot silently remove
|
||||||
|
// (or keep) the sign-in routes.
|
||||||
|
func (p *Plugin) publicPasswordRoutes() (bool, error) {
|
||||||
|
if p.app == nil || p.app.Config == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
const key = "golem15.user.password.public_routes"
|
||||||
|
raw, ok := p.app.Config.Lookup(key)
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case bool:
|
||||||
|
return v, nil
|
||||||
|
case string:
|
||||||
|
switch s := strings.TrimSpace(v); {
|
||||||
|
case s == "", strings.EqualFold(s, "true"):
|
||||||
|
return true, nil
|
||||||
|
case strings.EqualFold(s, "false"):
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("golem15.user: %s must be true or false, got %q", key, v)
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("golem15.user: %s must be true or false, got %v", key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
114
same_second_cutoff_test.go
Normal file
114
same_second_cutoff_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPasswordCutoffSameSecond pins the whole-second tokens_valid_after
|
||||||
|
// cutoff of POST /reset-password and of the admin Users password set: JWT
|
||||||
|
// iat has one-second resolution, so a token minted in the second of the
|
||||||
|
// reset is accepted on every check site (the jwt guard, /fetch, /refresh)
|
||||||
|
// and a token from an earlier second is refused.
|
||||||
|
func TestPasswordCutoffSameSecond(t *testing.T) {
|
||||||
|
e := newAdminEnv(t)
|
||||||
|
|
||||||
|
t.Run("reset-password", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "cutoff-reset", nil)
|
||||||
|
code, err := classes.IssueResetCode(t.Context(), e.gdb, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const next = "reset-password-same-second"
|
||||||
|
waitForFreshSecond(t)
|
||||||
|
rec := e.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{
|
||||||
|
"code": strconv.FormatUint(uint64(user.ID), 10) + "!" + code,
|
||||||
|
"password": next,
|
||||||
|
"password_confirmation": next,
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Password has been reset"}` {
|
||||||
|
t.Fatalf("reset = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
got := e.reload(t, user.ID)
|
||||||
|
if got.EmailVerifiedAt == nil {
|
||||||
|
t.Fatal("reset did not record email_verified_at")
|
||||||
|
}
|
||||||
|
assertCutoffSameSecond(t, e, got, next)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("admin password set", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "cutoff-admin", nil)
|
||||||
|
token := e.admin(t, permAccessUsers)
|
||||||
|
const next = "admin-password-same-second"
|
||||||
|
waitForFreshSecond(t)
|
||||||
|
e.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{
|
||||||
|
"name": "Cutoff admin", "email": user.Email, "password": next, "password_confirmation": next,
|
||||||
|
})
|
||||||
|
assertCutoffSameSecond(t, e, e.reload(t, user.ID), next)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertCutoffSameSecond: the stored cutoff is a whole second; a token whose
|
||||||
|
// iat is that second works at jwt.auth, /fetch and /refresh, one issued a
|
||||||
|
// second earlier is refused at all three, and a login with password works.
|
||||||
|
func assertCutoffSameSecond(t *testing.T, e *adminEnv, user *models.User, password string) {
|
||||||
|
t.Helper()
|
||||||
|
if user.TokensValidAfter == nil {
|
||||||
|
t.Fatal("tokens_valid_after is not set")
|
||||||
|
}
|
||||||
|
cutoff := *user.TokensValidAfter
|
||||||
|
if !cutoff.Equal(cutoff.Truncate(time.Second)) {
|
||||||
|
t.Fatalf("tokens_valid_after %s is not a whole second", cutoff.Format(time.RFC3339Nano))
|
||||||
|
}
|
||||||
|
sub := userSub(user)
|
||||||
|
|
||||||
|
same := userAudienceToken(t, sub, "same-"+adminStamp(), cutoff, cutoff.Add(time.Hour))
|
||||||
|
if code := e.guarded(t, same, ""); code != http.StatusOK {
|
||||||
|
t.Fatalf("jwt.auth with iat = cutoff: %d", code)
|
||||||
|
}
|
||||||
|
if code := e.fetchCode(t, same); code != http.StatusOK {
|
||||||
|
t.Fatalf("fetch with iat = cutoff: %d", code)
|
||||||
|
}
|
||||||
|
// Refresh last: it rotates and revokes the token.
|
||||||
|
if rec := e.refresh(t, same); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("refresh with iat = cutoff: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
earlier := cutoff.Add(-time.Second)
|
||||||
|
old := userAudienceToken(t, sub, "earlier-"+adminStamp(), earlier, cutoff.Add(time.Hour))
|
||||||
|
if code := e.guarded(t, old, ""); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("jwt.auth with iat a second before the cutoff: %d", code)
|
||||||
|
}
|
||||||
|
if code := e.fetchCode(t, old); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("fetch with iat a second before the cutoff: %d", code)
|
||||||
|
}
|
||||||
|
assertRefreshRefused(t, e.refresh(t, old))
|
||||||
|
|
||||||
|
status, token := e.userLogin(t, user.Email, password)
|
||||||
|
if status != http.StatusOK || token == "" {
|
||||||
|
t.Fatalf("login after the password change = %d", status)
|
||||||
|
}
|
||||||
|
if code := e.guarded(t, token, ""); code != http.StatusOK {
|
||||||
|
t.Fatalf("jwt.auth with the fresh login token: %d", code)
|
||||||
|
}
|
||||||
|
if code := e.fetchCode(t, token); code != http.StatusOK {
|
||||||
|
t.Fatalf("fetch with the fresh login token: %d", code)
|
||||||
|
}
|
||||||
|
if iat, err := parseToken(t, token).GetIssuedAt(); err == nil && iat != nil {
|
||||||
|
t.Logf("login iat %d, cutoff %d, same second: %v", iat.Unix(), cutoff.Unix(), iat.Unix() == cutoff.Unix())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForFreshSecond sleeps until 10ms after the next whole second, so the
|
||||||
|
// password change, the reload and the login usually fall within one second.
|
||||||
|
// The deterministic proof is the crafted-token pair, not this alignment.
|
||||||
|
func waitForFreshSecond(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now()
|
||||||
|
time.Sleep(now.Truncate(time.Second).Add(time.Second + 10*time.Millisecond).Sub(now))
|
||||||
|
}
|
||||||
81
serve_order_test.go
Normal file
81
serve_order_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBootBeforeDatabase pins logout revocation when Boot runs before the
|
||||||
|
// database is published, as under summer serve: nothing database-backed is
|
||||||
|
// installed before lagoon.Publish, the Postgres blacklist store and the GORM
|
||||||
|
// hooks are installed when it runs, and a token ended by POST /logout is
|
||||||
|
// refused on jwt.auth through every carrier, at /fetch and at /refresh, with
|
||||||
|
// its jwt_blacklist row.
|
||||||
|
func TestBootBeforeDatabase(t *testing.T) {
|
||||||
|
// The classes hook registrants are process-global and cannot be removed,
|
||||||
|
// so the test hook is gated and goes inert once this test ends.
|
||||||
|
var hooked atomic.Pointer[gorm.DB]
|
||||||
|
var active atomic.Bool
|
||||||
|
active.Store(true)
|
||||||
|
t.Cleanup(func() { active.Store(false) })
|
||||||
|
classes.RegisterHook(func(gdb *gorm.DB) error {
|
||||||
|
if active.Load() {
|
||||||
|
hooked.Store(gdb)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
e := newServeOrderEnv(t, func(app *backpack.App) {
|
||||||
|
if _, ok := app.Lookup[*gorm.DB](); ok {
|
||||||
|
t.Fatal("a *gorm.DB is published before lagoon.Publish")
|
||||||
|
}
|
||||||
|
if _, ok := app.Lookup[bouncer.BlacklistStore](); ok {
|
||||||
|
t.Fatal("a bouncer.BlacklistStore is published before lagoon.Publish")
|
||||||
|
}
|
||||||
|
if hooked.Load() != nil {
|
||||||
|
t.Fatal("the GORM hooks ran before lagoon.Publish")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
store, ok := e.app.Lookup[bouncer.BlacklistStore]()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no bouncer.BlacklistStore published after lagoon.Publish")
|
||||||
|
}
|
||||||
|
if _, ok := store.(*bouncer.PostgresBlacklist); !ok {
|
||||||
|
t.Fatalf("published blacklist store is %T, want *bouncer.PostgresBlacklist", store)
|
||||||
|
}
|
||||||
|
if got := hooked.Load(); got != e.gdb {
|
||||||
|
t.Fatalf("the GORM hooks ran with %p, want the published handle %p", got, e.gdb)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := e.sessionLogin(t, "serve-order")
|
||||||
|
if code := e.guarded(t, token, ""); code != http.StatusOK {
|
||||||
|
t.Fatalf("jwt.auth before logout (bearer) = %d", code)
|
||||||
|
}
|
||||||
|
for _, name := range classes.SessionCookieNames() {
|
||||||
|
if code := e.guarded(t, token, name); code != http.StatusOK {
|
||||||
|
t.Fatalf("jwt.auth before logout (cookie %q) = %d", name, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertLoggedOut(t, e.call(t, http.MethodPost, userLogoutPath, token, nil))
|
||||||
|
e.assertSessionEnded(t, token)
|
||||||
|
|
||||||
|
jti, _ := parseToken(t, token)["jti"].(string)
|
||||||
|
if jti == "" {
|
||||||
|
t.Fatal("session token has no jti")
|
||||||
|
}
|
||||||
|
var rows int64
|
||||||
|
if err := e.gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, jti).Scan(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rows != 1 {
|
||||||
|
t.Fatalf("jwt_blacklist rows for %s = %d, want 1", jti, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
263
session_cookie_test.go
Normal file
263
session_cookie_test.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
userFetchPath = "/_user/api/v1/fetch"
|
||||||
|
userUpdatePath = "/_user/api/v1/update"
|
||||||
|
userChangePasswordPath = "/_user/api/v1/change-password"
|
||||||
|
userConsentPath = "/_user/api/v1/marketing-consent"
|
||||||
|
tokenNotFoundBody = `{"error":"Token not found"}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionCookieNames are the cookies the plugin's jwt guard reads after the
|
||||||
|
// Authorization header.
|
||||||
|
var sessionCookieNames = []string{"token", "auth_token"}
|
||||||
|
|
||||||
|
// TestSessionRoutesAcceptCookies: GET /fetch, POST /refresh, POST /update and
|
||||||
|
// POST /change-password (and the other signed-in routes behind the shared
|
||||||
|
// authenticate) read the session token as the jwt guard does: the bearer
|
||||||
|
// header first, then the token and auth_token cookies. A cookie-only session
|
||||||
|
// that jwt.auth accepts is served; no credentials stays 401 with the old
|
||||||
|
// bodies; a bearer header wins over a cookie exactly as on jwt.auth; a
|
||||||
|
// cookie-carried token is refused for the same reasons as a bearer one; a
|
||||||
|
// /refresh through the token cookie (the auth.cookie default) gets that
|
||||||
|
// cookie back with the rotated token and an empty body, and one through
|
||||||
|
// auth_token gets the rotated token in the body and no cookie.
|
||||||
|
func TestSessionRoutesAcceptCookies(t *testing.T) {
|
||||||
|
e := newAdminEnv(t)
|
||||||
|
|
||||||
|
for _, name := range sessionCookieNames {
|
||||||
|
t.Run("fetch via the "+name+" cookie", func(t *testing.T) {
|
||||||
|
token := e.sessionLogin(t, "cookie-fetch-"+name)
|
||||||
|
if code := e.guarded(t, token, name); code != http.StatusOK {
|
||||||
|
t.Fatalf("jwt.auth with the %s cookie = %d", name, code)
|
||||||
|
}
|
||||||
|
rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("fetch with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got, want := fetchedID(t, rec), parseToken(t, token)["sub"]; got != want {
|
||||||
|
t.Fatalf("fetch served user %q, token names %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("fetch without credentials", func(t *testing.T) {
|
||||||
|
rec := e.send(t, http.MethodGet, userFetchPath, "", nil, "application/json", nil)
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
|
||||||
|
t.Fatalf("fetch without credentials = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bearer wins", func(t *testing.T) {
|
||||||
|
a := e.sessionLogin(t, "cookie-bearer-a")
|
||||||
|
b := e.sessionLogin(t, "cookie-bearer-b")
|
||||||
|
cookie := map[string]string{"Cookie": "token=" + b}
|
||||||
|
rec := e.send(t, http.MethodGet, userFetchPath, a, nil, "application/json", cookie)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("fetch with bearer A and cookie B = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got, want := fetchedID(t, rec), parseToken(t, a)["sub"]; got != want {
|
||||||
|
t.Fatalf("fetch served user %q, bearer names %v", got, want)
|
||||||
|
}
|
||||||
|
rec = e.send(t, http.MethodGet, userFetchPath, "not-a-token", nil, "application/json", cookie)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("fetch with an invalid bearer and a valid cookie = %d", rec.Code)
|
||||||
|
}
|
||||||
|
rec = e.send(t, http.MethodGet, userTokensPath, "not-a-token", nil, "application/json", cookie)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("jwt.auth with an invalid bearer and a valid cookie = %d", rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fetch refuses a cut-off cookie token", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "cookie-cutoff", nil)
|
||||||
|
e.cutOff(t, user)
|
||||||
|
token := userAudienceToken(t, userSub(user), "cookie-cutoff-"+adminStamp(), time.Now().Add(-10*time.Second), time.Now().Add(time.Hour))
|
||||||
|
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, "auth_token", token, nil); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("fetch with a cut-off auth_token cookie = %d", rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, name := range sessionCookieNames {
|
||||||
|
t.Run("refresh via the "+name+" cookie", func(t *testing.T) {
|
||||||
|
token := e.sessionLogin(t, "cookie-refresh-"+name)
|
||||||
|
if code := e.guarded(t, token, name); code != http.StatusOK {
|
||||||
|
t.Fatalf("jwt.auth with the %s cookie = %d", name, code)
|
||||||
|
}
|
||||||
|
rec := e.cookieSend(t, http.MethodPost, userRefreshPath, name, token, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("refresh with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var next string
|
||||||
|
if name == "token" {
|
||||||
|
if rec.Body.String() != `{}` {
|
||||||
|
t.Fatalf("refresh with the token cookie answered %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
next = assertSessionCookie(t, rec, e.cookieConfig(t), 20160*60)
|
||||||
|
} else {
|
||||||
|
var body struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
next = body.Token
|
||||||
|
if cookies := rec.Result().Cookies(); len(cookies) != 0 {
|
||||||
|
t.Fatalf("refresh set cookies %v", cookies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next == "" || next == token {
|
||||||
|
t.Fatalf("refresh answered %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if code := e.fetchCode(t, next); code != http.StatusOK {
|
||||||
|
t.Fatalf("fetch with the refreshed token = %d", code)
|
||||||
|
}
|
||||||
|
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("fetch with the refreshed-away token in the %s cookie = %d", name, rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("refresh refuses a banned user through the cookie", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "cookie-refresh-banned", nil)
|
||||||
|
e.seedThrottle(t, user, "", true, nil)
|
||||||
|
token := userAudienceToken(t, userSub(user), "cookie-banned-"+adminStamp(), time.Now(), time.Now().Add(time.Hour))
|
||||||
|
assertRefreshRefused(t, e.cookieSend(t, http.MethodPost, userRefreshPath, "token", token, nil))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refresh refuses a cut-off token through the cookie", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "cookie-refresh-cutoff", nil)
|
||||||
|
e.cutOff(t, user)
|
||||||
|
token := userAudienceToken(t, userSub(user), "cookie-refresh-cutoff-"+adminStamp(), time.Now().Add(-10*time.Second), time.Now().Add(time.Hour))
|
||||||
|
assertRefreshRefused(t, e.cookieSend(t, http.MethodPost, userRefreshPath, "token", token, nil))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refresh without credentials", func(t *testing.T) {
|
||||||
|
rec := e.send(t, http.MethodPost, userRefreshPath, "", nil, "application/json", nil)
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != tokenNotFoundBody {
|
||||||
|
t.Fatalf("refresh without credentials = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, name := range sessionCookieNames {
|
||||||
|
t.Run("update via the "+name+" cookie", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "cookie-update-"+name, nil)
|
||||||
|
token := e.login(t, user, "correct-horse")
|
||||||
|
if code := e.guarded(t, token, name); code != http.StatusOK {
|
||||||
|
t.Fatalf("jwt.auth with the %s cookie = %d", name, code)
|
||||||
|
}
|
||||||
|
rec := e.cookieSend(t, http.MethodPost, userUpdatePath, name, token, map[string]any{
|
||||||
|
"name": "Cookie", "surname": "Carrier", "email": user.Email,
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := e.reload(t, user.ID).Name; got == nil || *got != "Cookie" {
|
||||||
|
t.Fatalf("name after update = %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range sessionCookieNames {
|
||||||
|
t.Run("change-password via the "+name+" cookie", func(t *testing.T) {
|
||||||
|
user := e.seedUser(t, "cookie-password-"+name, func(u *models.User) { u.HasSelfSetPassword = true })
|
||||||
|
token := e.login(t, user, "correct-horse")
|
||||||
|
if code := e.guarded(t, token, name); code != http.StatusOK {
|
||||||
|
t.Fatalf("jwt.auth with the %s cookie = %d", name, code)
|
||||||
|
}
|
||||||
|
rec := e.cookieSend(t, http.MethodPost, userChangePasswordPath, name, token, map[string]any{
|
||||||
|
"current_password": "correct-horse",
|
||||||
|
"password": "cookie-next-pass",
|
||||||
|
"password_confirmation": "cookie-next-pass",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("change-password with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("fetch with the calling token after change-password = %d", rec.Code)
|
||||||
|
}
|
||||||
|
if code, _ := e.userLogin(t, user.Email, "cookie-next-pass"); code != http.StatusOK {
|
||||||
|
t.Fatalf("login with the new password = %d", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("marketing-consent via the token cookie", func(t *testing.T) {
|
||||||
|
token := e.sessionLogin(t, "cookie-consent")
|
||||||
|
rec := e.cookieSend(t, http.MethodPost, userConsentPath, "token", token, map[string]any{"marketing_consent": true})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("marketing-consent with the token cookie = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, path := range []string{userUpdatePath, userChangePasswordPath} {
|
||||||
|
t.Run(path+" without credentials", func(t *testing.T) {
|
||||||
|
rec := e.send(t, http.MethodPost, path, "", []byte(`{}`), "application/json", nil)
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
|
||||||
|
t.Fatalf("%s without credentials = %d %s", path, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookieSend sends one request with no bearer header and the session token
|
||||||
|
// only in the named cookie. body is JSON-encoded unless it is nil.
|
||||||
|
func (e *adminEnv) cookieSend(t *testing.T, method, path, cookie, token string, body any) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var raw []byte
|
||||||
|
if body != nil {
|
||||||
|
var err error
|
||||||
|
if raw, err = json.Marshal(body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e.send(t, method, path, "", raw, "application/json", map[string]string{"Cookie": cookie + "=" + token})
|
||||||
|
}
|
||||||
|
|
||||||
|
// login signs user in through POST /login and returns the session token.
|
||||||
|
func (e *adminEnv) login(t *testing.T, user *models.User, password string) string {
|
||||||
|
t.Helper()
|
||||||
|
code, token := e.userLogin(t, user.Email, password)
|
||||||
|
if code != http.StatusOK || token == "" {
|
||||||
|
t.Fatalf("login %s = %d", user.Email, code)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// cutOff sets the user's tokens_valid_after to now, truncated to the second.
|
||||||
|
func (e *adminEnv) cutOff(t *testing.T, user *models.User) {
|
||||||
|
t.Helper()
|
||||||
|
if err := e.gdb.Exec(`UPDATE users SET tokens_valid_after = ? WHERE id = ?`, time.Now().Truncate(time.Second), user.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func userSub(user *models.User) string {
|
||||||
|
return strconv.FormatUint(uint64(user.ID), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchedID returns user.id of a /fetch answer as a decimal string.
|
||||||
|
func fetchedID(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||||
|
t.Helper()
|
||||||
|
var body struct {
|
||||||
|
User struct {
|
||||||
|
ID json.Number `json:"id"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("fetch body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return fmt.Sprint(body.User.ID)
|
||||||
|
}
|
||||||
237
session_test.go
237
session_test.go
@@ -57,7 +57,7 @@ func TestLogin(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
user := &models.User{Email: "ada-login@example.test", Password: hash}
|
user := &models.User{Email: "ada-login@example.test", Password: hash, IsActivated: true}
|
||||||
if err := gdb.Create(user).Error; err != nil {
|
if err := gdb.Create(user).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ func TestLogin(t *testing.T) {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
ip := "192.0.2.1"
|
ip := "192.0.2.1"
|
||||||
throttled := &models.User{Email: "throttled@example.test", Password: hash}
|
throttled := &models.User{Email: "throttled@example.test", Password: hash, IsActivated: true}
|
||||||
if err := gdb.Create(throttled).Error; err != nil {
|
if err := gdb.Create(throttled).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func TestLogin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loc := "en"
|
loc := "en"
|
||||||
soft := &models.User{Email: "soft@example.test", Password: hash, PreferredLocale: &loc}
|
soft := &models.User{Email: "soft@example.test", Password: hash, IsActivated: true, PreferredLocale: &loc}
|
||||||
if err := gdb.Create(soft).Error; err != nil {
|
if err := gdb.Create(soft).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ func TestLoginSixthAttempt(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
user := &models.User{Email: "sixth@example.test", Password: hash}
|
user := &models.User{Email: "sixth@example.test", Password: hash, IsActivated: true}
|
||||||
if err := gdb.Create(user).Error; err != nil {
|
if err := gdb.Create(user).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -192,6 +192,201 @@ func TestLoginSixthAttempt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoginRequiresActivation pins the activation gate of POST /login:
|
||||||
|
// 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,
|
||||||
|
// and an account that is also deactivated stays deactivated. With the key
|
||||||
|
// off, and for activated accounts, login and the deactivation restore work
|
||||||
|
// as before.
|
||||||
|
func TestLoginRequiresActivation(t *testing.T) {
|
||||||
|
hash, err := bouncer.HashPassword(10, "correct-horse")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
newApp := func(t *testing.T, require *bool) (*backpack.App, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
app, gdb := sessionApp(t, "0s")
|
||||||
|
if require != nil {
|
||||||
|
if err := app.Config.Set("golem15.user.activation.require_activation", *require); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return app, gdb
|
||||||
|
}
|
||||||
|
on, off := true, false
|
||||||
|
create := func(t *testing.T, gdb *gorm.DB, email string, activated, deleted bool) *models.User {
|
||||||
|
t.Helper()
|
||||||
|
loc := "en"
|
||||||
|
user := &models.User{Email: email, Password: hash, IsActivated: activated, PreferredLocale: &loc}
|
||||||
|
if err := gdb.Create(user).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if deleted {
|
||||||
|
if err := gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
login := func(app *backpack.App, email, password string) *httptest.ResponseRecorder {
|
||||||
|
return postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"`+email+`","password":"`+password+`"}`)
|
||||||
|
}
|
||||||
|
reload := func(t *testing.T, gdb *gorm.DB, id uint) models.User {
|
||||||
|
t.Helper()
|
||||||
|
var u models.User
|
||||||
|
if err := gdb.Unscoped().First(&u, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
attempts := func(t *testing.T, gdb *gorm.DB, id uint) models.Throttle {
|
||||||
|
t.Helper()
|
||||||
|
var row models.Throttle
|
||||||
|
if err := gdb.Where("user_id = ?", id).Take(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
refused := func(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
|
||||||
|
t.Fatalf("want the invalid-login 401, got %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signedIn := func(t *testing.T, rec *httptest.ResponseRecorder, user *models.User) {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token, _ := body["token"].(string)
|
||||||
|
if token == "" {
|
||||||
|
t.Fatalf("no token in %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if sub := parseToken(t, token)["sub"]; sub != strconv.FormatUint(uint64(user.ID), 10) {
|
||||||
|
t.Fatalf("sub = %v", sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mailer := func(t *testing.T, app *backpack.App) *captureMail {
|
||||||
|
t.Helper()
|
||||||
|
mail := &captureMail{}
|
||||||
|
if err := app.Publish[postcard.Mailer](mail); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return mail
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("default_refuses", func(t *testing.T) {
|
||||||
|
app, gdb := newApp(t, nil)
|
||||||
|
user := create(t, gdb, "act-default@example.test", false, false)
|
||||||
|
rec := login(app, user.Email, "correct-horse")
|
||||||
|
refused(t, rec)
|
||||||
|
if strings.Contains(rec.Body.String(), "token") {
|
||||||
|
t.Fatalf("refusal carries a token: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := reload(t, gdb, user.ID); got.LastSeen != nil {
|
||||||
|
t.Fatalf("refusal stamped last_seen %v", got.LastSeen)
|
||||||
|
}
|
||||||
|
if row := attempts(t, gdb, user.ID); row.Attempts != 1 {
|
||||||
|
t.Fatalf("throttle after refusal: %+v", row)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("same_as_wrong_password", func(t *testing.T) {
|
||||||
|
app, gdb := newApp(t, nil)
|
||||||
|
user := create(t, gdb, "act-same@example.test", false, false)
|
||||||
|
right := login(app, user.Email, "correct-horse")
|
||||||
|
if row := attempts(t, gdb, user.ID); row.Attempts != 1 {
|
||||||
|
t.Fatalf("throttle after the correct password: %+v", row)
|
||||||
|
}
|
||||||
|
wrong := login(app, user.Email, "wrong-horse")
|
||||||
|
if row := attempts(t, gdb, user.ID); row.Attempts != 2 {
|
||||||
|
t.Fatalf("throttle after the wrong password: %+v", row)
|
||||||
|
}
|
||||||
|
refused(t, right)
|
||||||
|
if right.Code != wrong.Code || right.Body.String() != wrong.Body.String() {
|
||||||
|
t.Fatalf("correct %d %s, wrong %d %s", right.Code, right.Body.String(), wrong.Code, wrong.Body.String())
|
||||||
|
}
|
||||||
|
for _, h := range []string{"Content-Type", "Cache-Control"} {
|
||||||
|
if a, b := right.Header().Get(h), wrong.Header().Get(h); a != b || a == "" {
|
||||||
|
t.Fatalf("%s: correct %q, wrong %q", h, a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("explicit_true_refuses", func(t *testing.T) {
|
||||||
|
app, gdb := newApp(t, &on)
|
||||||
|
user := create(t, gdb, "act-true@example.test", false, false)
|
||||||
|
refused(t, login(app, user.Email, "correct-horse"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refusals_suspend", func(t *testing.T) {
|
||||||
|
app, gdb := newApp(t, nil)
|
||||||
|
user := create(t, gdb, "act-suspend@example.test", false, false)
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
refused(t, login(app, user.Email, "correct-horse"))
|
||||||
|
}
|
||||||
|
if row := attempts(t, gdb, user.ID); row.Attempts != 5 || !row.IsSuspended {
|
||||||
|
t.Fatalf("after 5 refusals: %+v", row)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("off_allows", func(t *testing.T) {
|
||||||
|
app, gdb := newApp(t, &off)
|
||||||
|
user := create(t, gdb, "act-off@example.test", false, false)
|
||||||
|
signedIn(t, login(app, user.Email, "correct-horse"), user)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("activated_allows", func(t *testing.T) {
|
||||||
|
app, gdb := newApp(t, nil)
|
||||||
|
user := create(t, gdb, "act-yes@example.test", true, false)
|
||||||
|
signedIn(t, login(app, user.Email, "correct-horse"), user)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deactivated_activated_restores", func(t *testing.T) {
|
||||||
|
app, gdb := newApp(t, nil)
|
||||||
|
user := create(t, gdb, "act-restore@example.test", true, true)
|
||||||
|
mail := mailer(t, app)
|
||||||
|
signedIn(t, login(app, user.Email, "correct-horse"), user)
|
||||||
|
if got := reload(t, gdb, user.ID); got.DeletedAt.Valid {
|
||||||
|
t.Fatal("deactivated activated account was not restored")
|
||||||
|
}
|
||||||
|
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
|
||||||
|
t.Fatalf("reactivate template = %#v", mail.got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deactivated_inactive_refused", func(t *testing.T) {
|
||||||
|
app, gdb := newApp(t, nil)
|
||||||
|
user := create(t, gdb, "act-both@example.test", false, true)
|
||||||
|
mail := mailer(t, app)
|
||||||
|
refused(t, login(app, user.Email, "correct-horse"))
|
||||||
|
if got := reload(t, gdb, user.ID); !got.DeletedAt.Valid {
|
||||||
|
t.Fatal("refused account was restored")
|
||||||
|
}
|
||||||
|
if len(mail.got) != 0 {
|
||||||
|
t.Fatalf("refusal sent mail %#v", mail.got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deactivated_inactive_off_restores", func(t *testing.T) {
|
||||||
|
app, gdb := newApp(t, &off)
|
||||||
|
user := create(t, gdb, "act-both-off@example.test", false, true)
|
||||||
|
mail := mailer(t, app)
|
||||||
|
signedIn(t, login(app, user.Email, "correct-horse"), user)
|
||||||
|
if got := reload(t, gdb, user.ID); got.DeletedAt.Valid {
|
||||||
|
t.Fatal("account was not restored with require_activation off")
|
||||||
|
}
|
||||||
|
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
|
||||||
|
t.Fatalf("reactivate template = %#v", mail.got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestLogout(t *testing.T) {
|
func TestLogout(t *testing.T) {
|
||||||
app, gdb := sessionApp(t, "0s")
|
app, gdb := sessionApp(t, "0s")
|
||||||
token := loginToken(t, app, gdb, "logout@example.test")
|
token := loginToken(t, app, gdb, "logout@example.test")
|
||||||
@@ -361,6 +556,8 @@ func (c *captureMail) Send(_ context.Context, msg postcard.Message) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loginToken inserts an activated account with the password "correct-horse"
|
||||||
|
// (insertUser) and signs it in through POST /login, returning the token.
|
||||||
func loginToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) string {
|
func loginToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
insertUser(t, gdb, email, "correct-horse")
|
insertUser(t, gdb, email, "correct-horse")
|
||||||
@@ -379,19 +576,49 @@ func loginToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) str
|
|||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// insertUser creates an activated account with password, an ordinary
|
||||||
|
// account that can sign in through POST /login.
|
||||||
func insertUser(t *testing.T, gdb *gorm.DB, email, password string) *models.User {
|
func insertUser(t *testing.T, gdb *gorm.DB, email, password string) *models.User {
|
||||||
|
t.Helper()
|
||||||
|
return createUser(t, gdb, email, password, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertInactiveUser creates a not-activated account with password. While
|
||||||
|
// golem15.user.activation.require_activation is on (the default) POST /login
|
||||||
|
// refuses it.
|
||||||
|
func insertInactiveUser(t *testing.T, gdb *gorm.DB, email, password string) *models.User {
|
||||||
|
t.Helper()
|
||||||
|
return createUser(t, gdb, email, password, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createUser(t *testing.T, gdb *gorm.DB, email, password string, activated bool) *models.User {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
hash, err := bouncer.HashPassword(10, password)
|
hash, err := bouncer.HashPassword(10, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
user := &models.User{Email: email, Password: hash}
|
user := &models.User{Email: email, Password: hash, IsActivated: activated}
|
||||||
if err := gdb.Create(user).Error; err != nil {
|
if err := gdb.Create(user).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inactiveSessionToken inserts a not-activated account (insertInactiveUser)
|
||||||
|
// and mints its session with controllers.IssueToken, the session such an
|
||||||
|
// account gets from /register when require_activation is off.
|
||||||
|
func inactiveSessionToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) string {
|
||||||
|
t.Helper()
|
||||||
|
user := insertInactiveUser(t, gdb, email, "correct-horse")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/login", nil)
|
||||||
|
req.Host = "example.com"
|
||||||
|
token, err := controllers.IssueToken(app, user, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
func postJSON(t *testing.T, h http.HandlerFunc, path, body string) *httptest.ResponseRecorder {
|
func postJSON(t *testing.T, h http.HandlerFunc, path, body string) *httptest.ResponseRecorder {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||||
|
|||||||
299
session_user_test.go
Normal file
299
session_user_test.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// failingBlacklist fails every call, as an unreachable blacklist table does.
|
||||||
|
type failingBlacklist struct{}
|
||||||
|
|
||||||
|
var errBlacklistDown = errors.New("blacklist down")
|
||||||
|
|
||||||
|
func (failingBlacklist) Add(context.Context, string, time.Time, time.Time) error {
|
||||||
|
return errBlacklistDown
|
||||||
|
}
|
||||||
|
func (failingBlacklist) IsBlacklisted(context.Context, string) (bool, error) {
|
||||||
|
return false, errBlacklistDown
|
||||||
|
}
|
||||||
|
func (failingBlacklist) Sweep(context.Context, time.Time) error { return errBlacklistDown }
|
||||||
|
|
||||||
|
// sessionUserApp is an application without a database: the plugin's JWT secret
|
||||||
|
// and, when cookie is set, the 19.3 cookie settings.
|
||||||
|
func sessionUserApp(t *testing.T, environ ...string) *backpack.App {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: session-user\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: append([]string{"SUMMER_ENV=development"}, environ...)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return backpack.New(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionRequest is a GET carrying the session token the way kind says:
|
||||||
|
// "bearer", "token" (cookie) or "auth_token" (cookie).
|
||||||
|
func sessionRequest(kind, token string) *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/oauth/authorize", nil)
|
||||||
|
switch kind {
|
||||||
|
case "bearer":
|
||||||
|
r.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
case "token", "auth_token":
|
||||||
|
r.AddCookie(&http.Cookie{Name: kind, Value: token})
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestUser pins classes.RequestUser (D-05): the session JWT from the
|
||||||
|
// bearer header, then the token and auth_token cookies, checked like the
|
||||||
|
// plugin's jwt guard; every rejected token is anonymous and only an
|
||||||
|
// infrastructure failure is an error.
|
||||||
|
func TestRequestUser(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
issue := func(u *models.User) string {
|
||||||
|
t.Helper()
|
||||||
|
token, err := controllers.IssueToken(env.app, u, httptest.NewRequest(http.MethodPost, "https://app.example.test/login", nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
expectUser := func(name string, r *http.Request, want *models.User) {
|
||||||
|
t.Helper()
|
||||||
|
got, err := classes.RequestUser(ctx, env.app, r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: error %v", name, err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case want == nil && got != nil:
|
||||||
|
t.Fatalf("%s: signed in user %d, want anonymous", name, got.ID)
|
||||||
|
case want != nil && (got == nil || got.ID != want.ID || got.Email != want.Email):
|
||||||
|
t.Fatalf("%s: user %+v, want %d", name, got, want.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ada, bea := env.seedUser(t, "ada", nil), env.seedUser(t, "bea", nil)
|
||||||
|
adaToken, beaToken := issue(ada), issue(bea)
|
||||||
|
|
||||||
|
expectUser("bearer", sessionRequest("bearer", adaToken), ada)
|
||||||
|
expectUser("token cookie", sessionRequest("token", adaToken), ada)
|
||||||
|
expectUser("auth_token cookie", sessionRequest("auth_token", adaToken), ada)
|
||||||
|
both := sessionRequest("bearer", adaToken)
|
||||||
|
both.AddCookie(&http.Cookie{Name: "token", Value: beaToken})
|
||||||
|
expectUser("bearer before cookie", both, ada)
|
||||||
|
cookies := sessionRequest("token", beaToken)
|
||||||
|
cookies.AddCookie(&http.Cookie{Name: "auth_token", Value: adaToken})
|
||||||
|
expectUser("token before auth_token", cookies, bea)
|
||||||
|
blankBearer := sessionRequest("token", beaToken)
|
||||||
|
blankBearer.Header.Set("Authorization", "Bearer ")
|
||||||
|
expectUser("blank bearer falls back to the cookie", blankBearer, bea)
|
||||||
|
|
||||||
|
expectUser("anonymous", httptest.NewRequest(http.MethodGet, "/", nil), nil)
|
||||||
|
if u, err := classes.RequestUser(ctx, env.app, nil); u != nil || err != nil {
|
||||||
|
t.Fatalf("nil request: %v %v", u, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := strconv.FormatUint(uint64(ada.ID), 10)
|
||||||
|
expired, _, _ := bouncer.Mint(sessionSecret, sub, "https://app.example.test", -time.Minute)
|
||||||
|
expectUser("expired", sessionRequest("bearer", expired), nil)
|
||||||
|
forged, _, _ := bouncer.Mint("another-secret-of-the-right-length-xyz1", sub, "https://app.example.test", time.Hour)
|
||||||
|
expectUser("another secret", sessionRequest("bearer", forged), nil)
|
||||||
|
expectUser("garbage", sessionRequest("bearer", "not-a-jwt"), nil)
|
||||||
|
for _, s := range []string{"abc", "0", "-1"} {
|
||||||
|
odd, _, _ := bouncer.Mint(sessionSecret, s, "https://app.example.test", time.Hour)
|
||||||
|
expectUser("sub "+s, sessionRequest("bearer", odd), nil)
|
||||||
|
}
|
||||||
|
gone, _, _ := bouncer.Mint(sessionSecret, strconv.FormatUint(uint64(bea.ID+1_000_000), 10), "https://app.example.test", time.Hour)
|
||||||
|
expectUser("unknown user", sessionRequest("bearer", gone), nil)
|
||||||
|
|
||||||
|
// Blacklisted: the jti EndRequestSession revoked signs nobody in.
|
||||||
|
carl := env.seedUser(t, "carl", nil)
|
||||||
|
carlToken := issue(carl)
|
||||||
|
expectUser("carl before sign-out", sessionRequest("bearer", carlToken), carl)
|
||||||
|
if err := classes.EndRequestSession(ctx, env.app, httptest.NewRecorder(), sessionRequest("bearer", carlToken)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expectUser("blacklisted", sessionRequest("bearer", carlToken), nil)
|
||||||
|
|
||||||
|
// tokens_valid_after after the token's iat (a password change).
|
||||||
|
dora := env.seedUser(t, "dora", nil)
|
||||||
|
doraToken := issue(dora)
|
||||||
|
cutoff := time.Now().Add(time.Minute)
|
||||||
|
if err := env.gdb.Model(&models.User{}).Where("id = ?", dora.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expectUser("tokens_valid_after", sessionRequest("bearer", doraToken), nil)
|
||||||
|
|
||||||
|
// A soft-deleted (deactivated) user.
|
||||||
|
eve := env.seedUser(t, "eve", nil)
|
||||||
|
eveToken := issue(eve)
|
||||||
|
if err := env.gdb.Model(&models.User{}).Where("id = ?", eve.ID).Update("deleted_at", time.Now()).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expectUser("soft-deleted", sessionRequest("bearer", eveToken), nil)
|
||||||
|
|
||||||
|
// Only infrastructure failures are errors.
|
||||||
|
noDB := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
||||||
|
if u, err := classes.RequestUser(ctx, noDB, sessionRequest("bearer", adaToken)); u != nil || err == nil {
|
||||||
|
t.Fatalf("no database handle: %v %v", u, err)
|
||||||
|
}
|
||||||
|
noSecret := sessionUserApp(t)
|
||||||
|
if u, err := classes.RequestUser(ctx, noSecret, sessionRequest("bearer", adaToken)); u != nil || err == nil {
|
||||||
|
t.Fatalf("no JWT secret: %v %v", u, err)
|
||||||
|
}
|
||||||
|
if u, err := classes.RequestUser(ctx, noSecret, httptest.NewRequest(http.MethodGet, "/", nil)); u != nil || err != nil {
|
||||||
|
t.Fatalf("anonymous without a secret: %v %v", u, err)
|
||||||
|
}
|
||||||
|
broken := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
||||||
|
if err := broken.Publish(env.gdb); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := broken.Publish[bouncer.BlacklistStore](failingBlacklist{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if u, err := classes.RequestUser(ctx, broken, sessionRequest("bearer", adaToken)); u != nil || !errors.Is(err, errBlacklistDown) {
|
||||||
|
t.Fatalf("failing blacklist: %v %v", u, err)
|
||||||
|
}
|
||||||
|
canceled, cancel := context.WithCancel(ctx)
|
||||||
|
cancel()
|
||||||
|
if u, err := classes.RequestUser(canceled, env.app, sessionRequest("bearer", adaToken)); u != nil || err == nil {
|
||||||
|
t.Fatalf("failing query: %v %v", u, err)
|
||||||
|
}
|
||||||
|
// Without a published blacklist the token is accepted on its own checks.
|
||||||
|
plain := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
||||||
|
if err := plain.Publish[*gorm.DB](env.gdb); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if u, err := classes.RequestUser(ctx, plain, sessionRequest("bearer", adaToken)); err != nil || u == nil || u.ID != ada.ID {
|
||||||
|
t.Fatalf("without a blacklist: %v %v", u, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEndRequestSession pins classes.EndRequestSession (D-04, D-05) without
|
||||||
|
// a database: the session's jti is blacklisted until its refresh window
|
||||||
|
// closes (not only until the access token expires, or /refresh could revive
|
||||||
|
// it), the forget cookie carries the configured Domain and Path, a request
|
||||||
|
// without a token still forgets the cookie, and a blacklist failure never
|
||||||
|
// fails the call.
|
||||||
|
func TestEndRequestSession(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
app := sessionUserApp(t,
|
||||||
|
"SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret,
|
||||||
|
"SUMMER_AUTH__COOKIE__DOMAIN=app.example.test",
|
||||||
|
"SUMMER_AUTH__COOKIE__PATH=/app",
|
||||||
|
)
|
||||||
|
bl := bouncer.NewMemoryBlacklist()
|
||||||
|
if err := app.Publish[bouncer.BlacklistStore](bl); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forgot := func(t *testing.T, rec *httptest.ResponseRecorder, domain, path string) {
|
||||||
|
t.Helper()
|
||||||
|
res := rec.Result()
|
||||||
|
var found *http.Cookie
|
||||||
|
for _, c := range res.Cookies() {
|
||||||
|
if c.Name == bouncer.DefaultCookieName {
|
||||||
|
found = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil || found.Value != "" || found.MaxAge >= 0 || found.Domain != domain || found.Path != path {
|
||||||
|
t.Fatalf("forget cookie = %+v (Set-Cookie %q)", found, res.Header.Values("Set-Cookie"))
|
||||||
|
}
|
||||||
|
if rec.Body.Len() != 0 {
|
||||||
|
t.Fatalf("EndRequestSession wrote a body: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, kind := range []string{"bearer", "token", "auth_token"} {
|
||||||
|
token, jti, err := bouncer.Mint(sessionSecret, "7", "https://app.example.test", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if err := classes.EndRequestSession(ctx, app, rec, sessionRequest(kind, token)); err != nil {
|
||||||
|
t.Fatalf("%s: %v", kind, err)
|
||||||
|
}
|
||||||
|
forgot(t, rec, "app.example.test", "/app")
|
||||||
|
if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked {
|
||||||
|
t.Fatalf("%s: the jti is not blacklisted", kind)
|
||||||
|
}
|
||||||
|
// Past the token's expiry, until its refresh window closes, and no
|
||||||
|
// longer.
|
||||||
|
_ = bl.Sweep(ctx, time.Now().Add(time.Hour+2*time.Minute))
|
||||||
|
if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked {
|
||||||
|
t.Fatalf("%s: the entry left when the access token expired", kind)
|
||||||
|
}
|
||||||
|
_ = bl.Sweep(ctx, time.Now().Add(controllers.RefreshTTL(app)+2*time.Minute))
|
||||||
|
if blocked, _ := bl.IsBlacklisted(ctx, jti); blocked {
|
||||||
|
t.Fatalf("%s: the entry outlived the refresh window", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No token, a token of another secret, garbage: the cookie is forgotten
|
||||||
|
// and nothing is blacklisted.
|
||||||
|
foreign, foreignJTI, _ := bouncer.Mint("another-secret-of-the-right-length-xyz1", "7", "https://app.example.test", time.Hour)
|
||||||
|
for name, r := range map[string]*http.Request{
|
||||||
|
"no token": httptest.NewRequest(http.MethodPost, "/", nil),
|
||||||
|
"another secret": sessionRequest("bearer", foreign),
|
||||||
|
"garbage": sessionRequest("token", "not-a-jwt"),
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if err := classes.EndRequestSession(ctx, app, rec, r); err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
forgot(t, rec, "app.example.test", "/app")
|
||||||
|
}
|
||||||
|
if blocked, _ := bl.IsBlacklisted(ctx, foreignJTI); blocked {
|
||||||
|
t.Fatal("a token of another secret was blacklisted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blacklist failure is logged, never returned; the cookie still goes.
|
||||||
|
down := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
||||||
|
if err := down.Publish[bouncer.BlacklistStore](failingBlacklist{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token, _, _ := bouncer.Mint(sessionSecret, "7", "https://app.example.test", time.Hour)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if err := classes.EndRequestSession(ctx, down, rec, sessionRequest("bearer", token)); err != nil {
|
||||||
|
t.Fatalf("failing blacklist: %v", err)
|
||||||
|
}
|
||||||
|
forgot(t, rec, "", "/")
|
||||||
|
|
||||||
|
// Without an app or a writer it does what it can.
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
if err := classes.EndRequestSession(ctx, nil, rec, sessionRequest("bearer", token)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forgot(t, rec, "", "/")
|
||||||
|
if err := classes.EndRequestSession(ctx, app, nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// An invalid cookie configuration falls back to the defaults.
|
||||||
|
badCookie := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret, "SUMMER_AUTH__COOKIE__PATH=relative")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
if err := classes.EndRequestSession(ctx, badCookie, rec, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forgot(t, rec, "", "/")
|
||||||
|
if !strings.Contains(strings.Join(rec.Result().Header.Values("Set-Cookie"), ";"), "token=") {
|
||||||
|
t.Fatal("no Set-Cookie for the token cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
86
social_hooks_smoke_test.go
Normal file
86
social_hooks_smoke_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/postcard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSocialHooksSmoke drives the helpers a social-login plugin calls: a
|
||||||
|
// password-less registration and FinishLogin on a soft-deleted account.
|
||||||
|
func TestSocialHooksSmoke(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Postgres-backed; skipped under -short")
|
||||||
|
}
|
||||||
|
app, gdb := sessionApp(t, "0s")
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
fields := map[string]any{
|
||||||
|
"email": "social-hooks@example.test",
|
||||||
|
"password": "random-password-never-shown",
|
||||||
|
"password_confirmation": "random-password-never-shown",
|
||||||
|
}
|
||||||
|
user, errs, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{
|
||||||
|
Activate: true, IP: "203.0.113.7", PasswordLess: true,
|
||||||
|
})
|
||||||
|
if err != nil || len(errs) > 0 {
|
||||||
|
t.Fatalf("RegisterUser: %v %v", err, errs)
|
||||||
|
}
|
||||||
|
var stored models.User
|
||||||
|
if err := gdb.First(&stored, user.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.HasSelfSetPassword {
|
||||||
|
t.Fatal("a password-less registration stored has_self_set_password=true")
|
||||||
|
}
|
||||||
|
if !stored.IsActivated {
|
||||||
|
t.Fatal("the registration was not activated")
|
||||||
|
}
|
||||||
|
if stored.Handle == nil || *stored.Handle == "" {
|
||||||
|
t.Fatal("the registration got no handle")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishLogin restores a soft-deleted account, records the IP, stamps
|
||||||
|
// last_seen and sends exactly one reactivate mail.
|
||||||
|
loc := "en"
|
||||||
|
soft := &models.User{Email: "social-soft@example.test", Password: "x", PreferredLocale: &loc}
|
||||||
|
if err := gdb.Create(soft).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gdb.Model(soft).Update("deleted_at", time.Now()).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var trashed models.User
|
||||||
|
if err := gdb.Unscoped().First(&trashed, soft.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !trashed.DeletedAt.Valid {
|
||||||
|
t.Fatal("setup: the user is not soft-deleted")
|
||||||
|
}
|
||||||
|
mail := &captureMail{}
|
||||||
|
if err := app.Publish[postcard.Mailer](mail); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := controllers.FinishLogin(ctx, app, gdb, &trashed, "198.51.100.4"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var after models.User
|
||||||
|
if err := gdb.Unscoped().First(&after, soft.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after.DeletedAt.Valid {
|
||||||
|
t.Fatal("FinishLogin did not restore the account")
|
||||||
|
}
|
||||||
|
if after.LastIPAddress == nil || *after.LastIPAddress != "198.51.100.4" {
|
||||||
|
t.Fatalf("last_ip_address = %v", after.LastIPAddress)
|
||||||
|
}
|
||||||
|
if after.LastSeen == nil {
|
||||||
|
t.Fatal("FinishLogin did not stamp last_seen")
|
||||||
|
}
|
||||||
|
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
|
||||||
|
t.Fatalf("mails = %#v", mail.got)
|
||||||
|
}
|
||||||
|
}
|
||||||
332
social_hooks_test.go
Normal file
332
social_hooks_test.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// skipShortPostgres skips a Postgres-backed test under -short (the phase
|
||||||
|
// gate runs these by name without -short on a Docker host).
|
||||||
|
func skipShortPostgres(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Postgres-backed; skipped under -short")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hookEmail is a unique address for one test.
|
||||||
|
func hookEmail(prefix string) string {
|
||||||
|
return fmt.Sprintf("%s-%d@example.test", prefix, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerFields(email string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"email": email,
|
||||||
|
"name": "Social Person",
|
||||||
|
"password": "random-password-never-shown",
|
||||||
|
"password_confirmation": "random-password-never-shown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterUserPasswordLess pins RegisterOptions.PasswordLess (D-22): the
|
||||||
|
// zero value keeps has_self_set_password true, true stores false; the handle
|
||||||
|
// is assigned either way; validation errors are the same with and without
|
||||||
|
// it and nothing is written.
|
||||||
|
func TestRegisterUserPasswordLess(t *testing.T) {
|
||||||
|
skipShortPostgres(t)
|
||||||
|
app, gdb := sessionApp(t, "0s")
|
||||||
|
ctx := t.Context()
|
||||||
|
stored := func(t *testing.T, id uint) models.User {
|
||||||
|
t.Helper()
|
||||||
|
var u models.User
|
||||||
|
if err := gdb.First(&u, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("password_less_false_keeps_self_set", func(t *testing.T) {
|
||||||
|
u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail("pwless-false")), controllers.RegisterOptions{Activate: true})
|
||||||
|
if err != nil || len(errs) > 0 {
|
||||||
|
t.Fatalf("RegisterUser: %v %v", err, errs)
|
||||||
|
}
|
||||||
|
if got := stored(t, u.ID); !got.HasSelfSetPassword || !u.HasSelfSetPassword {
|
||||||
|
t.Fatalf("has_self_set_password stored %v (returned %v), want true", got.HasSelfSetPassword, u.HasSelfSetPassword)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("password_less_true_stores_false", func(t *testing.T) {
|
||||||
|
u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail("pwless-true")), controllers.RegisterOptions{Activate: true, PasswordLess: true, IP: "198.18.7.1"})
|
||||||
|
if err != nil || len(errs) > 0 {
|
||||||
|
t.Fatalf("RegisterUser: %v %v", err, errs)
|
||||||
|
}
|
||||||
|
got := stored(t, u.ID)
|
||||||
|
if got.HasSelfSetPassword || u.HasSelfSetPassword {
|
||||||
|
t.Fatalf("has_self_set_password stored %v (returned %v), want false", got.HasSelfSetPassword, u.HasSelfSetPassword)
|
||||||
|
}
|
||||||
|
if !got.IsActivated || got.ActivatedAt == nil {
|
||||||
|
t.Fatalf("activation %v %v", got.IsActivated, got.ActivatedAt)
|
||||||
|
}
|
||||||
|
if got.CreatedIPAddress == nil || *got.CreatedIPAddress != "198.18.7.1" || got.LastIPAddress == nil || *got.LastIPAddress != "198.18.7.1" {
|
||||||
|
t.Fatalf("ip columns %v %v", got.CreatedIPAddress, got.LastIPAddress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handle_assigned", func(t *testing.T) {
|
||||||
|
for _, pl := range []bool{false, true} {
|
||||||
|
u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail(fmt.Sprintf("pwless-handle-%v", pl))), controllers.RegisterOptions{PasswordLess: pl})
|
||||||
|
if err != nil || len(errs) > 0 {
|
||||||
|
t.Fatalf("RegisterUser(PasswordLess=%v): %v %v", pl, err, errs)
|
||||||
|
}
|
||||||
|
if got := stored(t, u.ID); got.Handle == nil || *got.Handle == "" {
|
||||||
|
t.Fatalf("PasswordLess=%v: no handle", pl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("validation_errors_unchanged", func(t *testing.T) {
|
||||||
|
email := "not-an-email"
|
||||||
|
fields := map[string]any{"email": email, "password": "short", "password_confirmation": "other"}
|
||||||
|
_, plain, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{})
|
||||||
|
if err != nil || len(plain) == 0 {
|
||||||
|
t.Fatalf("no validation errors without PasswordLess: %v %v", err, plain)
|
||||||
|
}
|
||||||
|
u, less, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{PasswordLess: true})
|
||||||
|
if err != nil || u != nil {
|
||||||
|
t.Fatalf("PasswordLess registration with invalid fields: user %v err %v", u, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(plain, less) {
|
||||||
|
t.Fatalf("validation errors differ:\n without %v\n with %v", plain, less)
|
||||||
|
}
|
||||||
|
if _, ok := less["email"]; !ok {
|
||||||
|
t.Fatalf("no email error: %v", less)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
if err := gdb.Unscoped().Model(&models.User{}).Where("email = ?", email).Count(&n).Error; err != nil || n != 0 {
|
||||||
|
t.Fatalf("an invalid registration wrote %d rows (%v)", n, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFinishLogin pins the social sign-in tail of a password login: a
|
||||||
|
// trashed account is restored with exactly one reactivate mail, an active
|
||||||
|
// one gets none; last_ip_address and last_seen are written, and an empty
|
||||||
|
// ip leaves last_ip_address alone.
|
||||||
|
func TestFinishLogin(t *testing.T) {
|
||||||
|
skipShortPostgres(t)
|
||||||
|
newApp := func(t *testing.T) (*backpack.App, *gorm.DB, *captureMail) {
|
||||||
|
t.Helper()
|
||||||
|
app, gdb := sessionApp(t, "0s")
|
||||||
|
mail := &captureMail{}
|
||||||
|
if err := app.Publish[postcard.Mailer](mail); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return app, gdb, mail
|
||||||
|
}
|
||||||
|
load := func(t *testing.T, gdb *gorm.DB, id uint) models.User {
|
||||||
|
t.Helper()
|
||||||
|
var u models.User
|
||||||
|
if err := gdb.Unscoped().First(&u, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
newUser := func(t *testing.T, gdb *gorm.DB, prefix string) *models.User {
|
||||||
|
t.Helper()
|
||||||
|
loc := "en"
|
||||||
|
u := &models.User{Email: hookEmail(prefix), Password: "x", PreferredLocale: &loc, IsActivated: true}
|
||||||
|
if err := gdb.Create(u).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("trashed_user_restored_with_one_mail", func(t *testing.T) {
|
||||||
|
app, gdb, mail := newApp(t)
|
||||||
|
u := newUser(t, gdb, "finish-trashed")
|
||||||
|
if err := gdb.Model(u).Update("deleted_at", time.Now()).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
trashed := load(t, gdb, u.ID)
|
||||||
|
if !trashed.DeletedAt.Valid {
|
||||||
|
t.Fatal("setup: not soft-deleted")
|
||||||
|
}
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, gdb, &trashed, "198.18.8.1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after := load(t, gdb, u.ID); after.DeletedAt.Valid {
|
||||||
|
t.Fatal("not restored")
|
||||||
|
}
|
||||||
|
if trashed.DeletedAt.Valid {
|
||||||
|
t.Fatal("the passed user still reads as trashed")
|
||||||
|
}
|
||||||
|
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
|
||||||
|
t.Fatalf("mails %#v, want one reactivate-en", mail.got)
|
||||||
|
}
|
||||||
|
// A second sign-in of the restored user sends no further mail.
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, gdb, &trashed, "198.18.8.1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(mail.got) != 1 {
|
||||||
|
t.Fatalf("mails after a second sign-in %#v", mail.got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("active_user_no_mail", func(t *testing.T) {
|
||||||
|
app, gdb, mail := newApp(t)
|
||||||
|
u := newUser(t, gdb, "finish-active")
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, gdb, u, "198.18.8.2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(mail.got) != 0 {
|
||||||
|
t.Fatalf("mails %#v, want none", mail.got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ip_and_last_seen", func(t *testing.T) {
|
||||||
|
app, gdb, _ := newApp(t)
|
||||||
|
u := newUser(t, gdb, "finish-ip")
|
||||||
|
before := time.Now().Add(-time.Second)
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, gdb, u, "198.18.8.3"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after := load(t, gdb, u.ID)
|
||||||
|
if after.LastIPAddress == nil || *after.LastIPAddress != "198.18.8.3" {
|
||||||
|
t.Fatalf("last_ip_address %v", after.LastIPAddress)
|
||||||
|
}
|
||||||
|
if u.LastIPAddress == nil || *u.LastIPAddress != "198.18.8.3" {
|
||||||
|
t.Fatalf("the passed user's LastIPAddress %v", u.LastIPAddress)
|
||||||
|
}
|
||||||
|
if after.LastSeen == nil || after.LastSeen.Before(before) {
|
||||||
|
t.Fatalf("last_seen %v, want at or after %v", after.LastSeen, before)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty_ip_keeps_last_ip", func(t *testing.T) {
|
||||||
|
app, gdb, _ := newApp(t)
|
||||||
|
u := newUser(t, gdb, "finish-noip")
|
||||||
|
if err := gdb.Model(u).Update("last_ip_address", "198.18.8.4").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, gdb, u, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after := load(t, gdb, u.ID); after.LastIPAddress == nil || *after.LastIPAddress != "198.18.8.4" {
|
||||||
|
t.Fatalf("last_ip_address %v, want the old 198.18.8.4", after.LastIPAddress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refuses_unsaved_user", func(t *testing.T) {
|
||||||
|
app, gdb, _ := newApp(t)
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, gdb, &models.User{}, "198.18.8.5"); err == nil {
|
||||||
|
t.Fatal("FinishLogin accepted a user without an id")
|
||||||
|
}
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, nil, &models.User{ID: 1}, ""); err == nil {
|
||||||
|
t.Fatal("FinishLogin accepted a nil database")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegistrationExports pins the helpers golem15.user exports for a social
|
||||||
|
// login plugin: RegistrationSettings (defaults and config), RegistrationsFromIP
|
||||||
|
// (the last 60 minutes, that IP only), RefreshTTL (default 20160 minutes and
|
||||||
|
// config) and RemoteIP (with and without a port).
|
||||||
|
func TestRegistrationExports(t *testing.T) {
|
||||||
|
t.Run("registration_settings", func(t *testing.T) {
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
allow, throttle, mode, require := controllers.RegistrationSettings(app)
|
||||||
|
if !allow || !throttle || mode != "auto" || !require {
|
||||||
|
t.Fatalf("defaults = %v %v %q %v, want true true auto true", allow, throttle, mode, require)
|
||||||
|
}
|
||||||
|
for key, value := range map[string]any{
|
||||||
|
"golem15.user.registration.allow_registration": false,
|
||||||
|
"golem15.user.registration.use_register_throttle": false,
|
||||||
|
"golem15.user.activation.activate_mode": "admin",
|
||||||
|
"golem15.user.activation.require_activation": false,
|
||||||
|
} {
|
||||||
|
if err := app.Config.Set(key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allow, throttle, mode, require = controllers.RegistrationSettings(app)
|
||||||
|
if allow || throttle || mode != "admin" || require {
|
||||||
|
t.Fatalf("configured = %v %v %q %v, want false false admin false", allow, throttle, mode, require)
|
||||||
|
}
|
||||||
|
if a, th, m, r := controllers.RegistrationSettings(nil); !a || !th || m != "auto" || !r {
|
||||||
|
t.Fatalf("nil app = %v %v %q %v", a, th, m, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("registrations_from_ip", func(t *testing.T) {
|
||||||
|
skipShortPostgres(t)
|
||||||
|
_, gdb := sessionApp(t, "0s")
|
||||||
|
ip := fmt.Sprintf("198.18.%d.%d", time.Now().UnixNano()%200+20, time.Now().UnixNano()%250+1)
|
||||||
|
other := "198.19.0.1"
|
||||||
|
mk := func(addr string, age time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
u := &models.User{Email: hookEmail("regip"), Password: "x", CreatedIPAddress: &addr}
|
||||||
|
if err := gdb.Create(u).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if age > 0 {
|
||||||
|
if err := gdb.Exec(`UPDATE users SET created_at = ? WHERE id = ?`, time.Now().Add(-age), u.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before := controllers.RegistrationsFromIP(t.Context(), gdb, ip)
|
||||||
|
mk(ip, 0)
|
||||||
|
mk(ip, 30*time.Minute)
|
||||||
|
mk(ip, 61*time.Minute)
|
||||||
|
mk(ip, 3*time.Hour)
|
||||||
|
mk(other, 0)
|
||||||
|
if got := controllers.RegistrationsFromIP(t.Context(), gdb, ip); got-before != 2 {
|
||||||
|
t.Fatalf("RegistrationsFromIP = %d (before %d), want 2 more: the last 60 minutes only", got, before)
|
||||||
|
}
|
||||||
|
if got := controllers.RegistrationsFromIP(t.Context(), gdb, "198.19.255.254"); got != 0 {
|
||||||
|
t.Fatalf("an unused ip counts %d", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refresh_ttl", func(t *testing.T) {
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
if got := controllers.RefreshTTL(app); got != 20160*time.Minute {
|
||||||
|
t.Fatalf("default RefreshTTL = %s, want 20160m", got)
|
||||||
|
}
|
||||||
|
if err := app.Config.Set("golem15.user.jwt.refresh_ttl", 90); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := controllers.RefreshTTL(app); got != 90*time.Minute {
|
||||||
|
t.Fatalf("configured RefreshTTL = %s, want 90m", got)
|
||||||
|
}
|
||||||
|
if got := controllers.RefreshTTL(nil); got != 20160*time.Minute {
|
||||||
|
t.Fatalf("nil app RefreshTTL = %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("remote_ip", func(t *testing.T) {
|
||||||
|
for remote, want := range map[string]string{
|
||||||
|
"203.0.113.9:4242": "203.0.113.9",
|
||||||
|
"203.0.113.9": "203.0.113.9",
|
||||||
|
"[2001:db8::1]:443": "2001:db8::1",
|
||||||
|
"": "",
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = remote
|
||||||
|
req.Header.Set("X-Forwarded-For", "192.0.2.200")
|
||||||
|
if got := controllers.RemoteIP(req); got != want {
|
||||||
|
t.Fatalf("RemoteIP(%q) = %q, want %q", remote, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
237
token_cookie_test.go
Normal file
237
token_cookie_test.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
userLoginPath = "/_user/api/v1/login"
|
||||||
|
userRegisterPath = "/_user/api/v1/register"
|
||||||
|
userActivateByCodePath = "/_user/api/v1/activate-by-code"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTokenCookie pins golem15.user.jwt.token_cookie: off keeps the body
|
||||||
|
// token and sets no cookie; on puts the session token of /login, /register
|
||||||
|
// and /activate-by-code into the HttpOnly auth.cookie cookie and leaves it
|
||||||
|
// out of the body; a broken auth.cookie block answers the opaque 500 before
|
||||||
|
// anything is signed in, created or activated.
|
||||||
|
func TestTokenCookie(t *testing.T) {
|
||||||
|
t.Run("off by default", func(t *testing.T) {
|
||||||
|
e := newAdminEnv(t, tokenCookieSettings(t, nil))
|
||||||
|
user := e.seedUser(t, "token-cookie-off", nil)
|
||||||
|
rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertBodyKeys(t, rec, "token", "user")
|
||||||
|
assertNoCookie(t, rec)
|
||||||
|
|
||||||
|
rec = e.call(t, http.MethodPost, userRegisterPath, "", registerBody("token-cookie-off-register"))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("register = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertBodyKeys(t, rec, "token", "user")
|
||||||
|
assertNoCookie(t, rec)
|
||||||
|
|
||||||
|
rec = e.call(t, http.MethodPost, userActivateByCodePath, "", map[string]string{"code": e.activationCode(t, e.inactiveUser(t, "token-cookie-off-activate"))})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("activate-by-code = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertBodyKeys(t, rec, "message", "token", "user")
|
||||||
|
assertNoCookie(t, rec)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("on", func(t *testing.T) {
|
||||||
|
e := newAdminEnv(t, tokenCookieSettings(t, map[string]any{"golem15.user.jwt.token_cookie": true}))
|
||||||
|
cfg := e.cookieConfig(t)
|
||||||
|
user := e.seedUser(t, "token-cookie-on", nil)
|
||||||
|
rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := assertBodyKeys(t, rec, "user")
|
||||||
|
if got, _ := body["user"].(map[string]any)["email"].(string); got != user.Email {
|
||||||
|
t.Fatalf("login answered user %q, want %q", got, user.Email)
|
||||||
|
}
|
||||||
|
session := assertSessionCookie(t, rec, cfg, 20160*60)
|
||||||
|
e.assertCookieServes(t, cfg.Name, session, userSub(user))
|
||||||
|
|
||||||
|
rec = e.cookieSend(t, http.MethodPost, userRefreshPath, cfg.Name, session, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("cookie refresh = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertBodyKeys(t, rec)
|
||||||
|
rotated := assertSessionCookie(t, rec, cfg, 20160*60)
|
||||||
|
if rotated == session {
|
||||||
|
t.Fatal("the refreshed cookie carries the old token")
|
||||||
|
}
|
||||||
|
e.assertCookieServes(t, cfg.Name, rotated, userSub(user))
|
||||||
|
|
||||||
|
rec = e.cookieSend(t, http.MethodPost, userTokensPath, cfg.Name, rotated, map[string]string{"name": "cli"})
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("POST /tokens = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if secret, _ := assertBodyKeys(t, rec, "token", "meta")["token"].(string); secret == "" {
|
||||||
|
t.Fatalf("POST /tokens answered %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoCookie(t, rec)
|
||||||
|
|
||||||
|
rec = e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "wrong-horse"})
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
|
||||||
|
t.Fatalf("login with a wrong password = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoCookie(t, rec)
|
||||||
|
|
||||||
|
rec = e.call(t, http.MethodPost, userRegisterPath, "", registerBody("token-cookie-on-register"))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("register = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body = assertBodyKeys(t, rec, "user")
|
||||||
|
registered := assertSessionCookie(t, rec, cfg, 20160*60)
|
||||||
|
id, _ := body["user"].(map[string]any)["id"].(float64)
|
||||||
|
e.assertCookieServes(t, cfg.Name, registered, userSub(&models.User{ID: uint(id)}))
|
||||||
|
|
||||||
|
inactive := e.inactiveUser(t, "token-cookie-on-activate")
|
||||||
|
rec = e.call(t, http.MethodPost, userActivateByCodePath, "", map[string]string{"code": e.activationCode(t, inactive)})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("activate-by-code = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body = assertBodyKeys(t, rec, "message", "user")
|
||||||
|
if body["message"] != "Account activated" || body["user"].(map[string]any)["is_activated"] != true {
|
||||||
|
t.Fatalf("activate-by-code answered %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
activated := assertSessionCookie(t, rec, cfg, 20160*60)
|
||||||
|
e.assertCookieServes(t, cfg.Name, activated, userSub(inactive))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("on with configured auth.cookie", func(t *testing.T) {
|
||||||
|
e := newAdminEnv(t, tokenCookieSettings(t, map[string]any{
|
||||||
|
"golem15.user.jwt.token_cookie": true,
|
||||||
|
"auth.cookie.name": "auth_token",
|
||||||
|
"golem15.user.jwt.refresh_ttl": 60,
|
||||||
|
}))
|
||||||
|
cfg := e.cookieConfig(t)
|
||||||
|
if cfg.Name != "auth_token" {
|
||||||
|
t.Fatalf("config not applied: %+v", cfg)
|
||||||
|
}
|
||||||
|
user := e.seedUser(t, "token-cookie-custom", nil)
|
||||||
|
rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertBodyKeys(t, rec, "user")
|
||||||
|
session := assertSessionCookie(t, rec, cfg, 3600)
|
||||||
|
e.assertCookieServes(t, "auth_token", session, userSub(user))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("on with a broken auth.cookie", func(t *testing.T) {
|
||||||
|
e := newAdminEnv(t, tokenCookieSettings(t, map[string]any{
|
||||||
|
"golem15.user.jwt.token_cookie": true,
|
||||||
|
"auth.cookie.same_site": "sideways",
|
||||||
|
}))
|
||||||
|
user := e.seedUser(t, "token-cookie-broken", nil)
|
||||||
|
rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"})
|
||||||
|
assertOpaque500(t, rec)
|
||||||
|
if got := e.reload(t, user.ID); got.LastSeen != nil {
|
||||||
|
t.Fatalf("last_seen after a refused login = %v", got.LastSeen)
|
||||||
|
}
|
||||||
|
if got := e.hangers(t, user).Throttle; got != 0 {
|
||||||
|
t.Fatalf("user_throttle rows after a refused login = %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
register := registerBody("token-cookie-broken-register")
|
||||||
|
rec = e.call(t, http.MethodPost, userRegisterPath, "", register)
|
||||||
|
assertOpaque500(t, rec)
|
||||||
|
var count int64
|
||||||
|
if err := e.gdb.Unscoped().Model(&models.User{}).Where("email = ?", register["email"]).Count(&count).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("register with a broken auth.cookie stored %d users", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
inactive := e.inactiveUser(t, "token-cookie-broken-activate")
|
||||||
|
code := e.activationCode(t, inactive)
|
||||||
|
rec = e.call(t, http.MethodPost, userActivateByCodePath, "", map[string]string{"code": code})
|
||||||
|
assertOpaque500(t, rec)
|
||||||
|
if e.reload(t, inactive.ID).IsActivated {
|
||||||
|
t.Fatal("activate-by-code with a broken auth.cookie activated the user")
|
||||||
|
}
|
||||||
|
if err := e.app.Config.Set("auth.cookie.same_site", "lax"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec = e.call(t, http.MethodPost, userActivateByCodePath, "", map[string]string{"code": code})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("activate-by-code after fixing auth.cookie = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertBodyKeys(t, rec, "message", "user")
|
||||||
|
assertSessionCookie(t, rec, e.cookieConfig(t), 20160*60)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenCookieSettings turns the registration throttle off and sets keys.
|
||||||
|
func tokenCookieSettings(t *testing.T, keys map[string]any) func(*compass.Config) {
|
||||||
|
return func(c *compass.Config) {
|
||||||
|
if err := c.Set("golem15.user.registration.use_register_throttle", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for key, value := range keys {
|
||||||
|
if err := c.Set(key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerBody is a /register body with a unique email.
|
||||||
|
func registerBody(label string) map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"email": label + "-" + adminStamp() + "@example.test",
|
||||||
|
"password": "correct-horse",
|
||||||
|
"name": label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inactiveUser seeds a not-activated frontend user.
|
||||||
|
func (e *adminEnv) inactiveUser(t *testing.T, label string) *models.User {
|
||||||
|
t.Helper()
|
||||||
|
return e.seedUser(t, label, func(u *models.User) { u.IsActivated = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// activationCode issues an activation code for user, packed as id!code.
|
||||||
|
func (e *adminEnv) activationCode(t *testing.T, user *models.User) string {
|
||||||
|
t.Helper()
|
||||||
|
code, err := classes.IssueActivationCode(t.Context(), e.gdb, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return userSub(user) + "!" + code
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertCookieServes: GET /fetch with only the named cookie set to token
|
||||||
|
// serves the user sub.
|
||||||
|
func (e *adminEnv) assertCookieServes(t *testing.T, name, token, sub string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("fetch with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := fetchedID(t, rec); got != sub {
|
||||||
|
t.Fatalf("fetch with the %s cookie served user %q, want %q", name, got, sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertOpaque500 requires the plugin's opaque 500 and no cookie.
|
||||||
|
func assertOpaque500(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != http.StatusInternalServerError || rec.Body.String() != opaque500Body {
|
||||||
|
t.Fatalf("want the opaque 500, got %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoCookie(t, rec)
|
||||||
|
}
|
||||||
158
turnstile_routes_test.go
Normal file
158
turnstile_routes_test.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordedRoute is one registration seen by recordingRouter.
|
||||||
|
type recordedRoute struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
middleware []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordingRouter is a pact.Router that records each registration with its
|
||||||
|
// full path and the group plus route middleware. The plugin requires a
|
||||||
|
// framework whose surf does not know the turnstile:* names, so the smoke test
|
||||||
|
// checks the declared middleware instead of assembling a real router.
|
||||||
|
type recordingRouter struct {
|
||||||
|
prefix string
|
||||||
|
middleware []string
|
||||||
|
routes *[]recordedRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRecordingRouter() *recordingRouter {
|
||||||
|
return &recordingRouter{routes: &[]recordedRoute{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingRouter) Group(prefix string, middleware []string, fn func(pact.Router)) {
|
||||||
|
fn(&recordingRouter{
|
||||||
|
prefix: r.prefix + prefix,
|
||||||
|
middleware: append(append([]string{}, r.middleware...), middleware...),
|
||||||
|
routes: r.routes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingRouter) GroupRaw(prefix string, middleware []string, fn func(pact.Router)) {
|
||||||
|
r.Group(prefix, middleware, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingRouter) add(method, path string, middleware []string) {
|
||||||
|
*r.routes = append(*r.routes, recordedRoute{
|
||||||
|
method: method,
|
||||||
|
path: r.prefix + path,
|
||||||
|
middleware: append(append([]string{}, r.middleware...), middleware...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingRouter) Get(path string, _ http.HandlerFunc, mw ...string) {
|
||||||
|
r.add(http.MethodGet, path, mw)
|
||||||
|
}
|
||||||
|
func (r *recordingRouter) Post(path string, _ http.HandlerFunc, mw ...string) {
|
||||||
|
r.add(http.MethodPost, path, mw)
|
||||||
|
}
|
||||||
|
func (r *recordingRouter) Put(path string, _ http.HandlerFunc, mw ...string) {
|
||||||
|
r.add(http.MethodPut, path, mw)
|
||||||
|
}
|
||||||
|
func (r *recordingRouter) Patch(path string, _ http.HandlerFunc, mw ...string) {
|
||||||
|
r.add(http.MethodPatch, path, mw)
|
||||||
|
}
|
||||||
|
func (r *recordingRouter) Delete(path string, _ http.HandlerFunc, mw ...string) {
|
||||||
|
r.add(http.MethodDelete, path, mw)
|
||||||
|
}
|
||||||
|
func (r *recordingRouter) Where(string, string) {}
|
||||||
|
func (r *recordingRouter) WhereIn(string, ...string) {}
|
||||||
|
|
||||||
|
func recordUserRoutes(t *testing.T, mode string) ([]recordedRoute, error) {
|
||||||
|
t.Helper()
|
||||||
|
app := bareApp(t)
|
||||||
|
if err := app.Config.Set("golem15.user.turnstile", mode); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := newRecordingRouter()
|
||||||
|
err := (&Plugin{app: app}).Routes(rec)
|
||||||
|
return *rec.routes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func turnstileOf(route recordedRoute) []string {
|
||||||
|
var out []string
|
||||||
|
for _, name := range route.middleware {
|
||||||
|
if strings.HasPrefix(name, "turnstile") {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTurnstileRoutes pins golem15.user.turnstile (D-07): empty adds no
|
||||||
|
// middleware; open and closed (surrounding spaces trimmed) append
|
||||||
|
// turnstile:{mode} to POST /_user/api/v1/login and /register only, leaving
|
||||||
|
// every other route's middleware as it is with the key empty; any other
|
||||||
|
// value fails Routes naming the key.
|
||||||
|
func TestTurnstileRoutes(t *testing.T) {
|
||||||
|
off, err := recordUserRoutes(t, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty turnstile: Routes error %v", err)
|
||||||
|
}
|
||||||
|
if len(off) == 0 {
|
||||||
|
t.Fatal("no routes recorded")
|
||||||
|
}
|
||||||
|
guarded := map[string]bool{
|
||||||
|
http.MethodPost + " /_user/api/v1/login": true,
|
||||||
|
http.MethodPost + " /_user/api/v1/register": true,
|
||||||
|
}
|
||||||
|
seen := 0
|
||||||
|
for _, route := range off {
|
||||||
|
if got := turnstileOf(route); len(got) > 0 {
|
||||||
|
t.Errorf("empty turnstile: %s %s carries %v", route.method, route.path, got)
|
||||||
|
}
|
||||||
|
if guarded[route.method+" "+route.path] {
|
||||||
|
seen++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seen != len(guarded) {
|
||||||
|
t.Fatalf("found %d of the guarded routes, want %d", seen, len(guarded))
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name, mode, want string
|
||||||
|
}{
|
||||||
|
{name: "open", mode: "open", want: "turnstile:open"},
|
||||||
|
{name: "closed", mode: "closed", want: "turnstile:closed"},
|
||||||
|
{name: "closed with spaces", mode: " closed ", want: "turnstile:closed"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got, err := recordUserRoutes(t, c.mode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Routes error %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != len(off) {
|
||||||
|
t.Fatalf("%d routes, want %d", len(got), len(off))
|
||||||
|
}
|
||||||
|
for i, route := range got {
|
||||||
|
base := off[i]
|
||||||
|
if route.method != base.method || route.path != base.path {
|
||||||
|
t.Fatalf("route %d is %s %s, want %s %s", i, route.method, route.path, base.method, base.path)
|
||||||
|
}
|
||||||
|
want := base.middleware
|
||||||
|
if guarded[route.method+" "+route.path] {
|
||||||
|
want = append(append([]string{}, base.middleware...), c.want)
|
||||||
|
}
|
||||||
|
if strings.Join(route.middleware, ",") != strings.Join(want, ",") {
|
||||||
|
t.Errorf("%s %s middleware %v, want %v", route.method, route.path, route.middleware, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{"bogus", "Closed", "on", "true"} {
|
||||||
|
if _, err := recordUserRoutes(t, bad); err == nil || !strings.Contains(err.Error(), "golem15.user.turnstile") {
|
||||||
|
t.Errorf("turnstile %q: error %v, want one naming golem15.user.turnstile", bad, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
updates/202610160002_add_handle_to_users.go
Normal file
30
updates/202610160002_add_handle_to_users.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addHandleToUsers adds users.handle, the unique @mention identity (D-15),
|
||||||
|
// only when it is missing: an application whose users table already has the
|
||||||
|
// column (added on PHP by chatchannels) keeps it and its data. The rollback
|
||||||
|
// is a no-op for the same reason: the column may predate this plugin.
|
||||||
|
var addHandleToUsers = []*gormigrate.Migration{
|
||||||
|
{
|
||||||
|
ID: "202610160002_add_handle_to_users",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS handle VARCHAR(255) NULL`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS users_handle_unique ON users (handle)`).Error
|
||||||
|
},
|
||||||
|
Rollback: func(*gorm.DB) error {
|
||||||
|
// No-op: the shared users column may predate the plugin.
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(addHandleToUsers...)
|
||||||
|
}
|
||||||
33
updates/202610210001_add_users_email_verified_at.go
Normal file
33
updates/202610210001_add_users_email_verified_at.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// usersEmailVerifiedAtMigrations adds users.email_verified_at, the time the
|
||||||
|
// account's owner proved control of the address: a mailed activation code or
|
||||||
|
// a completed reset-code password reset (D-27). Social sign-in matching links
|
||||||
|
// a verified provider's identity onto an existing account only when it is set.
|
||||||
|
//
|
||||||
|
// The migration writes no rows: accounts that exist before it runs have no
|
||||||
|
// recorded proof (no backfill, D-26). A users table that already carries the
|
||||||
|
// column (a Laravel-derived schema) keeps it and its values, which mean the
|
||||||
|
// same thing. The rollback is a no-op for the same reason: the shared users
|
||||||
|
// column may predate this plugin.
|
||||||
|
var usersEmailVerifiedAtMigrations = []*gormigrate.Migration{
|
||||||
|
{
|
||||||
|
ID: "202610210001_add_users_email_verified_at",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL`).Error
|
||||||
|
},
|
||||||
|
Rollback: func(*gorm.DB) error {
|
||||||
|
// No-op: the shared users column may predate the plugin.
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(usersEmailVerifiedAtMigrations...)
|
||||||
|
}
|
||||||
60
updates/202610220001_create_backend_links.go
Normal file
60
updates/202610220001_create_backend_links.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// backendLinkMigrations create the verified frontend-to-backend account link
|
||||||
|
// (one frontend user to one administrator, both sides unique) and the
|
||||||
|
// one-shot link requests an administrator starts on My account. A request
|
||||||
|
// stores only the SHA-256 of its code. Deleting either account removes its
|
||||||
|
// link and requests; deleting the administrator who set a link keeps the
|
||||||
|
// link and clears linked_by.
|
||||||
|
//
|
||||||
|
// The foreign keys to backend_users are added when that table exists, which
|
||||||
|
// it always does when lagoon.Migrate runs the plugin (the admin's own
|
||||||
|
// migrations run first); a run of the plugin's migrations alone, as some
|
||||||
|
// tests do, creates the tables without them.
|
||||||
|
var backendLinkMigrations = []*gormigrate.Migration{
|
||||||
|
{
|
||||||
|
ID: "202610220001_create_golem15_user_backend_links",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec(`CREATE TABLE golem15_user_backend_links (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
backend_user_id INTEGER NOT NULL UNIQUE,
|
||||||
|
linked_by INTEGER NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE TABLE golem15_user_backend_link_requests (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
code_hash CHAR(64) NOT NULL UNIQUE,
|
||||||
|
backend_user_id INTEGER NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX golem15_user_backend_link_requests_backend_user_id_index ON golem15_user_backend_link_requests (backend_user_id);
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('backend_users') IS NOT NULL THEN
|
||||||
|
ALTER TABLE golem15_user_backend_links
|
||||||
|
ADD CONSTRAINT golem15_user_backend_links_backend_user_id_foreign
|
||||||
|
FOREIGN KEY (backend_user_id) REFERENCES backend_users(id) ON DELETE CASCADE,
|
||||||
|
ADD CONSTRAINT golem15_user_backend_links_linked_by_foreign
|
||||||
|
FOREIGN KEY (linked_by) REFERENCES backend_users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE golem15_user_backend_link_requests
|
||||||
|
ADD CONSTRAINT golem15_user_backend_link_requests_backend_user_id_foreign
|
||||||
|
FOREIGN KEY (backend_user_id) REFERENCES backend_users(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$`).Error
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec("DROP TABLE IF EXISTS golem15_user_backend_link_requests; DROP TABLE IF EXISTS golem15_user_backend_links").Error
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { Register(backendLinkMigrations...) }
|
||||||
143
updates/email_verified_at_test.go
Normal file
143
updates/email_verified_at_test.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const emailVerifiedAtID = "202610210001_add_users_email_verified_at"
|
||||||
|
|
||||||
|
// emailVerifiedDB opens a dedicated database and a migrator over every
|
||||||
|
// registered migration.
|
||||||
|
func emailVerifiedDB(t *testing.T, name string) (*gorm.DB, *gormigrate.Gormigrate) {
|
||||||
|
t.Helper()
|
||||||
|
gdb, err := lagoon.Use(t.Context(), dedicatedDB(t, name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return gdb, gormigrate.New(gdb, &gormigrate.Options{
|
||||||
|
TableName: "summer_migrations_golem15_user",
|
||||||
|
UseTransaction: true,
|
||||||
|
}, All())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmailVerifiedAtMigration pins D-27: users.email_verified_at is a
|
||||||
|
// nullable TIMESTAMPTZ added only when missing, the migration writes no
|
||||||
|
// rows (no backfill, D-26), a column that already exists keeps its values
|
||||||
|
// and the rollback leaves it in place.
|
||||||
|
func TestEmailVerifiedAtMigration(t *testing.T) {
|
||||||
|
t.Run("column_shape", func(t *testing.T) {
|
||||||
|
gdb, m := emailVerifiedDB(t, "user_email_verified_shape")
|
||||||
|
if err := m.Migrate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var row struct {
|
||||||
|
DataType string
|
||||||
|
IsNullable string
|
||||||
|
}
|
||||||
|
if err := gdb.Raw(`SELECT data_type, is_nullable FROM information_schema.columns
|
||||||
|
WHERE table_name = 'users' AND column_name = 'email_verified_at'`).Scan(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if row.DataType != "timestamp with time zone" || row.IsNullable != "YES" {
|
||||||
|
t.Fatalf("email_verified_at = %+v", row)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no_backfill", func(t *testing.T) {
|
||||||
|
gdb, m := emailVerifiedDB(t, "user_email_verified_backfill")
|
||||||
|
if err := m.MigrateTo("202610160002_add_handle_to_users"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gdb.Migrator().HasColumn("users", "email_verified_at") {
|
||||||
|
t.Fatal("setup: the column exists before the migration")
|
||||||
|
}
|
||||||
|
for _, email := range []string{"legacy-a@example.test", "legacy-b@example.test"} {
|
||||||
|
if err := gdb.Exec(`INSERT INTO users (email, password, is_activated, has_self_set_password, activated_at)
|
||||||
|
VALUES (?, 'hash', TRUE, TRUE, NOW())`, email).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := m.Migrate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var total, proven int64
|
||||||
|
if err := gdb.Raw(`SELECT COUNT(*), COUNT(email_verified_at) FROM users`).Row().Scan(&total, &proven); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 2 || proven != 0 {
|
||||||
|
t.Fatalf("users %d, with proof %d: the migration must write no rows", total, proven)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("existing_column_kept", func(t *testing.T) {
|
||||||
|
gdb, m := emailVerifiedDB(t, "user_email_verified_existing")
|
||||||
|
if err := m.MigrateTo("202610160002_add_handle_to_users"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A Laravel-derived schema already carries the column and its values.
|
||||||
|
if err := gdb.Exec(`ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ NULL`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
if err := gdb.Exec(`INSERT INTO users (email, email_verified_at) VALUES ('imported@example.test', ?)`, want).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gdb.Exec(`INSERT INTO users (email) VALUES ('imported-unproven@example.test')`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.Migrate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var ran int64
|
||||||
|
if err := gdb.Raw(`SELECT COUNT(*) FROM summer_migrations_golem15_user WHERE id = ?`, emailVerifiedAtID).Scan(&ran).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ran != 1 {
|
||||||
|
t.Fatal("the migration did not run over the existing column")
|
||||||
|
}
|
||||||
|
var got *time.Time
|
||||||
|
if err := gdb.Raw(`SELECT email_verified_at FROM users WHERE email = 'imported@example.test'`).Row().Scan(&got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got == nil || !got.Equal(want) {
|
||||||
|
t.Fatalf("existing value = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
var proven int64
|
||||||
|
if err := gdb.Raw(`SELECT COUNT(email_verified_at) FROM users`).Row().Scan(&proven); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if proven != 1 {
|
||||||
|
t.Fatalf("rows with proof = %d, want only the imported one", proven)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rollback_keeps_column", func(t *testing.T) {
|
||||||
|
gdb, m := emailVerifiedDB(t, "user_email_verified_rollback")
|
||||||
|
if err := m.Migrate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.RollbackMigration(usersEmailVerifiedAtMigrations[0]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !gdb.Migrator().HasColumn("users", "email_verified_at") {
|
||||||
|
t.Fatal("the rollback dropped the shared users column")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("history_once", func(t *testing.T) {
|
||||||
|
n := 0
|
||||||
|
for _, mig := range All() {
|
||||||
|
if mig.ID == emailVerifiedAtID {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != 1 || len(usersEmailVerifiedAtMigrations) != 1 || usersEmailVerifiedAtMigrations[0].ID != emailVerifiedAtID {
|
||||||
|
t.Fatalf("%s registered %d times", emailVerifiedAtID, n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -49,6 +49,10 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
|||||||
"202610040001_create_user_api_tokens",
|
"202610040001_create_user_api_tokens",
|
||||||
"202610040002_add_users_last_seen",
|
"202610040002_add_users_last_seen",
|
||||||
"202610040003_create_frontend_permissions",
|
"202610040003_create_frontend_permissions",
|
||||||
|
"202610050001_create_oauth_identities",
|
||||||
|
"202610160002_add_handle_to_users",
|
||||||
|
"202610210001_add_users_email_verified_at",
|
||||||
|
"202610220001_create_golem15_user_backend_links",
|
||||||
}
|
}
|
||||||
if len(ids) != len(want) {
|
if len(ids) != len(want) {
|
||||||
t.Fatalf("history = %v", ids)
|
t.Fatalf("history = %v", ids)
|
||||||
@@ -59,9 +63,9 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The three admin migrations, API tokens, user groups, jwt blacklist,
|
// Backend links, email proof, handle, OAuth identities, the three admin migrations, API tokens, user groups,
|
||||||
// throttle, extend-users, then organisations.
|
// jwt blacklist, throttle, extend-users, then organisations.
|
||||||
for range 9 {
|
for range 13 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ func TestUserGroupsMigration(t *testing.T) {
|
|||||||
t.Fatalf("users_groups primary key = %q", pk)
|
t.Fatalf("users_groups primary key = %q", pk)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frontend permissions, last seen, API tokens, user permissions, then user groups.
|
// Backend links, email verified at, handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
|
||||||
for range 5 {
|
for range 9 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ func TestExtendUsersMigration(t *testing.T) {
|
|||||||
if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded {
|
if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded {
|
||||||
t.Fatalf("defaults = %+v", row)
|
t.Fatalf("defaults = %+v", row)
|
||||||
}
|
}
|
||||||
// The three admin migrations, API tokens, user groups, jwt blacklist,
|
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups,
|
||||||
// throttle, then extend-users.
|
// jwt blacklist, throttle, then extend-users.
|
||||||
for range 8 {
|
for range 12 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -88,8 +88,8 @@ func TestCreateUserThrottleMigration(t *testing.T) {
|
|||||||
t.Fatalf("missing column %s", col)
|
t.Fatalf("missing column %s", col)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
|
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
|
||||||
for range 7 {
|
for range 11 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -120,8 +120,8 @@ func TestCreateJwtBlacklistMigration(t *testing.T) {
|
|||||||
t.Fatalf("missing column %s", col)
|
t.Fatalf("missing column %s", col)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The three admin migrations, API tokens, user groups, then jwt blacklist.
|
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
|
||||||
for range 6 {
|
for range 10 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user