Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07196a4bcb | |||
| 557c03c595 | |||
| 20e7b175f8 | |||
| cc3636cea9 | |||
| 1f3266a417 | |||
| 2c689793f4 | |||
| 6844d362c4 | |||
| 3b22134292 | |||
| 4e3e5b05b5 | |||
| 3d4c8d2160 | |||
| 7900f47ba6 | |||
| 8501f3475a |
46
README.md
46
README.md
@@ -12,24 +12,29 @@ 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`, `golem15_user_frontend_permissions`, `golem15_user_backend_links` and `golem15_user_backend_link_requests`, the JSON API under `/_user/api/v1`, and the Users, User Groups and Organisations screens of the admin. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs, permission codes and config keys are a contract: change them only in a backwards-compatible way.
|
It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_api_tokens`, `golem15_user_oauth_identities`, `golem15_user_organisations`, `user_groups`, `users_groups`, `golem15_user_frontend_permissions`, `golem15_user_backend_links`, `golem15_user_backend_link_requests` and `golem15_user_device_auth_sessions`, the JSON API under `/_user/api/v1`, and the Users, User Groups and Organisations screens of the admin. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs, permission codes and config keys are a contract: change them only in a backwards-compatible way.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. A `/register` body sets only the `User.RegisterFillable` columns: `name`, `surname`, `email`, `password` (stored as its hash), `preferred_locale` and `marketing_consent`. It cannot set `organisation_id` or `organisation_role` (membership is the administrator's or the application's), `is_onboarded`, `must_change_password`, or the activation, reset, address proof, handle and permission columns; other keys are ignored (they still reach `RegisterEvent.Payload`). `created_ip_address` and `last_ip_address` are server-set from the client address only. With `require_activation` on (the default), `POST /login` refuses an account that is not activated with the same 401 invalid-login answer as a wrong password and counts the attempt for the login throttle. Such an account is activated by the mailed code (`POST /activate-by-code` answers with a token), by the invitation link or by an administrator. `POST /activate` serves the sessions of not-activated accounts, which exist when `require_activation` is off. `Login` checks in this order: a ban or an active suspension (`RejectIfThrottled`), the password, the activation, then the deactivation restore.
|
- **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. A `/register` body sets only the `User.RegisterFillable` columns: `name`, `surname`, `email`, `password` (stored as its hash), `preferred_locale` and `marketing_consent`. It cannot set `organisation_id` or `organisation_role` (membership is the administrator's or the application's), `is_onboarded`, `must_change_password`, or the activation, reset, address proof, handle and permission columns; other keys are ignored (they still reach `RegisterEvent.Payload`). `created_ip_address` and `last_ip_address` are server-set from the client address only. With `require_activation` on (the default), `POST /login` refuses an account that is not activated with the same 401 invalid-login answer as a wrong password and counts the attempt for the login throttle. Such an account is activated by the mailed code (`POST /activate-by-code` answers with a token), by the invitation link or by an administrator. `POST /activate` serves the sessions of not-activated accounts, which exist when `require_activation` is off. `Login` checks in this order: a ban or an active suspension (`RejectIfThrottled`), the password, the activation, then the deactivation restore.
|
||||||
- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts its background sweep, which prunes the blacklist, through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh. `GET /fetch`, `POST /refresh`, `POST /update` and `POST /change-password`, like the plugin's other signed-in routes, read the session token as the `jwt` guard does: the bearer header first, then the `token` and `auth_token` cookies, so a cookie-only session works on them and a bearer header wins when both are sent. A `POST /refresh` whose token came from the `auth.cookie.*` cookie (`auth.cookie.name`, default `token`) with no bearer header gets that cookie back with the rotated token: the cookie has the `bouncer.LoadCookieConfig` attributes, HttpOnly and Max-Age `refresh_ttl`, and the answer is `{}`, so the token never reaches JavaScript. This holds whoever wrote the cookie (this plugin's `golem15.user.jwt.token_cookie`, `golem15.sociallogin`'s `token_cookie` or the application); a frontend that writes the `token` cookie itself and refreshes through it gets it back HttpOnly, without a body token, so such a frontend sends the bearer header to keep rotating in the body. A refresh carried by the bearer header (a cookie sent with it included) or by the other session cookie answers `{"token": …}` and writes no cookie; the old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so that caller stores the new token itself. A refused refresh writes and forgets no cookie. When `bouncer.LoadCookieConfig` refuses the `auth.cookie` block, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block. Keep `auth.cookie.name` at `token` or `auth_token`, the cookies the guard reads. With `golem15.user.jwt.token_cookie` on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set that HttpOnly session cookie (`bouncer.LoadCookieConfig` attributes, Max-Age `refresh_ttl`) and leave `token` out of the body; `user`, and `message` on activate-by-code, stay. With it off (the default) the token is in the body and no cookie is set. SameSite stays the `auth.cookie` setting (`lax` by default) and there is no CSRF token, so do not set `same_site: none` for this cookie. With the key on, a broken `auth.cookie` block answers those three routes with the opaque 500 before they sign in, create or activate anything. Personal API tokens (`POST /tokens`) are not sessions and are unaffected. A cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the `auth.cookie` default is `lax`), as on the `jwt.auth` routes.
|
- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts its background sweep, which prunes the blacklist, through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh. `GET /fetch`, `POST /refresh`, `POST /update` and `POST /change-password`, like the plugin's other signed-in routes, read the session token as the `jwt` guard does: the bearer header first, then the `token` and `auth_token` cookies, so a cookie-only session works on them and a bearer header wins when both are sent. A `POST /refresh` whose token came from the `auth.cookie.*` cookie (`auth.cookie.name`, default `token`) with no bearer header gets that cookie back with the rotated token: the cookie has the `bouncer.LoadCookieConfig` attributes, HttpOnly and Max-Age `refresh_ttl`, and the answer is `{}`, so the token never reaches JavaScript. This holds whoever wrote the cookie (this plugin's `golem15.user.jwt.token_cookie`, `golem15.sociallogin`'s `token_cookie` or the application); a frontend that writes the `token` cookie itself and refreshes through it gets it back HttpOnly, without a body token, so such a frontend sends the bearer header to keep rotating in the body. A refresh carried by the bearer header (a cookie sent with it included) or by the other session cookie answers `{"token": …}` and writes no cookie; the old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so that caller stores the new token itself. A refused refresh writes and forgets no cookie. When `bouncer.LoadCookieConfig` refuses the `auth.cookie` block, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block. Keep `auth.cookie.name` at `token` or `auth_token`, the cookies the guard reads. With `golem15.user.jwt.token_cookie` on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set that HttpOnly session cookie (`bouncer.LoadCookieConfig` attributes, Max-Age `refresh_ttl`) and leave `token` out of the body; `user`, and `message` on activate-by-code, stay. With it off (the default) the token is in the body and no cookie is set. SameSite stays the `auth.cookie` setting (`lax` by default) and there is no CSRF token, so do not set `same_site: none` for this cookie. With the key on, a broken `auth.cookie` block answers those three routes with the opaque 500 before they sign in, create or activate anything. Personal API tokens (`POST /tokens`) are not sessions and are unaffected. A cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the `auth.cookie` default is `lax`), as on the `jwt.auth` routes.
|
||||||
|
- **Login gates.** Register publishes a `*classes.LoginGates` registry. Another plugin (a two-factor plugin, for example) looks it up with `app.Lookup[*classes.LoginGates]()` and adds a `classes.LoginGate` in its Boot. `POST /login` consults the gates through `controllers.CheckLoginGates` after the password, activation and throttle checks (and the bcrypt rehash) and before it restores a deactivated account, sends the reactivate mail, stamps `last_seen` and mints the token. A gate sees a `classes.LoginAttempt` (`User`, `Source`, the already-read login body as `Fields`, `Request`). A nil result allows the sign-in; a `classes.LoginGateResult` replaces the token response with its `Status` (0 means 200) and `Body`, for example `{"two_factor_required":true,"challenge_token":…,"available_methods":[…],"expires_at":…}`, and no token is issued, no account is restored and no login event fires. A gate error fails closed with the opaque 500. Without a gate, `/login` answers exactly as before. `POST /refresh` never consults the gates.
|
||||||
|
- **Login event.** Every sign-in that issued a session token fires `classes.LoginEvent` (`classes.LoginEventName`, `golem15.user.login`, PHP's name) with `User` and `Source` (`LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor`, `LoginSourceDevice`), after the token was minted. `POST /login` fires it with `password`; another plugin that completes a sign-in fires it through `controllers.FireLoginEvent`. A listener error is logged and never fails the sign-in. Refresh and `/register` fire none.
|
||||||
|
- **Password-change hook.** A password reset, the user's own `POST /change-password` and an administrator setting a password on the Users screen fire `classes.AccessRevokedEvent` inside the write's transaction (see Access revocation); `(*AccessRevokedEvent).PasswordChanged()` reports these three reasons. A two-factor plugin listens to it to revoke trusted devices, and this plugin revokes the users' pending and confirmed device sessions (see Device authorization); a listener error rolls the password write back. The bcrypt rehash on login and a new account fire nothing.
|
||||||
- **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password. With `golem15.user.password.public_routes` false, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered, so they answer the router's own 404 like any unknown path (any method, so no 405 shows they exist), while the `controllers` handlers stay exported.
|
- **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 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.
|
- **Login throttling.** Failed logins are counted per user and client address in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. The client address is `surf.ClientIP` with the framework's `http.trusted_proxies`, as for the `/register` IP limit and the `user-api` rate limit: behind a listed reverse proxy it is the `X-Forwarded-For` client, otherwise the connection address, and an `X-Forwarded-For` header from any other peer is ignored. A deployment behind a proxy lists the proxy's address in `http.trusted_proxies`; otherwise every visitor shares the proxy's counter.
|
||||||
|
- **Device authorization.** A device without a keyboard (a TV, a desktop app) signs in by QR code or short code, PHP's flow with its paths and bodies. It calls `POST /devices/initiate` (no session; optional `device_name`, at most 255 characters) and gets `{"success":true,"data":{"token","short_code","auth_url","expires_at"}}`: a 64-character token, a short code `XXXX-XXXX` over `23456789ABCDEFGHJKMNPQRSTUVWXYZ`, the page to show as a QR code (`golem15.user.device_auth.auth_url`, default `{frontend_url}/device/scan/{short_code}`) and the expiry (`golem15.user.device_auth.ttl_minutes`, default 5). A signed-in user opens that page, or types the short code, and the site calls `POST /devices/authorize` with the user's session token and `short_code` (any case, hyphen optional) or `token` (the device token, or the short code the default page carries); there is no second-factor check, the user already passed it at sign-in. The device polls `GET /devices/status/{token}`: `pending`, then on the first poll after the authorization `{"success":true,"data":{"status":"confirmed","user_id",…,"message":"Device authorized - complete login","token":<session JWT>,"user":<payload>}}`, exactly once (one guarded `UPDATE` moves the session from `confirmed` to `used`, so of concurrent polls one gets the token), then `used`. The device token is the device's polling secret: the default `auth_url` carries the short code instead, so a photo of the QR code, the page's URL in a browser history or a `Referer` gives an onlooker nothing to poll with, and only a poll with the `User-Agent` the device initiated with receives the session token (another client gets `{"success":true,"data":{"status":"confirmed"}}` and takes nothing). A template with `{token}` still works but puts the polling secret on screen. A user who is gone, deactivated or banned, or whose `tokens_valid_after` is later than the authorization (a password change or `RevokeUserAccess` after the user authorized the device), gets no token: the session becomes `revoked`. Every `AccessRevokedEvent` (a ban, a password reset, change or an administrator's new password) also revokes the user's pending and confirmed sessions inside the write's transaction (`classes.RevokeDeviceSessions`). A pending or confirmed session past its expiry answers PHP's flat `{"success":true,"status":"expired","message":"Authorization session has expired"}`; a used or revoked session never turns into expired. The statuses are `pending`, `confirmed`, `used`, `expired` and `revoked`. Only the SHA-256 of the device token is stored (column `token`, `golem15_user_device_auth_sessions`), with the jti of the session token the device received (`jwt_jti`). The hand-over fires the login event with source `device`. Each initiate deletes up to 500 pending or expired sessions that expired over an hour ago. The token travels in the status URL, so a reverse proxy that logs paths records it; it is one-time and lives `ttl_minutes`. A signed-in user lists their devices with `GET /devices` (`{"success":true,"devices":[{"id","device_name","device_ip","authorized_at","last_activity","is_current"}]}`, confirmed and used sessions, most recent activity first; `device_name` is the stored name or one parsed from the user agent; `is_current` marks the session whose token the request carries) and revokes one with `DELETE /devices/{id}` (`{"success":true,"message":"Device revoked successfully"}`, 404 `{"error":"Device not found"}` for another user's session). A revoke marks the session `revoked` and blacklists the jti of the session token the device holds for the token's whole refresh window, so the device is signed out at once and cannot refresh. `POST /refresh` moves a used session's `jwt_jti` to the refreshed token (`classes.FollowDeviceJTI`), so a device that refreshed before the revoke is signed out too. The revoke reads the jti with its own `UPDATE … RETURNING`, under the row lock the refresh's hand-over takes, so a revoke and a refresh that race cannot leave the device signed in: a refresh that comes second finds the session revoked and answers 401 `{"error":"Could not refresh token","msg":"The token has been blacklisted"}` without a token. The hand-over fails closed: a database error answers the opaque 500 instead of a token the session does not follow. With `golem15.user.jwt.blacklist_grace` above 0, two concurrent refreshes of one token can fork a device's token lineage and only one fork is followed; a password change still ends both through `tokens_valid_after`. The device routes check the session inside the handler and answer PHP's 401 `{"error":"Unauthorized"}`.
|
||||||
- **Rate limiting.** Every route runs behind the `user-api` bucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls.
|
- **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).
|
||||||
- **Deactivation and bans.** Deactivation is self-service and reversible: a password login or a `golem15.sociallogin` sign-in restores a deactivated account and sends the reactivate mail, and an administrator can restore it too. It ends every earlier session JWT and personal API token of the user (`RevokeUserAccess` stamps `tokens_valid_after` and revokes the `user_api_tokens` rows), so the user signs in again and gets a fresh token, while the old API tokens stay revoked. A ban is the lock: password login, social sign-in and `POST /refresh` refuse a banned user, and banning revokes the user's sessions and API tokens. A banned user's account link answers no administrator (`LinkedBackendUser`). Neither unban nor restore revives a token. Activation gates login, deactivation does not: while `require_activation` is on, an account that is both deactivated and not activated is refused and stays deactivated, with no reactivate mail. Upgrade note: accounts deactivated or banned before this version keep their API tokens until they are deactivated or banned again (both actions are idempotent) or `RevokeUserAccess` runs for them.
|
- **Deactivation and bans.** Deactivation is self-service and reversible: a password login or a `golem15.sociallogin` sign-in restores a deactivated account and sends the reactivate mail, and an administrator can restore it too. It ends every earlier session JWT and personal API token of the user (`RevokeUserAccess` stamps `tokens_valid_after` and revokes the `user_api_tokens` rows), so the user signs in again and gets a fresh token, while the old API tokens stay revoked. A ban is the lock: password login, social sign-in and `POST /refresh` refuse a banned user, and banning revokes the user's sessions and API tokens. A banned user's account link answers no administrator (`LinkedBackendUser`). Neither unban nor restore revives a token. Activation gates login, deactivation does not: while `require_activation` is on, an account that is both deactivated and not activated is refused and stays deactivated, with no reactivate mail. Upgrade note: accounts deactivated or banned before this version keep their API tokens until they are deactivated or banned again (both actions are idempotent) or `RevokeUserAccess` runs for them.
|
||||||
- **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins. An organisation has a name, a slug (filled from the name by `Slugify` when it is left empty), a description, an avatar, a logo and its members: the users whose `organisation_id` points at it. The logo is a second public image attachment (`logo`, `models.OrganisationLogoField`), kept in `system_files` like the avatar. The plugin serves no frontend organisation payload, so applications that build one add the logo with `classes.OrganisationLogoFields(ctx, app, org)`, which answers `has_logo` (bool), `logo` (the attachment's disk name, or null) and `logo_url` (a 256 px thumbnail URL, `classes.OrganisationLogoSize`, or null), the keys the user payload uses for its avatar; `classes.OrganisationLogoURL(ctx, app, org, size)` answers the URL at any size (the thumbnail, or the original file's public URL when the thumbnail fails, as `AvatarURL` does) and false when there is no logo or no file bucket. `organisation_role` belongs to that membership: the admin clears it whenever a user's organisation changes (see the Organisations screen), and an application that sets `organisation_id` itself sets or clears the role with it.
|
- **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins. An organisation has a name, a slug (filled from the name by `Slugify` when it is left empty), a description, an avatar, a logo and its members: the users whose `organisation_id` points at it. The logo is a second public image attachment (`logo`, `models.OrganisationLogoField`), kept in `system_files` like the avatar. The plugin serves no frontend organisation payload, so applications that build one add the logo with `classes.OrganisationLogoFields(ctx, app, org)`, which answers `has_logo` (bool), `logo` (the attachment's disk name, or null) and `logo_url` (a 256 px thumbnail URL, `classes.OrganisationLogoSize`, or null), the keys the user payload uses for its avatar; `classes.OrganisationLogoURL(ctx, app, org, size)` answers the URL at any size (the thumbnail, or the original file's public URL when the thumbnail fails, as `AvatarURL` does) and false when there is no logo or no file bucket. `organisation_role` belongs to that membership: the admin clears it whenever a user's organisation changes (see the Organisations screen), and an application that sets `organisation_id` itself sets or clears the role with it.
|
||||||
- **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No user API route reads or writes groups, and the user payload still answers `"groups":[]`. Membership is written in one place only: the `groups` field of the admin user form. Apart from that, rows leave `users_groups` only when a user or a group is deleted for good. The admin Users list can filter by group.
|
- **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No user API route reads or writes groups, and the user payload still answers `"groups":[]`. Membership is written in one place only: the `groups` field of the admin user form. Apart from that, rows leave `users_groups` only when a user or a group is deleted for good. The admin Users list can filter by group.
|
||||||
- **Admin screens.** The plugin registers the Users screen with the framework's admin (`pact.HasAdminControllers`, controller ID `golem15.user.users`), the port of the WinterCMS `Users` controller: a list with search, the group, registration date and activated filters and row states (deactivated, banned, not activated); a read-only preview with a status hint; and a form with name, email, a password with confirmation, an optional invitation mail, the user's groups, the organisation, the avatar and the user's frontend permissions. Bulk actions activate, deactivate, restore, ban and unban users; the preview offers activate, unban and unsuspend while they apply. Deactivating is the soft delete, and deactivate and ban both end the user's earlier sessions and personal API tokens (see Deactivation and bans); the delete button and the bulk delete remove a user for good, together with the throttle rows, group memberships and attachments. A user created in the admin starts not activated. There is no impersonation and no guest account.
|
- **Admin screens.** The plugin registers the Users screen with the framework's admin (`pact.HasAdminControllers`, controller ID `golem15.user.users`), the port of the WinterCMS `Users` controller: a list with search, the group, registration date and activated filters and row states (deactivated, banned, not activated); a read-only preview with a status hint; and a form with name, email, a password with confirmation, an optional invitation mail, the user's groups, the organisation, the avatar and the user's frontend permissions. Bulk actions activate, deactivate, restore, ban and unban users; the preview offers activate, unban and unsuspend while they apply. Deactivating is the soft delete, and deactivate and ban both end the user's earlier sessions and personal API tokens (see Deactivation and bans); the delete button and the bulk delete remove a user for good, together with the throttle rows, group memberships and attachments. A user created in the admin starts not activated. There is no impersonation and no guest account.
|
||||||
|
- **Admin: Reset 2FA.** The Users screen declares the record action `reset_two_factor` ("Reset 2FA", danger confirmation) behind the backend permission `golem15.users.reset_two_factor`, which no role has by default. A two-factor plugin backs it by publishing a `classes.TwoFactorAdmin` (`app.Publish[classes.TwoFactorAdmin](svc)`): the screen looks the service up on every request and offers the action only when one is published and its `HasTwoFactor` reports a second factor for the user, so an application without a two-factor plugin never shows it. Running it calls the service's `Reset` in the action's transaction. On a member of a privileged group it also needs `golem15.users.manage_privileged_groups` (see Privileged groups): without it the action answers 403 and `Reset` does not run.
|
||||||
- **User Groups screen.** Controller ID `golem15.user.usergroups`, the port of the WinterCMS `UserGroups` controller, behind `golem15.users.access_groups`: a list with the name, code, creation date and number of members, and a form with the name, the code (letters, digits, underscore and hyphen; unique), a description and the group's frontend permissions as checkboxes, stored in `user_groups.permissions` as a JSON object of code to `1`. A group can be deleted from its form; its memberships go with it and its members stay. The screen never changes who is in a group.
|
- **User Groups screen.** Controller ID `golem15.user.usergroups`, the port of the WinterCMS `UserGroups` controller, behind `golem15.users.access_groups`: a list with the name, code, creation date and number of members, and a form with the name, the code (letters, digits, underscore and hyphen; unique), a description and the group's frontend permissions as checkboxes, stored in `user_groups.permissions` as a JSON object of code to `1`. A group can be deleted from its form; its memberships go with it and its members stay. The screen never changes who is in a group.
|
||||||
- **Organisations screen.** Controller ID `golem15.user.organisations`, the port of the WinterCMS `Organisations` controller, behind `golem15.users.access_users`: a list and a form with the name, slug, description, avatar and logo (an image upload), and on an existing organisation the Members tab. Adding a member sets that user's `organisation_id` and removing one clears it; both also clear the user's `organisation_role`, so a role never moves to another organisation or outlives the membership. A user is never deleted. Only users without an organisation can be added, so a user moves by being removed from one organisation and added to the other (or through the organisation field of the user form, where a save that changes the organisation, to another one or to none, clears `organisation_role` unless the same save writes it). Deleting an organisation clears `organisation_id` and `organisation_role` of its members first, and removes its avatar and logo attachments with it (the files once the delete commits).
|
- **Organisations screen.** Controller ID `golem15.user.organisations`, the port of the WinterCMS `Organisations` controller, behind `golem15.users.access_users`: a list and a form with the name, slug, description, avatar and logo (an image upload), and on an existing organisation the Members tab. Adding a member sets that user's `organisation_id` and removing one clears it; both also clear the user's `organisation_role`, so a role never moves to another organisation or outlives the membership. A user is never deleted. Only users without an organisation can be added, so a user moves by being removed from one organisation and added to the other (or through the organisation field of the user form, where a save that changes the organisation, to another one or to none, clears `organisation_role` unless the same save writes it). Deleting an organisation clears `organisation_id` and `organisation_role` of its members first, and removes its avatar and logo attachments with it (the files once the delete commits).
|
||||||
- **Privileged groups.** The groups whose code is listed in `golem15.user.privileged_groups` (default `admin`) are privileged: an application may treat their members as site administrators. Three things therefore need the backend permission `golem15.users.manage_privileged_groups` on top of the screen's own permission, and answer 403 without it, saving nothing. First, membership: adding a privileged group to a user or removing it, on create and on update of the user form. An administrator without the permission sees those groups locked in the field and can still change the user's other groups. Second, the code: creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group. Its name, description and frontend permissions need only `golem15.users.access_groups`. Codes compare case-sensitively, and a group without a code is never privileged. Third, the member's account, which is protected against takeover: changing that user's email or password, and deleting them for good, needs the backend permission `golem15.users.manage_privileged_groups` on top of `golem15.users.access_users`. Without it the request answers 403 and nothing is saved; a bulk delete that contains such a user deletes none of the selected users. Everything else on such a user (name, organisation, avatar, frontend permissions, activate, deactivate, restore, ban, unban, unsuspend) needs only `golem15.users.access_users`. `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs` and `IsPrivilegedMember` expose the rule to other code.
|
- **Privileged groups.** The groups whose code is listed in `golem15.user.privileged_groups` (default `admin`) are privileged: an application may treat their members as site administrators. Three things therefore need the backend permission `golem15.users.manage_privileged_groups` on top of the screen's own permission, and answer 403 without it, saving nothing. First, membership: adding a privileged group to a user or removing it, on create and on update of the user form. An administrator without the permission sees those groups locked in the field and can still change the user's other groups. Second, the code: creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group. Its name, description and frontend permissions need only `golem15.users.access_groups`. Codes compare case-sensitively, and a group without a code is never privileged. Third, the member's account, which is protected against takeover: changing that user's email or password, resetting their second factors (Reset 2FA), and deleting them for good, needs the backend permission `golem15.users.manage_privileged_groups` on top of `golem15.users.access_users`. Without it the request answers 403 and nothing is saved; a bulk delete that contains such a user deletes none of the selected users. Everything else on such a user (name, organisation, avatar, frontend permissions, activate, deactivate, restore, ban, unban, unsuspend) needs only `golem15.users.access_users`. `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs` and `IsPrivilegedMember` expose the rule to other code.
|
||||||
- **Frontend permissions.** `golem15_user_frontend_permissions` is the catalogue of permissions a site defines for its users (rows come from the application's migrations or seeds). A user's own choices are stored in `users.permissions` and a group's in `user_groups.permissions`, as a JSON object of code to `1` (allow) or `-1` (deny). `MergedPermissions` combines them as PHP `getMergedPermissions` does: among a user's groups the most positive value wins, then the user's own value replaces the groups'. `HasPermission` and `UserHasPermission` answer whether a code is held: only at exactly `1`, with Winter's `*` wildcards. Neither value appears in the user payload, whose `permissions` key stays `null`.
|
- **Frontend permissions.** `golem15_user_frontend_permissions` is the catalogue of permissions a site defines for its users (rows come from the application's migrations or seeds). A user's own choices are stored in `users.permissions` and a group's in `user_groups.permissions`, as a JSON object of code to `1` (allow) or `-1` (deny). `MergedPermissions` combines them as PHP `getMergedPermissions` does: among a user's groups the most positive value wins, then the user's own value replaces the groups'. `HasPermission` and `UserHasPermission` answer whether a code is held: only at exactly `1`, with Winter's `*` wildcards. Neither value appears in the user payload, whose `permissions` key stays `null`.
|
||||||
- **Last seen.** `users.last_seen` is stamped by `POST /login` and a successful `POST /refresh` through `TouchLastSeen`, at most once per five minutes per user. A failed write is logged and never fails authentication. It is shown in the admin list and is not part of any user payload.
|
- **Last seen.** `users.last_seen` is stamped by `POST /login` and a successful `POST /refresh` through `TouchLastSeen`, at most once per five minutes per user. A failed write is logged and never fails authentication. It is shown in the admin list and is not part of any user payload.
|
||||||
- **Address proof.** `users.email_verified_at` records when the account's owner proved control of the address (`User.EmailVerifiedAt`). Exactly two flows write it: the mailed activation code (`POST /activate`, `POST /activate-by-code` and the invitation link, through `VerifyActivationCode`) and a completed reset-code password reset (`POST /reset-password`). `POST /register` in `auto` or `admin` mode, `RegisterUser`, an administrator's create and the admin Activate action, `POST /change-password` and social sign-ins record none. The proof follows the address: an email change through `POST /update` or the admin Users form clears it, while a change of letter case only keeps it. The column is never mass-assigned and is in no user API payload, admin form or list. No migration or code path backfills it: accounts from before v0.1.5 have no proof. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not. A social-login plugin links a verified provider's identity onto an existing account only when it is set.
|
- **Address proof.** `users.email_verified_at` records when the account's owner proved control of the address (`User.EmailVerifiedAt`). Exactly two flows write it: the mailed activation code (`POST /activate`, `POST /activate-by-code` and the invitation link, through `VerifyActivationCode`) and a completed reset-code password reset (`POST /reset-password`). `POST /register` in `auto` or `admin` mode, `RegisterUser`, an administrator's create and the admin Activate action, `POST /change-password` and social sign-ins record none. The proof follows the address: an email change through `POST /update` or the admin Users form clears it, while a change of letter case only keeps it. The column is never mass-assigned and is in no user API payload, admin form or list. No migration or code path backfills it: accounts from before v0.1.5 have no proof. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not. A social-login plugin links a verified provider's identity onto an existing account only when it is set.
|
||||||
@@ -128,6 +133,11 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle
|
|||||||
| `POST /backend-link/show` | `controllers.BackendLinkShowPost` |
|
| `POST /backend-link/show` | `controllers.BackendLinkShowPost` |
|
||||||
| `POST /backend-link/confirm` | `controllers.BackendLinkConfirm` (`jwt.auth`) |
|
| `POST /backend-link/confirm` | `controllers.BackendLinkConfirm` (`jwt.auth`) |
|
||||||
| `GET /token/me` | `controllers.TokenMe` (`user.api_token`, `user.scope:read`) |
|
| `GET /token/me` | `controllers.TokenMe` (`user.api_token`, `user.scope:read`) |
|
||||||
|
| `POST /devices/initiate` | `controllers.DeviceInitiate` |
|
||||||
|
| `GET /devices/status/{token}` | `controllers.DeviceStatus` |
|
||||||
|
| `POST /devices/authorize` | `controllers.DeviceAuthorize` (session token checked in the handler, 401 `{"error":"Unauthorized"}`) |
|
||||||
|
| `GET /devices` | `controllers.DeviceIndex` (session token checked in the handler) |
|
||||||
|
| `DELETE /devices/{id}` | `controllers.DeviceDestroy` (session token checked in the handler; `id` is `[0-9]+`) |
|
||||||
|
|
||||||
`/fetch`, `/refresh`, `/update`, `/change-password`, `/activate`, `/marketing-consent`, `/avatar`, `/avatar/remove` and the `jwt.auth` routes read the session token from the bearer header, then the `token` and `auth_token` cookies.
|
`/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.
|
||||||
|
|
||||||
@@ -155,7 +165,7 @@ The three screens are served by the framework's generic admin API under `<admin
|
|||||||
| Navigation | main item `user` (order 555) with the side items `users`, `usergroups` and `organisations`, each shown to an administrator who holds its permission |
|
| 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; `deactivate` and `ban` also revoke the selected users' sessions and personal API tokens |
|
| Bulk actions (Users) | `activate`, `deactivate`, `restore`, `ban`, `unban`, and the built-in delete; `deactivate` and `ban` also revoke the selected users' sessions and personal API tokens |
|
||||||
| Record actions (Users) | `activate`, `unban`, `unsuspend` |
|
| Record actions (Users) | `activate`, `unban`, `unsuspend`, and `reset_two_factor` (danger, behind `golem15.users.reset_two_factor`, offered only while a published `classes.TwoFactorAdmin` reports a second factor) |
|
||||||
| Record actions (Administrators, `summercms.cabana.users`) | `link_frontend_account` (asks for the frontend user's email) and `unlink_frontend_account` (danger), both behind `golem15.users.manage_backend_links`, from `AdminActionExtensions` |
|
| 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` |
|
| My account section | `frontend_account` with the actions `link` and `unlink`, from `AccountContributions` |
|
||||||
|
|
||||||
@@ -169,8 +179,9 @@ Backend permissions, all on the tab `golem15.user::lang.plugin.tab`:
|
|||||||
| `golem15.users.access_groups` | `developer` | The User Groups screen. |
|
| `golem15.users.access_groups` | `developer` | The User Groups screen. |
|
||||||
| `golem15.users.access_settings` | `developer` | Registered for roles that already name it; no screen uses it yet. |
|
| `golem15.users.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, resetting their second factors, and deleting one for good. Only a superuser or a role that was granted it holds it. |
|
||||||
| `golem15.users.manage_backend_links` | none | Linking a frontend account to another administrator, and unlinking one, on the Administrators screen. Only a superuser or a role that was granted it holds it. |
|
| `golem15.users.manage_backend_links` | none | Linking a frontend account to another administrator, and unlinking one, on the Administrators screen. Only a superuser or a role that was granted it holds it. |
|
||||||
|
| `golem15.users.reset_two_factor` | none | The Users screen's "Reset 2FA" record action, which removes every second factor of a user through the two-factor plugin's `classes.TwoFactorAdmin`. Only a superuser or a role that was granted it holds it. |
|
||||||
|
|
||||||
Migrations added for the admin, all additive:
|
Migrations added for the admin, all additive:
|
||||||
|
|
||||||
@@ -204,6 +215,12 @@ Migrations added for the frontend account link, all additive:
|
|||||||
|----|------|
|
|----|------|
|
||||||
| `202610220001_create_golem15_user_backend_links` | the tables `golem15_user_backend_links` (`user_id` unique, cascade FK to `users`; `backend_user_id` unique, cascade FK to `backend_users`; `linked_by` FK to `backend_users`, set null on delete; `created_at`) and `golem15_user_backend_link_requests` (`code_hash` `CHAR(64)` unique, `backend_user_id` cascade FK, `expires_at`, `used_at`, `created_at`). The `backend_users` keys are added when that table exists, which it does whenever the application migrates through `lagoon.Migrate`. |
|
| `202610220001_create_golem15_user_backend_links` | the tables `golem15_user_backend_links` (`user_id` unique, cascade FK to `users`; `backend_user_id` unique, cascade FK to `backend_users`; `linked_by` FK to `backend_users`, set null on delete; `created_at`) and `golem15_user_backend_link_requests` (`code_hash` `CHAR(64)` unique, `backend_user_id` cascade FK, `expires_at`, `used_at`, `created_at`). The `backend_users` keys are added when that table exists, which it does whenever the application migrates through `lagoon.Migrate`. |
|
||||||
|
|
||||||
|
Migration added for device authorization, additive:
|
||||||
|
|
||||||
|
| ID | Adds |
|
||||||
|
|----|------|
|
||||||
|
| `202610260001_create_device_auth_sessions` | the table `golem15_user_device_auth_sessions` with PHP's columns (`token` holding the SHA-256 hex of the device token, unique; `short_code` unique; `user_id` nullable, cascade FK to `users`; `status` checked against the five statuses; `expires_at`, `device_ip`, `device_user_agent`, `device_name`, `auth_ip`, `confirmed_at`, `used_at`, `session_id`, `last_activity_at`) plus `jwt_jti`, indexed on `user_id`, `status`, `expires_at` and `jwt_jti`; rollback drops it |
|
||||||
|
|
||||||
Packages:
|
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:
|
||||||
@@ -215,7 +232,10 @@ Packages:
|
|||||||
- `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`, `BackendLinkShowPost`, `BackendLinkConfirm` and `TokenMe`, the handlers of the account link and `/token/me` routes.
|
- `BackendLinkShow`, `BackendLinkShowPost`, `BackendLinkConfirm` and `TokenMe`, the handlers of the account link and `/token/me` routes.
|
||||||
- `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners.
|
- `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners.
|
||||||
- `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks.
|
- `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks, consults the login gates before it and fires the login event after it minted the token.
|
||||||
|
- `CheckLoginGates(ctx, app, attempt)` consults the published `*classes.LoginGates` (none published allows the sign-in) and returns its answer: nil allows, a `*classes.LoginGateResult` replaces the token response, an error must fail the sign-in closed. A social-login plugin calls it before it issues a token.
|
||||||
|
- `FireLoginEvent(ctx, app, user, source)` fires `classes.LoginEvent` and returns the listeners' error; call it after the token was minted and log the error.
|
||||||
|
- `DeviceInitiate`, `DeviceStatus`, `DeviceAuthorize`, `DeviceIndex` and `DeviceDestroy`, the device authorization handlers (see Device authorization).
|
||||||
- `RegistrationSettings(app)` returns `allow_registration`, `use_register_throttle`, `activate_mode` and `require_activation` with their defaults (`true`, `true`, `auto`, `true`), and `RegistrationsFromIP(ctx, db, ip)` counts the accounts created from an IP in the last 60 minutes (`/register` refuses at 3 with the throttle on).
|
- `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`).
|
- `RefreshTTL(app)` is the refresh window, `golem15.user.jwt.refresh_ttl` minutes (default 20160), and `RemoteIP(r)` the host part of `r.RemoteAddr`, with no proxy handling (`/login` and `/register` use `surf.ClientIP` with `http.trusted_proxies`).
|
||||||
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `MintAPITokenFor`, `HashAPIToken` and `RevokeAPIToken`, the member token scope catalog `APITokenScope`, `APITokenScopeCatalog` (`Scopes`, `Allowed`), `CompileAPITokenScopes`, `APITokenScopesFor` and the test seam `ResetDeclaredAPITokenScopes`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
|
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `MintAPITokenFor`, `HashAPIToken` and `RevokeAPIToken`, the member token scope catalog `APITokenScope`, `APITokenScopeCatalog` (`Scopes`, `Allowed`), `CompileAPITokenScopes`, `APITokenScopesFor` and the test seam `ResetDeclaredAPITokenScopes`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
|
||||||
@@ -233,9 +253,13 @@ Packages:
|
|||||||
- `BackendLinks` (`CreateLinkRequest`, `LinkRequestByCode`, `ConfirmLinkRequest`, `SetLink`, `RemoveLink`, `LinkFor`, `LinkOf`, `LinkedBackendUser`, `RegisterBackendLinkListeners`), its errors `ErrLinkRequestNotFound`, `ErrLinkRequestUsed`, `ErrLinkRequestExpired`, `ErrStaleSession`, `ErrAlreadyLinked`, `ErrBackendUserUnavailable` and `ErrFrontendUserUnavailable`, the events `BackendLinkedEvent` / `BackendLinkedEventName` and `BackendUnlinkedEvent` / `BackendUnlinkedEventName`, `BackendLinkRequestTTL(app)`, `DefaultBackendLinkRequestTTL`, `FrontendURL(app)`, `BackendLinkPagePath(app)`, `DefaultBackendLinkPagePath`, `BackendLinksFor(app)` (the published service, or one over the app) and `BackendDisplayName(admin)`.
|
- `BackendLinks` (`CreateLinkRequest`, `LinkRequestByCode`, `ConfirmLinkRequest`, `SetLink`, `RemoveLink`, `LinkFor`, `LinkOf`, `LinkedBackendUser`, `RegisterBackendLinkListeners`), its errors `ErrLinkRequestNotFound`, `ErrLinkRequestUsed`, `ErrLinkRequestExpired`, `ErrStaleSession`, `ErrAlreadyLinked`, `ErrBackendUserUnavailable` and `ErrFrontendUserUnavailable`, the events `BackendLinkedEvent` / `BackendLinkedEventName` and `BackendUnlinkedEvent` / `BackendUnlinkedEventName`, `BackendLinkRequestTTL(app)`, `DefaultBackendLinkRequestTTL`, `FrontendURL(app)`, `BackendLinkPagePath(app)`, `DefaultBackendLinkPagePath`, `BackendLinksFor(app)` (the published service, or one over the app) and `BackendDisplayName(admin)`.
|
||||||
- `PermissionManageBackendLinks`, `FrontendPermissionCatalog(ctx, app, db)`, `MCPUserGuard`, `FireSubjectChanged(ctx, app, guard, userID, kind)` and `FireSubjectChangedAfterCommit(ctx, app, tx, guard, userID, kind)` with the guard names `SubjectGuardUser` and `SubjectGuardBackend`, and `FireSubjectEvent(ctx, app, ev)` and `FireSubjectEventAfterCommit(ctx, app, tx, ev)`, which fire a whole `pact.MCPSubjectChanged` (with `TokenID` or `ClientID` to end only one token's or one client's sessions).
|
- `PermissionManageBackendLinks`, `FrontendPermissionCatalog(ctx, app, db)`, `MCPUserGuard`, `FireSubjectChanged(ctx, app, guard, userID, kind)` and `FireSubjectChangedAfterCommit(ctx, app, tx, guard, userID, kind)` with the guard names `SubjectGuardUser` and `SubjectGuardBackend`, and `FireSubjectEvent(ctx, app, ev)` and `FireSubjectEventAfterCommit(ctx, app, tx, ev)`, which fire a whole `pact.MCPSubjectChanged` (with `TokenID` or `ClientID` to end only one token's or one client's sessions).
|
||||||
- `AccessRevokedEvent` / `AccessRevokedEventName` and `FireAccessRevoked(ctx, app, tx, userIDs, before, reason)` (see Access revocation).
|
- `AccessRevokedEvent` / `AccessRevokedEventName` and `FireAccessRevoked(ctx, app, tx, userIDs, before, reason)` (see Access revocation).
|
||||||
|
- `LoginAttempt`, `LoginGateResult`, `LoginGate` (`CheckLogin(ctx, attempt)`), `LoginGates` with `NewLoginGates()`, `Add(gate)` and `Check(ctx, attempt)`, the login-gate seam (see Login gates), and the sources `LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor` and `LoginSourceDevice`.
|
||||||
|
- `LoginEvent` / `LoginEventName` (see Login event).
|
||||||
|
- `DeviceShortCodeAlphabet`, `NewDeviceToken()`, `NewDeviceShortCode()`, `NormalizeDeviceShortCode(s)`, `DeviceAuthTTL(app)`, `DeviceAuthURLFor(app, token, shortCode)`, `DeviceAuthURL(app, token)` (the same with the token in place of the short code), `DeviceDisplayName(stored, userAgent)`, `RevokeDeviceSessions(ctx, db, userIDs...)` (revokes the users' pending and confirmed sessions; the plugin calls it for every `AccessRevokedEvent`), `FollowDeviceJTI(ctx, db, oldJTI, newJTI)` (moves a used session to a refreshed token's jti and reports a session revoked meanwhile; `POST /refresh` calls it and refuses the refresh then) and `HandOverDeviceJTI(ctx, db, oldJTI, newJTI)` (the same move without the report), the device authorization helpers; device tokens are hashed with `HashAPIToken`.
|
||||||
|
- `TwoFactorAdmin` (`HasTwoFactor(ctx, tx, userID)`, `Reset(ctx, tx, user)`) and `PermissionResetTwoFactor`, the Reset 2FA slot (see Admin: Reset 2FA), and `(*AccessRevokedEvent).PasswordChanged()` (see Password-change hook).
|
||||||
- `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
|
- `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`, `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 its logo (`OrganisationLogoField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code.
|
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `BackendLink`, `BackendLinkRequest`, `DeviceAuthSession` (with `DeviceStatusPending`, `DeviceStatusConfirmed`, `DeviceStatusUsed`, `DeviceStatusExpired` and `DeviceStatusRevoked`) and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`) and its logo (`OrganisationLogoField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code.
|
||||||
- `updates`: the gormigrate migrations, returned by `updates.All()`.
|
- `updates`: the gormigrate migrations, returned by `updates.All()`.
|
||||||
- `console`: `RequirePasswordChange`, `BackfillHandles` and `APITokenCommands`.
|
- `console`: `RequirePasswordChange`, `BackfillHandles` and `APITokenCommands`.
|
||||||
|
|
||||||
@@ -265,8 +289,10 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
|
|||||||
| `golem15.user.password.min_length` | `8` | Minimum password length. |
|
| `golem15.user.password.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.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, Reset 2FA and permanent delete of its members need `golem15.users.manage_privileged_groups`. Read on every request. An empty list protects no group. |
|
||||||
| `golem15.user.frontend_url` | `""` | The site the plugin's own links point to: the account link page is this URL + `backend_links.page_path` + `#code=<code>`. Empty falls back to `app.url`; a trailing slash is dropped. |
|
| `golem15.user.frontend_url` | `""` | The site the plugin's own links point to: the account link page is this URL + `backend_links.page_path` + `#code=<code>`, and it fills `{frontend_url}` in `device_auth.auth_url`. Empty falls back to `app.url`; a trailing slash is dropped. |
|
||||||
|
| `golem15.user.device_auth.auth_url` | `{frontend_url}/device/scan/{short_code}` | The `auth_url` a device shows as its QR code, where a signed-in user authorizes it. `{frontend_url}` is `golem15.user.frontend_url` (else `app.url`, no trailing slash), `{short_code}` the session's short code and `{token}` the device token. `POST /devices/authorize` takes the short code in `token` too. Keep `{token}` out of it: the device token is also the secret the device collects its session token with. |
|
||||||
|
| `golem15.user.device_auth.ttl_minutes` | `5` | Minutes a device session can be authorized and handed over. |
|
||||||
| `golem15.user.backend_links.page_path` | `/link-admin` | The site page that confirms an account link. A value without a leading `/` gets one. |
|
| `golem15.user.backend_links.page_path` | `/link-admin` | The site page that confirms an account link. A value without a leading `/` gets one. |
|
||||||
| `golem15.user.backend_links.request_ttl` | `10m` | How long a link request an administrator starts on My account stays usable (a Go duration). A value that does not parse or is not positive fails boot. |
|
| `golem15.user.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. |
|
| `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. |
|
||||||
@@ -327,4 +353,8 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
|
|||||||
|
|
||||||
`TestMemberTokenScopesBoot` (no Docker) checks that the scope Boot step fails for a name two plugins declare (naming both), for a redeclared `read`, `write` or `ai`, for a bad name, an empty label and a missing phrase key. With Docker: `TestMemberTokenScopes` walks the member token scope acceptance list item by item (mint and `middleware.Scope`, the 403 message, the 422 for an undeclared scope, the list, discovery in en and pl, the per-application catalog and the legacy fallback); `TestBackendLinkAuthTime` checks the link proof against login, request, refresh and re-login orders, a token without `auth_time` and another user's session token; `TestBackendLinkShowPost` checks that `POST /backend-link/show` answers like the GET route with `Cache-Control: no-store, private`, the fragment URL and `backend_links.page_path`; `TestAccessRevocation` checks `AccessRevokedEvent` from the ban action, the password reset, the password change and an administrator setting a password (ids, `Before`, reason, the transaction, the subject event after commit, the rollback on a failing listener) and that a banned user loses the backend link until unbanned; `TestUserMCPGuardClientIP` checks `last_used_ip` on the MCP personal-token path; `TestAPITokenRevokeEvent` checks the token-specific revoked event of `DELETE /tokens/{id}`; `TestOrganisationLogo` checks the logo field, the upload, `OrganisationLogoFields` with and without a logo, the URL fallback when the thumbnail fails, and that deleting an organisation removes its avatar and logo attachments, the rows and, once the delete commits, the blobs and thumbnails.
|
`TestMemberTokenScopesBoot` (no Docker) checks that the scope Boot step fails for a name two plugins declare (naming both), for a redeclared `read`, `write` or `ai`, for a bad name, an empty label and a missing phrase key. With Docker: `TestMemberTokenScopes` walks the member token scope acceptance list item by item (mint and `middleware.Scope`, the 403 message, the 422 for an undeclared scope, the list, discovery in en and pl, the per-application catalog and the legacy fallback); `TestBackendLinkAuthTime` checks the link proof against login, request, refresh and re-login orders, a token without `auth_time` and another user's session token; `TestBackendLinkShowPost` checks that `POST /backend-link/show` answers like the GET route with `Cache-Control: no-store, private`, the fragment URL and `backend_links.page_path`; `TestAccessRevocation` checks `AccessRevokedEvent` from the ban action, the password reset, the password change and an administrator setting a password (ids, `Before`, reason, the transaction, the subject event after commit, the rollback on a failing listener) and that a banned user loses the backend link until unbanned; `TestUserMCPGuardClientIP` checks `last_used_ip` on the MCP personal-token path; `TestAPITokenRevokeEvent` checks the token-specific revoked event of `DELETE /tokens/{id}`; `TestOrganisationLogo` checks the logo field, the upload, `OrganisationLogoFields` with and without a logo, the URL fallback when the thumbnail fails, and that deleting an organisation removes its avatar and logo attachments, the rows and, once the delete commits, the blobs and thumbnails.
|
||||||
|
|
||||||
|
The login-gate seam, the password-change hook and the Reset 2FA slot have their own tests. `TestLoginGates` (in `-short`, clean under `-race`) pins the registry: a nil or empty registry allows, gates run in the order they were added, the first result or error wins and stops the chain, nil gates are ignored, and `controllers.CheckLoginGates` allows when no registry is published. `TestLoginGateOrder` pins where `POST /login` asks the gates: refused first factors (wrong password, unknown email, not activated, banned) never reach them; the gate sees the attempt with the body already read; the bcrypt rehash happens before a challenge and fires no password-change event; a challenged deactivated account stays deactivated with no mail and no login event, while an allowed one is restored with one reactivate mail and one login event; a gate's status and body pass through; `/refresh` never asks a gate. `TestPasswordChangedSources` checks that the reset, the user's own change and an administrator setting a password each fire one `AccessRevokedEvent` with `PasswordChanged()` true inside the write's transaction (a listener's write through `ev.Tx` commits with it, a listener error rolls both back), and that the rehash and a registration fire none. `TestResetTwoFactorAction` covers the `reset_two_factor` record action: not offered without a published `classes.TwoFactorAdmin`, offered with the danger confirmation when one reports a second factor, hidden when it reports none, refused without `golem15.users.reset_two_factor`, `Run` refused outside the action's transaction, `Reset` committed with the action and rolled back by its error, and a privileged-group member refused with 403 before `Reset` without `golem15.users.manage_privileged_groups`. The plan 01 smoke tests (`TestLoginGatesSmoke`, `TestLoginGateSmoke`, `TestPasswordChangedEventSmoke`, `TestResetTwoFactorSlotSmoke`) stay. The Postgres tests need Docker.
|
||||||
|
|
||||||
|
The device authorization flow is pinned by `TestDeviceCodes` (in `classes`, `-short`: 10,000 tokens and short codes, the alphabet in every position, no modulo bias, the normalization table), `TestDeviceAuthURL` and `TestDeviceDisplayName` (both `-short`: the `auth_url` template with `{short_code}` as the default, the TTL setting and PHP's user-agent order), and on Postgres through the assembled routes: `TestDeviceInitiate` (PHP's answer with an `auth_url` that carries the short code and not the token, the stored pending row, the TTL, the 255-character `device_name` bound, the cleanup of at most 500 stale pending or expired sessions per call, the retry of an insert whose short code collides with a stored one); `TestDeviceStatus` (unknown, pending, PHP's flat expired body for a pending or confirmed session past `expires_at`, used and revoked sessions that never expire); `TestDeviceAuthorize` (short-code forms, the 422 and 400 refusals, the token path, the short code in the token field, body-only fields, sessions bound to another user, a repeated authorize, expired, used and revoked sessions, concurrent authorizes by two users binding one, and the 401 for a missing, invalid, foreign-signed or ended session); `TestDeviceHandOverConcurrent` (50 concurrent polls hand one token over with one login event of source `device`; a poll with another `User-Agent` than the initiating device's takes nothing and the device still collects its token; a deactivated or banned user's session becomes revoked); `TestDeviceAccessRevocation` (a password change revokes the user's confirmed sessions and keeps used ones and other users' sessions, and a session authorized before `tokens_valid_after` hands nothing over without the event); `TestDeviceListAndRevoke` (only confirmed and used sessions, newest activity first, `is_current`, the display name, an empty list as `[]`, 404 for foreign and unknown ids, a repeated revoke, a revoked device signed out); `TestDeviceRevokeAfterRefresh` (the row follows one and two refreshes, a revoke ends the whole token lineage and the blacklist entry outlives the refresh window, a failed refresh leaves the row); `TestDeviceRevokeRacesRefresh` (a refresh of a session revoked after the blacklist check is refused without a token, and a device refreshing in a tight loop while it is revoked ends with its last token refused); `TestDeviceTokensAtRest` (the token column is the SHA-256 hex, no column holds the token or the session JWT). `TestDeviceAuthSessionsMigration` (in `updates`) checks the table's PHP column names plus `jwt_jti`, the types, the unique `token` and `short_code`, the status CHECK, the indexes, the cascade on user delete, the rollback and a second up. The plan 01 smoke tests `TestDeviceCodesSmoke`, `TestDeviceFlowSmoke` and `TestDeviceRevokeSmoke` stay.
|
||||||
|
|
||||||
The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`.
|
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`.
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ const userPermissionTab = "golem15.user::lang.plugin.tab"
|
|||||||
// or password of a privileged-group member or delete one. The sixth
|
// or password of a privileged-group member or delete one. The sixth
|
||||||
// (classes.PermissionManageBackendLinks) has no default role either: it
|
// (classes.PermissionManageBackendLinks) has no default role either: it
|
||||||
// links and unlinks another administrator's frontend account on the
|
// links and unlinks another administrator's frontend account on the
|
||||||
// Administrators screen.
|
// Administrators screen. The seventh (classes.PermissionResetTwoFactor) has
|
||||||
|
// no default role either: it runs the Users screen's "Reset 2FA" action,
|
||||||
|
// which a two-factor plugin backs.
|
||||||
func (*Plugin) Permissions() []pact.Permission {
|
func (*Plugin) Permissions() []pact.Permission {
|
||||||
return []pact.Permission{
|
return []pact.Permission{
|
||||||
{
|
{
|
||||||
@@ -53,5 +55,10 @@ func (*Plugin) Permissions() []pact.Permission {
|
|||||||
Tab: userPermissionTab,
|
Tab: userPermissionTab,
|
||||||
Label: "golem15.user::lang.plugin.manage_backend_links",
|
Label: "golem15.user::lang.plugin.manage_backend_links",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Code: classes.PermissionResetTwoFactor,
|
||||||
|
Tab: userPermissionTab,
|
||||||
|
Label: "golem15.user::lang.plugin.reset_two_factor",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ var langKey = regexp.MustCompile(`golem15\.user::lang\.[a-z0-9_]+(\.[a-z0-9_]+)+
|
|||||||
func TestAdminRegistration(t *testing.T) {
|
func TestAdminRegistration(t *testing.T) {
|
||||||
plugin := &Plugin{}
|
plugin := &Plugin{}
|
||||||
|
|
||||||
// The six permission codes, each on the plugin's tab.
|
// The seven permission codes, each on the plugin's tab.
|
||||||
var codes []string
|
var codes []string
|
||||||
for _, permission := range plugin.Permissions() {
|
for _, permission := range plugin.Permissions() {
|
||||||
codes = append(codes, permission.Code)
|
codes = append(codes, permission.Code)
|
||||||
@@ -40,15 +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",
|
"golem15.users.manage_backend_links", "golem15.users.reset_two_factor",
|
||||||
}
|
}
|
||||||
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 and backend-links permissions have no default
|
// The privileged-groups, backend-links and reset-2FA permissions have no
|
||||||
// role: nobody holds them until they are granted on purpose.
|
// default 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 || permission.Code == permManageBackendLinks; privileged != (len(permission.Roles) == 0) {
|
if privileged := permission.Code == permManagePrivileged || permission.Code == permManageBackendLinks || permission.Code == permResetTwoFactor; privileged != (len(permission.Roles) == 0) {
|
||||||
t.Fatalf("permission %s has roles %v", permission.Code, permission.Roles)
|
t.Fatalf("permission %s has roles %v", permission.Code, permission.Roles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,14 @@ func TestAdminUsersControllerGuards(t *testing.T) {
|
|||||||
if action.Name == "activate" {
|
if action.Name == "activate" {
|
||||||
continue // reads the record only
|
continue // reads the record only
|
||||||
}
|
}
|
||||||
|
if action.Name == "reset_two_factor" {
|
||||||
|
// No classes.TwoFactorAdmin is published, so it reads
|
||||||
|
// nothing and is simply not offered.
|
||||||
|
if applies, err := action.Applies(ctx, user); applies || err != nil {
|
||||||
|
t.Fatalf("applies reset_two_factor without a service = %v, %v", applies, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
_, err := action.Applies(ctx, user)
|
_, err := action.Applies(ctx, user)
|
||||||
wantPlainError(t, "applies "+action.Name, err)
|
wantPlainError(t, "applies "+action.Name, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ func TestAdminUsersTracer(t *testing.T) {
|
|||||||
"golem15.users.access_users@developer", "golem15.users.access_groups@developer",
|
"golem15.users.access_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.PermissionManageBackendLinks + "@-",
|
classes.PermissionManagePrivilegedGroups + "@-", classes.PermissionManageBackendLinks + "@-",
|
||||||
|
classes.PermissionResetTwoFactor + "@-",
|
||||||
}; !reflect.DeepEqual(codes, want) || classes.PermissionManagePrivilegedGroups != permManagePrivileged {
|
}; !reflect.DeepEqual(codes, want) || classes.PermissionManagePrivilegedGroups != permManagePrivileged {
|
||||||
t.Fatalf("permissions = %v", codes)
|
t.Fatalf("permissions = %v", codes)
|
||||||
}
|
}
|
||||||
@@ -610,7 +611,7 @@ func TestAdminUserActions(t *testing.T) {
|
|||||||
registered = append(registered, action.Name)
|
registered = append(registered, action.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if want := []string{"activate", "unban", "unsuspend"}; !reflect.DeepEqual(registered, want) {
|
if want := []string{"activate", "unban", "unsuspend", "reset_two_factor"}; !reflect.DeepEqual(registered, want) {
|
||||||
t.Fatalf("record actions = %v, want %v", registered, want)
|
t.Fatalf("record actions = %v, want %v", registered, want)
|
||||||
}
|
}
|
||||||
var form struct {
|
var form struct {
|
||||||
|
|||||||
201
classes/device_auth.go
Normal file
201
classes/device_auth.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeviceShortCodeAlphabet is the alphabet of device short codes (PHP's
|
||||||
|
// recovery-code alphabet: no 0, 1, I, L or O).
|
||||||
|
const DeviceShortCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
||||||
|
|
||||||
|
// defaultDeviceAuthURL is golem15.user.device_auth.auth_url when unset. It
|
||||||
|
// carries the short code, never the device token: the token is the secret
|
||||||
|
// the device polls the session token with, and a QR code, a scan URL in a
|
||||||
|
// browser history or a Referer must not hand it to an onlooker.
|
||||||
|
const defaultDeviceAuthURL = "{frontend_url}/device/scan/{short_code}"
|
||||||
|
|
||||||
|
// defaultDeviceAuthTTL is golem15.user.device_auth.ttl_minutes when unset.
|
||||||
|
const defaultDeviceAuthTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
// NewDeviceToken returns a fresh device token: 48 bytes from crypto/rand as
|
||||||
|
// unpadded base64url, 64 characters. Store only its HashAPIToken.
|
||||||
|
func NewDeviceToken() (string, error) {
|
||||||
|
var b [48]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDeviceShortCode returns a fresh short code XXXX-XXXX, eight characters
|
||||||
|
// drawn uniformly from DeviceShortCodeAlphabet with crypto/rand (rejection
|
||||||
|
// sampling, no modulo bias).
|
||||||
|
func NewDeviceShortCode() (string, error) {
|
||||||
|
n := len(DeviceShortCodeAlphabet)
|
||||||
|
limit := 256 - 256%n // largest multiple of n that fits a byte
|
||||||
|
out := make([]byte, 0, 9)
|
||||||
|
var buf [16]byte
|
||||||
|
for len(out) < 9 {
|
||||||
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, c := range buf {
|
||||||
|
if int(c) >= limit {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(out) == 4 {
|
||||||
|
out = append(out, '-')
|
||||||
|
}
|
||||||
|
out = append(out, DeviceShortCodeAlphabet[int(c)%n])
|
||||||
|
if len(out) == 9 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeDeviceShortCode turns what a user typed into the stored form
|
||||||
|
// XXXX-XXXX: upper case, spaces and hyphens removed, exactly eight
|
||||||
|
// characters of DeviceShortCodeAlphabet. It reports false otherwise.
|
||||||
|
func NormalizeDeviceShortCode(s string) (string, bool) {
|
||||||
|
s = strings.ToUpper(s)
|
||||||
|
s = strings.NewReplacer(" ", "", "-", "").Replace(s)
|
||||||
|
if len(s) != 8 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if strings.IndexByte(DeviceShortCodeAlphabet, s[i]) < 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s[:4] + "-" + s[4:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceAuthTTL is how long a device authorization session can be
|
||||||
|
// authorized and handed over: golem15.user.device_auth.ttl_minutes, default
|
||||||
|
// 5.
|
||||||
|
func DeviceAuthTTL(app *backpack.App) time.Duration {
|
||||||
|
if app != nil && app.Config != nil {
|
||||||
|
if mins := app.Config.Int("golem15.user.device_auth.ttl_minutes"); mins > 0 {
|
||||||
|
return time.Duration(mins) * time.Minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultDeviceAuthTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceAuthURL is DeviceAuthURLFor without a short code: {short_code}
|
||||||
|
// is filled with the device token, as the template's only key.
|
||||||
|
func DeviceAuthURL(app *backpack.App, token string) string {
|
||||||
|
return DeviceAuthURLFor(app, token, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceAuthURLFor is the auth_url a new device shows as its QR code: the
|
||||||
|
// template golem15.user.device_auth.auth_url (default
|
||||||
|
// {frontend_url}/device/scan/{short_code}) with {frontend_url} replaced by
|
||||||
|
// FrontendURL (golem15.user.frontend_url, else app.url, no trailing slash),
|
||||||
|
// {short_code} by the session's short code and {token} by the device
|
||||||
|
// token. POST /devices/authorize accepts the short code in its token field
|
||||||
|
// too, so a page that posts what its URL carries as token works with
|
||||||
|
// either template. An empty shortCode fills {short_code} with the token.
|
||||||
|
func DeviceAuthURLFor(app *backpack.App, token, shortCode string) string {
|
||||||
|
tpl := ""
|
||||||
|
if app != nil && app.Config != nil {
|
||||||
|
tpl = strings.TrimSpace(app.Config.String("golem15.user.device_auth.auth_url"))
|
||||||
|
}
|
||||||
|
if tpl == "" {
|
||||||
|
tpl = defaultDeviceAuthURL
|
||||||
|
}
|
||||||
|
if shortCode == "" {
|
||||||
|
shortCode = token
|
||||||
|
}
|
||||||
|
return strings.NewReplacer("{frontend_url}", FrontendURL(app), "{short_code}", shortCode, "{token}", token).Replace(tpl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceDisplayName is the name a device list shows (PHP's device_name
|
||||||
|
// accessor): the stored name, else a name parsed from the user agent
|
||||||
|
// (iPhone, iPad, Android Device, Windows PC, Mac, Linux PC), else Unknown
|
||||||
|
// Device.
|
||||||
|
func DeviceDisplayName(stored *string, userAgent string) string {
|
||||||
|
if stored != nil && *stored != "" {
|
||||||
|
return *stored
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(userAgent, "iPhone"):
|
||||||
|
return "iPhone"
|
||||||
|
case strings.Contains(userAgent, "iPad"):
|
||||||
|
return "iPad"
|
||||||
|
case strings.Contains(userAgent, "Android"):
|
||||||
|
return "Android Device"
|
||||||
|
case strings.Contains(userAgent, "Windows"):
|
||||||
|
return "Windows PC"
|
||||||
|
case strings.Contains(userAgent, "Macintosh"):
|
||||||
|
return "Mac"
|
||||||
|
case strings.Contains(userAgent, "Linux"):
|
||||||
|
return "Linux PC"
|
||||||
|
}
|
||||||
|
return "Unknown Device"
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandOverDeviceJTI moves a used device session from the jti of a session
|
||||||
|
// token to the jti of the token that refreshed it, and stamps its
|
||||||
|
// last_activity_at, so revoking the device still reaches the token the
|
||||||
|
// device holds now. It is FollowDeviceJTI without the revoked report;
|
||||||
|
// empty values do nothing.
|
||||||
|
func HandOverDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) error {
|
||||||
|
_, err := FollowDeviceJTI(ctx, db, oldJTI, newJTI)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeDeviceSessions marks the pending and confirmed device sessions of
|
||||||
|
// userIDs revoked, so none of them hands a session token over any more.
|
||||||
|
// Used sessions keep their status: their tokens end through
|
||||||
|
// tokens_valid_after or a revoke. The plugin calls it on db, the write's
|
||||||
|
// transaction, for every AccessRevokedEvent (a ban or a password change).
|
||||||
|
func RevokeDeviceSessions(ctx context.Context, db *gorm.DB, userIDs ...uint) error {
|
||||||
|
if db == nil || len(userIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return db.WithContext(ctx).Exec(`UPDATE golem15_user_device_auth_sessions
|
||||||
|
SET status = 'revoked', updated_at = NOW()
|
||||||
|
WHERE user_id IN ? AND status IN ('pending', 'confirmed')`, userIDs).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FollowDeviceJTI moves a used device session from oldJTI, the jti of the
|
||||||
|
// session token a refresh presented, to newJTI, the jti of the token it
|
||||||
|
// minted, and stamps last_activity_at. It reports revoked when no used
|
||||||
|
// session moved but a session with oldJTI exists in another status: the
|
||||||
|
// device was revoked after the refresh passed the blacklist check, so the
|
||||||
|
// caller must refuse the refresh and drop the new token. The move is one
|
||||||
|
// guarded UPDATE that takes the session's row lock, as the revoke's
|
||||||
|
// UPDATE … RETURNING jwt_jti does (controllers.DeviceDestroy): a revoke
|
||||||
|
// that runs after the move blacklists newJTI, and a move that runs after
|
||||||
|
// the revoke matches nothing and reports revoked. POST /refresh calls it
|
||||||
|
// and fails closed on an error. Empty or equal values do nothing.
|
||||||
|
func FollowDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) (revoked bool, err error) {
|
||||||
|
if db == nil || oldJTI == "" || newJTI == "" || oldJTI == newJTI {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
res := db.WithContext(ctx).Exec(`UPDATE golem15_user_device_auth_sessions
|
||||||
|
SET jwt_jti = ?, last_activity_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE jwt_jti = ? AND status = 'used'`, newJTI, oldJTI)
|
||||||
|
if res.Error != nil {
|
||||||
|
return false, res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected > 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var stale int64
|
||||||
|
if err := db.WithContext(ctx).Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions
|
||||||
|
WHERE jwt_jti = ? AND status <> 'used'`, oldJTI).Scan(&stale).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return stale > 0, nil
|
||||||
|
}
|
||||||
129
classes/device_auth_test.go
Normal file
129
classes/device_auth_test.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeviceCodes pins the device token and short code (D-11, D-06)
|
||||||
|
// without a database: 10,000 tokens are 64 base64url characters, all
|
||||||
|
// distinct, and their stored form is the SHA-256 hex; 10,000 short codes
|
||||||
|
// are XXXX-XXXX over DeviceShortCodeAlphabet with every character of the
|
||||||
|
// alphabet in every position and no character far from its expected
|
||||||
|
// share; NormalizeDeviceShortCode accepts case, spaces and a missing
|
||||||
|
// hyphen and refuses lengths other than eight and the excluded letters.
|
||||||
|
func TestDeviceCodes(t *testing.T) {
|
||||||
|
const draws = 10000
|
||||||
|
|
||||||
|
t.Run("tokens", func(t *testing.T) {
|
||||||
|
seen := make(map[string]struct{}, draws)
|
||||||
|
for range draws {
|
||||||
|
tok, err := NewDeviceToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(tok) != 64 {
|
||||||
|
t.Fatalf("token %q has %d characters", tok, len(tok))
|
||||||
|
}
|
||||||
|
if strings.Trim(tok, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" {
|
||||||
|
t.Fatalf("token %q is not base64url", tok)
|
||||||
|
}
|
||||||
|
if _, dup := seen[tok]; dup {
|
||||||
|
t.Fatalf("token %q drawn twice", tok)
|
||||||
|
}
|
||||||
|
seen[tok] = struct{}{}
|
||||||
|
}
|
||||||
|
tok, _ := NewDeviceToken()
|
||||||
|
sum := sha256.Sum256([]byte(tok))
|
||||||
|
if got := HashAPIToken(tok); got != hex.EncodeToString(sum[:]) || len(got) != 64 || got == tok {
|
||||||
|
t.Fatalf("stored form %q is not the SHA-256 hex of the token", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("short_codes", func(t *testing.T) {
|
||||||
|
alphabet := DeviceShortCodeAlphabet
|
||||||
|
if alphabet != "23456789ABCDEFGHJKMNPQRSTUVWXYZ" || len(alphabet) != 31 {
|
||||||
|
t.Fatalf("alphabet = %q", alphabet)
|
||||||
|
}
|
||||||
|
format := regexp.MustCompile(`^[` + alphabet + `]{4}-[` + alphabet + `]{4}$`)
|
||||||
|
perPosition := make([]map[byte]int, 8)
|
||||||
|
for i := range perPosition {
|
||||||
|
perPosition[i] = map[byte]int{}
|
||||||
|
}
|
||||||
|
total := map[byte]int{}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for range draws {
|
||||||
|
code, err := NewDeviceShortCode()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !format.MatchString(code) {
|
||||||
|
t.Fatalf("short code %q", code)
|
||||||
|
}
|
||||||
|
seen[code] = struct{}{}
|
||||||
|
raw := code[:4] + code[5:]
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
perPosition[i][raw[i]]++
|
||||||
|
total[raw[i]]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 31^8 codes: 10,000 draws repeating more than a handful would mean
|
||||||
|
// a broken generator.
|
||||||
|
if len(seen) < draws-5 {
|
||||||
|
t.Fatalf("%d distinct codes in %d draws", len(seen), draws)
|
||||||
|
}
|
||||||
|
for i, counts := range perPosition {
|
||||||
|
for j := 0; j < len(alphabet); j++ {
|
||||||
|
if counts[alphabet[j]] == 0 {
|
||||||
|
t.Fatalf("character %q never drawn at position %d", alphabet[j], i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Each character is expected 80000/31 ≈ 2581 times; a modulo bias
|
||||||
|
// of the byte draw would push the first 8 characters ~3% up. Allow
|
||||||
|
// 10% either way, far outside the noise of a fair draw.
|
||||||
|
expected := float64(8*draws) / float64(len(alphabet))
|
||||||
|
for j := 0; j < len(alphabet); j++ {
|
||||||
|
got := float64(total[alphabet[j]])
|
||||||
|
if got < expected*0.9 || got > expected*1.1 {
|
||||||
|
t.Fatalf("character %q drawn %v times, expected about %.0f", alphabet[j], got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, excluded := range "01ILO" {
|
||||||
|
if total[byte(excluded)] != 0 {
|
||||||
|
t.Fatalf("excluded character %q drawn", excluded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("normalization", func(t *testing.T) {
|
||||||
|
for _, c := range []struct{ in, want string }{
|
||||||
|
{"ABCD-EFGH", "ABCD-EFGH"},
|
||||||
|
{"abcd-efgh", "ABCD-EFGH"},
|
||||||
|
{"AbCd-eFgH", "ABCD-EFGH"},
|
||||||
|
{"ABCDEFGH", "ABCD-EFGH"},
|
||||||
|
{"abcdefgh", "ABCD-EFGH"},
|
||||||
|
{" ab cd ef gh ", "ABCD-EFGH"},
|
||||||
|
{"ABCD - EFGH", "ABCD-EFGH"},
|
||||||
|
{"A-B-C-D-E-F-G-H", "ABCD-EFGH"},
|
||||||
|
{"2345-6789", "2345-6789"},
|
||||||
|
{"zzzz zzzz", "ZZZZ-ZZZZ"},
|
||||||
|
} {
|
||||||
|
if got, ok := NormalizeDeviceShortCode(c.in); !ok || got != c.want {
|
||||||
|
t.Errorf("normalize %q = %q, %v; want %q", c.in, got, ok, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, in := range []string{
|
||||||
|
"", "-", "ABCDEFG", "ABCD-EFG", "ABCDEFGHJ", "ABCD-EFGHJ",
|
||||||
|
"ABCDEFG0", "ABCDEFG1", "ABCDEFGI", "ABCDEFGL", "ABCDEFGO", "abcdefgo", "abcdefgl", "abcdefgi",
|
||||||
|
"ABCD_EFGH", "ABCD.EFGH", "ABCDÉFGH", "ABCD\tEFGH",
|
||||||
|
} {
|
||||||
|
if got, ok := NormalizeDeviceShortCode(in); ok {
|
||||||
|
t.Errorf("normalize %q accepted as %q", in, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -25,6 +25,10 @@ const BackendUnlinkedEventName = "user.BackendUnlinked"
|
|||||||
// AccessRevokedEventName is the festival name of AccessRevokedEvent.
|
// AccessRevokedEventName is the festival name of AccessRevokedEvent.
|
||||||
const AccessRevokedEventName = "user.AccessRevoked"
|
const AccessRevokedEventName = "user.AccessRevoked"
|
||||||
|
|
||||||
|
// LoginEventName is the festival name of LoginEvent, the PHP plugin's
|
||||||
|
// golem15.user.login.
|
||||||
|
const LoginEventName = "golem15.user.login"
|
||||||
|
|
||||||
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
|
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
|
||||||
type GetApiArrayEvent struct {
|
type GetApiArrayEvent struct {
|
||||||
User *models.User
|
User *models.User
|
||||||
@@ -111,3 +115,32 @@ type AccessRevokedEvent struct {
|
|||||||
Reason string
|
Reason string
|
||||||
Tx *gorm.DB
|
Tx *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PasswordChanged reports whether the event comes from a new password the
|
||||||
|
// user or an administrator chose: a password reset, the user's own change or
|
||||||
|
// an administrator setting one (Reason AccessRevokedPasswordReset,
|
||||||
|
// AccessRevokedPasswordChanged or AccessRevokedPasswordSetByAdmin). The
|
||||||
|
// bcrypt rehash on login and a new account fire no AccessRevokedEvent. A
|
||||||
|
// two-factor plugin listens for these events to revoke the users' trusted
|
||||||
|
// devices inside the same transaction.
|
||||||
|
func (e *AccessRevokedEvent) PasswordChanged() bool {
|
||||||
|
if e == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch e.Reason {
|
||||||
|
case AccessRevokedPasswordReset, AccessRevokedPasswordChanged, AccessRevokedPasswordSetByAdmin:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginEvent fires after a sign-in issued a session token: a password login,
|
||||||
|
// a sign-in completed by a second factor, a passkey sign-in or a device
|
||||||
|
// hand-over (Source is one of the LoginSource constants). It does not fire
|
||||||
|
// on refresh, on /register or for a sign-in a login gate held back. It fires
|
||||||
|
// after the token was minted and outside any transaction; a listener error
|
||||||
|
// is logged and never fails the sign-in (PHP's golem15.user.login).
|
||||||
|
type LoginEvent struct {
|
||||||
|
User *models.User
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|||||||
124
classes/login_gate.go
Normal file
124
classes/login_gate.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Login sources name the first factor a LoginAttempt or a LoginEvent comes
|
||||||
|
// from.
|
||||||
|
const (
|
||||||
|
// LoginSourcePassword is POST /_user/api/v1/login (email and password).
|
||||||
|
LoginSourcePassword = "password"
|
||||||
|
// LoginSourceSocial is a social sign-in completed by a social-login
|
||||||
|
// plugin.
|
||||||
|
LoginSourceSocial = "social"
|
||||||
|
// LoginSourcePasskey is a passwordless passkey sign-in.
|
||||||
|
LoginSourcePasskey = "passkey"
|
||||||
|
// LoginSourceTwoFactor is a sign-in completed by a second factor after a
|
||||||
|
// gate challenged it.
|
||||||
|
LoginSourceTwoFactor = "two_factor"
|
||||||
|
// LoginSourceDevice is the device authorization hand-over (the first poll
|
||||||
|
// of devices/status after a signed-in user authorized the device).
|
||||||
|
LoginSourceDevice = "device"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginAttempt is a first-factor sign-in that already succeeded: the
|
||||||
|
// password (or the social identity) checked out, the throttle was reset and
|
||||||
|
// the account may sign in. A LoginGate decides whether a token is issued
|
||||||
|
// now.
|
||||||
|
type LoginAttempt struct {
|
||||||
|
// User is the account signing in. A deactivated account still has
|
||||||
|
// DeletedAt set: Login restores it only after every gate allowed the
|
||||||
|
// sign-in.
|
||||||
|
User *models.User
|
||||||
|
// Source is one of the LoginSource constants.
|
||||||
|
Source string
|
||||||
|
// Fields is the login body Login already read (a gate must not read the
|
||||||
|
// request body again, it is consumed). It carries keys a gate needs,
|
||||||
|
// such as trusted_device_token. It is nil on a social sign-in. Treat it
|
||||||
|
// as read-only, untrusted input; it holds the password on a password
|
||||||
|
// login.
|
||||||
|
Fields map[string]any
|
||||||
|
// Request is the sign-in request, for the client address and the user
|
||||||
|
// agent.
|
||||||
|
Request *http.Request
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginGateResult replaces the token response of a sign-in a gate held
|
||||||
|
// back.
|
||||||
|
type LoginGateResult struct {
|
||||||
|
// Status is the HTTP status of the answer; 0 means 200. A challenge
|
||||||
|
// answers 200, a refusal (for example a second-factor failure cap)
|
||||||
|
// answers its own status.
|
||||||
|
Status int
|
||||||
|
// Body is the JSON body of the answer, for example
|
||||||
|
// {two_factor_required, challenge_token, available_methods, expires_at}.
|
||||||
|
Body map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginGate inspects a first-factor sign-in that already succeeded, before
|
||||||
|
// any token exists. A two-factor plugin implements one to challenge users
|
||||||
|
// who enabled a second factor.
|
||||||
|
type LoginGate interface {
|
||||||
|
// CheckLogin returns nil, nil to allow the sign-in. A non-nil result
|
||||||
|
// replaces the token response with its Status and Body, and no token is
|
||||||
|
// issued. An error must fail the sign-in closed: the caller answers the
|
||||||
|
// opaque 500 and issues no token. Error strings must not carry a token,
|
||||||
|
// a code or a password.
|
||||||
|
CheckLogin(ctx context.Context, a LoginAttempt) (*LoginGateResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginGates is the registry of login gates. The user plugin publishes one
|
||||||
|
// in its Register (app.Publish(classes.NewLoginGates())), and another plugin
|
||||||
|
// looks it up with app.Lookup[*classes.LoginGates]() and adds its gate in
|
||||||
|
// its Boot: every Register runs before every Boot. Password Login consults
|
||||||
|
// it after the password, activation and throttle checks and before it
|
||||||
|
// restores a deactivated account and mints the token; JWT refresh never
|
||||||
|
// consults it. It is safe for concurrent use.
|
||||||
|
type LoginGates struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
gates []LoginGate
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLoginGates returns an empty registry.
|
||||||
|
func NewLoginGates() *LoginGates {
|
||||||
|
return &LoginGates{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add appends gate; gates run in the order they were added. A nil gate is
|
||||||
|
// ignored.
|
||||||
|
func (g *LoginGates) Add(gate LoginGate) {
|
||||||
|
if g == nil || gate == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.mu.Lock()
|
||||||
|
g.gates = append(g.gates, gate)
|
||||||
|
g.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check runs the gates in order and returns the first non-nil result or the
|
||||||
|
// first error; gates after it are not called. A nil registry and an empty
|
||||||
|
// one return nil, nil, which allows the sign-in.
|
||||||
|
func (g *LoginGates) Check(ctx context.Context, a LoginAttempt) (*LoginGateResult, error) {
|
||||||
|
if g == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
g.mu.RLock()
|
||||||
|
gates := slices.Clone(g.gates)
|
||||||
|
g.mu.RUnlock()
|
||||||
|
for _, gate := range gates {
|
||||||
|
res, err := gate.CheckLogin(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
38
classes/two_factor_admin.go
Normal file
38
classes/two_factor_admin.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PermissionResetTwoFactor is the backend permission an administrator needs
|
||||||
|
// to run the Users screen's "Reset 2FA" record action (reset_two_factor). No
|
||||||
|
// role has it by default: only a superuser, or a role granted it
|
||||||
|
// explicitly, may remove a user's second factors.
|
||||||
|
const PermissionResetTwoFactor = "golem15.users.reset_two_factor"
|
||||||
|
|
||||||
|
// TwoFactorAdmin is what a two-factor plugin publishes so the Users screen
|
||||||
|
// can offer its "Reset 2FA" record action. The screen looks it up on every
|
||||||
|
// request: without a published service the action is never offered, so an
|
||||||
|
// application that mounts no two-factor plugin never shows it.
|
||||||
|
//
|
||||||
|
// Publish it with an explicit type argument,
|
||||||
|
//
|
||||||
|
// app.Publish[classes.TwoFactorAdmin](svc)
|
||||||
|
//
|
||||||
|
// because a bare app.Publish(svc) keys the service on its concrete type and
|
||||||
|
// the lookup misses it.
|
||||||
|
type TwoFactorAdmin interface {
|
||||||
|
// HasTwoFactor reports whether the user has any second factor (an
|
||||||
|
// enabled method, a WebAuthn credential, recovery codes or a trusted
|
||||||
|
// device) to reset. It only reads, through tx.
|
||||||
|
HasTwoFactor(ctx context.Context, tx *gorm.DB, userID uint) (bool, error)
|
||||||
|
// Reset disables every second-factor method of user, deletes the
|
||||||
|
// WebAuthn credentials and recovery codes, revokes the trusted devices
|
||||||
|
// and fires the plugin's disabled event, all on tx (the record action's
|
||||||
|
// transaction): an error rolls the reset back. The user then signs in
|
||||||
|
// with the password alone until they set up a second factor again.
|
||||||
|
Reset(ctx context.Context, tx *gorm.DB, user *models.User) error
|
||||||
|
}
|
||||||
@@ -39,9 +39,19 @@ privileged_groups:
|
|||||||
# v0.3.0 and later); its secret is turnstile.secret (SUMMER_TURNSTILE__SECRET).
|
# v0.3.0 and later); its secret is turnstile.secret (SUMMER_TURNSTILE__SECRET).
|
||||||
turnstile: ""
|
turnstile: ""
|
||||||
# The site this plugin's links point to (the account link page,
|
# The site this plugin's links point to (the account link page,
|
||||||
# <frontend_url><backend_links.page_path>#code=...). Empty falls back to
|
# <frontend_url><backend_links.page_path>#code=..., and the device
|
||||||
# app.url.
|
# authorization page of device_auth.auth_url). Empty falls back to app.url.
|
||||||
frontend_url: ""
|
frontend_url: ""
|
||||||
|
device_auth:
|
||||||
|
# The page a new device shows as its QR code, where a signed-in user
|
||||||
|
# authorizes it. {frontend_url} is frontend_url (else app.url, no trailing
|
||||||
|
# slash), {short_code} the session's short code and {token} the device
|
||||||
|
# token. Keep {token} out of it: the device token is also the secret the
|
||||||
|
# device collects its session token with, and whoever sees the QR code or
|
||||||
|
# the page's URL could take the session.
|
||||||
|
auth_url: "{frontend_url}/device/scan/{short_code}"
|
||||||
|
# Minutes a device session can be authorized and handed over.
|
||||||
|
ttl_minutes: 5
|
||||||
backend_links:
|
backend_links:
|
||||||
# How long a link request an administrator starts on My account stays
|
# How long a link request an administrator starts on My account stays
|
||||||
# usable (a Go duration). The code is one-shot.
|
# usable (a Go duration). The code is one-shot.
|
||||||
|
|||||||
@@ -47,8 +47,11 @@ const invalidLoginBody = "Invalid email or password"
|
|||||||
//
|
//
|
||||||
// It checks in this order: a ban or an active suspension
|
// It checks in this order: a ban or an active suspension
|
||||||
// (classes.RejectIfThrottled), the password (bcrypt), the activation, then
|
// (classes.RejectIfThrottled), the password (bcrypt), the activation, then
|
||||||
// restores a deactivated (deleted_at) account and sends it the reactivate
|
// records the successful attempt, consults the login gates
|
||||||
// mail, records the successful attempt and mints the token. With
|
// (CheckLoginGates: a gate answer replaces the token response, a gate error
|
||||||
|
// answers the opaque 500), restores a deactivated (deleted_at) account and
|
||||||
|
// sends it the reactivate mail, mints the token and fires
|
||||||
|
// classes.LoginEvent. With
|
||||||
// golem15.user.activation.require_activation on (the default) the correct
|
// golem15.user.activation.require_activation on (the default) the correct
|
||||||
// password of a not-activated account gets the same 401 invalid-login answer
|
// 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
|
// as a wrong password and counts as a failed attempt for the throttle; an
|
||||||
@@ -106,13 +109,6 @@ func Login(app *backpack.App) http.HandlerFunc {
|
|||||||
refuseLogin(ctx, w, r, app, gdb, user, ip)
|
refuseLogin(ctx, w, r, app, gdb, user, ip)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user.DeletedAt.Valid {
|
|
||||||
if err := gdb.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil {
|
|
||||||
writeOpaque500(w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
|
||||||
}
|
|
||||||
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, true); err != nil {
|
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, true); err != nil {
|
||||||
if classes.IsLoginRejected(err) {
|
if classes.IsLoginRejected(err) {
|
||||||
writeInvalidLogin(w, r, app)
|
writeInvalidLogin(w, r, app)
|
||||||
@@ -121,15 +117,51 @@ func Login(app *backpack.App) http.HandlerFunc {
|
|||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The rehash runs before the login gates, so a user who is always
|
||||||
|
// challenged still gets the stronger hash. It is not a password
|
||||||
|
// change: it fires no AccessRevokedEvent (D-07). Unscoped, because a
|
||||||
|
// deactivated account is restored only after the gates.
|
||||||
cost := 10
|
cost := 10
|
||||||
if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 {
|
if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 {
|
||||||
cost = app.Config.Int("golem15.user.password.bcrypt_cost")
|
cost = app.Config.Int("golem15.user.password.bcrypt_cost")
|
||||||
}
|
}
|
||||||
if bouncer.NeedsRehash(user.Password, cost) {
|
if bouncer.NeedsRehash(user.Password, cost) {
|
||||||
if next, err := bouncer.HashPassword(cost, password); err == nil {
|
if next, err := bouncer.HashPassword(cost, password); err == nil {
|
||||||
_ = gdb.WithContext(ctx).Model(user).Update("password", next).Error
|
_ = gdb.WithContext(ctx).Unscoped().Model(user).Update("password", next).Error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Login gates (D-02) run after the password, activation and throttle
|
||||||
|
// checks and before anything that signs the user in. A gate answer
|
||||||
|
// replaces the token response; a gate error fails closed. The
|
||||||
|
// restore of a deactivated account and its reactivate mail come
|
||||||
|
// after the gates (D-18 A10), so the password alone cannot reactivate
|
||||||
|
// an account that a second factor protects.
|
||||||
|
gate, err := CheckLoginGates(ctx, app, classes.LoginAttempt{
|
||||||
|
User: user,
|
||||||
|
Source: classes.LoginSourcePassword,
|
||||||
|
Fields: fields,
|
||||||
|
Request: r,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.Error("golem15.user: login gate", "user_id", user.ID, "err", err)
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if gate != nil {
|
||||||
|
status := gate.Status
|
||||||
|
if status == 0 {
|
||||||
|
status = http.StatusOK
|
||||||
|
}
|
||||||
|
writeJSON(w, status, gate.Body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.DeletedAt.Valid {
|
||||||
|
if err := gdb.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
||||||
|
}
|
||||||
// A failed last_seen write is logged and never fails the login.
|
// 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 {
|
||||||
pluginLog.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)
|
||||||
@@ -144,6 +176,9 @@ func Login(app *backpack.App) http.HandlerFunc {
|
|||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := FireLoginEvent(ctx, app, user, classes.LoginSourcePassword); err != nil {
|
||||||
|
pluginLog.Warn("golem15.user: login event listener", "user_id", user.ID, "err", err)
|
||||||
|
}
|
||||||
writeSession(w, app, cc, token, map[string]any{"user": payload})
|
writeSession(w, app, cc, token, map[string]any{"user": payload})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,6 +300,40 @@ func Refresh(app *backpack.App) http.HandlerFunc {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// A device session follows its token through refresh (D-12b), so a
|
||||||
|
// revoke blacklists the token the device holds now. Reading the old
|
||||||
|
// jti unverified is safe: bouncer.Refresh has just verified it. The
|
||||||
|
// hand-over and the revoke take the session's row lock, so whichever
|
||||||
|
// runs second sees the other: a device revoked after bouncer.Refresh
|
||||||
|
// checked the blacklist is refused here with the blacklisted-token
|
||||||
|
// answer and the new token is never sent. The hand-over fails
|
||||||
|
// closed: a database error answers the opaque 500 (the presented
|
||||||
|
// token is already rotated out), never a token the device row does
|
||||||
|
// not follow. With golem15.user.jwt.blacklist_grace above 0 two
|
||||||
|
// concurrent refreshes of one token can fork a device's lineage and
|
||||||
|
// only one fork is followed; a password change still ends both
|
||||||
|
// (tokens_valid_after). Log lines carry no token or jti.
|
||||||
|
if oldJTI := unverifiedJTI(raw); oldJTI != "" {
|
||||||
|
_, _, _, newJTI, err := bouncer.VerifyClaims(next, secret)
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.Error("golem15.user: read the refreshed token", "user_id", userID, "err", err)
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
revoked, err := classes.FollowDeviceJTI(r.Context(), gdb, oldJTI, newJTI)
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.Error("golem15.user: hand a device session over on refresh", "user_id", userID, "err", err)
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if revoked {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]any{
|
||||||
|
"error": "Could not refresh token",
|
||||||
|
"msg": refreshBlacklistedMsg,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// A failed last_seen write is logged and never fails the refresh.
|
// A failed last_seen write is logged and never fails the refresh.
|
||||||
if err := classes.TouchLastSeen(r.Context(), gdb, userID); err != nil {
|
if err := classes.TouchLastSeen(r.Context(), gdb, userID); err != nil {
|
||||||
pluginLog.Warn("golem15.user: stamp last_seen on refresh", "user_id", userID, "err", err)
|
pluginLog.Warn("golem15.user: stamp last_seen on refresh", "user_id", userID, "err", err)
|
||||||
@@ -273,6 +342,21 @@ func Refresh(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshBlacklistedMsg is bouncer.Refresh's message for a blacklisted
|
||||||
|
// token, which POST /refresh also answers for a revoked device session.
|
||||||
|
const refreshBlacklistedMsg = "The token has been blacklisted"
|
||||||
|
|
||||||
|
// unverifiedJTI returns the jti claim of a token without verifying it, ""
|
||||||
|
// when there is none. Only call it on a token that was verified already.
|
||||||
|
func unverifiedJTI(raw string) string {
|
||||||
|
claims := jwt.MapClaims{}
|
||||||
|
if _, _, err := jwt.NewParser().ParseUnverified(raw, claims); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
jti, _ := claims["jti"].(string)
|
||||||
|
return jti
|
||||||
|
}
|
||||||
|
|
||||||
// refreshSubjectRefused reads the subject and iat of a token bouncer.Refresh
|
// refreshSubjectRefused reads the subject and iat of a token bouncer.Refresh
|
||||||
// has already verified and reports whether its user may not slide the
|
// has already verified and reports whether its user may not slide the
|
||||||
// session: not found by a scoped load (missing or deactivated), issued
|
// session: not found by a scoped load (missing or deactivated), issued
|
||||||
@@ -1036,12 +1120,18 @@ func apiArray(ctx context.Context, app *backpack.App, user *models.User) (map[st
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mintFor(app *backpack.App, secret string, user *models.User, issuer string) (string, error) {
|
func mintFor(app *backpack.App, secret string, user *models.User, issuer string) (string, error) {
|
||||||
|
token, _, err := bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, sessionTTL(app))
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionTTL is the lifetime of a session JWT: golem15.user.jwt.ttl
|
||||||
|
// minutes, default 60.
|
||||||
|
func sessionTTL(app *backpack.App) time.Duration {
|
||||||
ttl := 60 * time.Minute
|
ttl := 60 * time.Minute
|
||||||
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.ttl") > 0 {
|
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.ttl") > 0 {
|
||||||
ttl = time.Duration(app.Config.Int("golem15.user.jwt.ttl")) * time.Minute
|
ttl = time.Duration(app.Config.Int("golem15.user.jwt.ttl")) * time.Minute
|
||||||
}
|
}
|
||||||
token, _, err := bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, ttl)
|
return ttl
|
||||||
return token, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshTTL is the refresh window of a session token:
|
// RefreshTTL is the refresh window of a session token:
|
||||||
|
|||||||
573
controllers/devices.go
Normal file
573
controllers/devices.go
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/wire"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deviceTable is the device authorization session table.
|
||||||
|
const deviceTable = "golem15_user_device_auth_sessions"
|
||||||
|
|
||||||
|
// deviceInsertAttempts bounds the retries of an initiate whose fresh short
|
||||||
|
// code (or token) collided with a stored one.
|
||||||
|
const deviceInsertAttempts = 5
|
||||||
|
|
||||||
|
// deviceCleanupBatch bounds the stale sessions one initiate deletes.
|
||||||
|
const deviceCleanupBatch = 500
|
||||||
|
|
||||||
|
// writeDeviceUnauthorized is the PHP device controller's 401 body, which
|
||||||
|
// differs from the session routes' {"error":true,"message":"Unauthorized"}.
|
||||||
|
func writeDeviceUnauthorized(w http.ResponseWriter) {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "Unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceCaller resolves the signed-in user of a device route that needs one
|
||||||
|
// (authorize, list, revoke). It reads and checks the session token as the
|
||||||
|
// plugin's jwt guard does (bearer header, then the session cookies; the
|
||||||
|
// blacklist and tokens_valid_after apply) and also returns the token's jti.
|
||||||
|
// Anything else answers PHP's 401 {"error":"Unauthorized"}.
|
||||||
|
func deviceCaller(w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, secret string) (*models.User, string, bool) {
|
||||||
|
principal, err := sessionGuard(app, secret).Authenticate(r)
|
||||||
|
if err != nil || principal == nil || principal.ID == 0 {
|
||||||
|
writeDeviceUnauthorized(w)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
var user models.User
|
||||||
|
if err := gdb.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error; err != nil {
|
||||||
|
writeDeviceUnauthorized(w)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
jti := ""
|
||||||
|
if raw := classes.SessionToken(r); raw != "" {
|
||||||
|
if _, _, _, id, err := bouncer.VerifyClaims(raw, secret); err == nil {
|
||||||
|
jti = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &user, jti, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceClientIP is the client address device rows record: surf.ClientIP
|
||||||
|
// with http.trusted_proxies, as /login and /register use.
|
||||||
|
func deviceClientIP(app *backpack.App, r *http.Request) string {
|
||||||
|
return surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceInitiate is POST /devices/initiate, called without a session by the
|
||||||
|
// device that wants to sign in. It stores a pending session (the SHA-256 of
|
||||||
|
// a 64-character token, a short code XXXX-XXXX, expires_at now plus
|
||||||
|
// golem15.user.device_auth.ttl_minutes, the device's address, user agent and
|
||||||
|
// optional device_name of at most 255 characters) and answers
|
||||||
|
// {"success":true,"data":{"token","short_code","auth_url","expires_at"}}.
|
||||||
|
// Each call first deletes at most 500 pending or expired sessions that
|
||||||
|
// expired more than an hour ago.
|
||||||
|
func DeviceInitiate(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gdb, _, ok := sessionDeps(w, app)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields, err := readFields(r)
|
||||||
|
if err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strField(fields, "device_name")
|
||||||
|
if utf8.RuneCountInString(name) > 255 {
|
||||||
|
writeValidation(w, map[string][]string{"device_name": {"The device name may not be greater than 255 characters."}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
// Stale sessions go in bounded batches, so the unauthenticated route
|
||||||
|
// cannot grow the table without limit (a failure is only logged).
|
||||||
|
if err := gdb.WithContext(ctx).Exec(`DELETE FROM `+deviceTable+` WHERE id IN (
|
||||||
|
SELECT id FROM `+deviceTable+`
|
||||||
|
WHERE status IN ('pending', 'expired') AND expires_at < NOW() - INTERVAL '1 hour'
|
||||||
|
LIMIT ?)`, deviceCleanupBatch).Error; err != nil {
|
||||||
|
pluginLog.Warn("golem15.user: device session cleanup", "err", err)
|
||||||
|
}
|
||||||
|
ip := deviceClientIP(app, r)
|
||||||
|
ua := r.UserAgent()
|
||||||
|
expires := time.Now().Add(classes.DeviceAuthTTL(app)).Truncate(time.Second)
|
||||||
|
var (
|
||||||
|
token string
|
||||||
|
row models.DeviceAuthSession
|
||||||
|
)
|
||||||
|
for attempt := 0; ; attempt++ {
|
||||||
|
token, err = classes.NewDeviceToken()
|
||||||
|
if err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code, err := classes.NewDeviceShortCode()
|
||||||
|
if err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row = models.DeviceAuthSession{
|
||||||
|
TokenHash: classes.HashAPIToken(token),
|
||||||
|
ShortCode: &code,
|
||||||
|
Status: models.DeviceStatusPending,
|
||||||
|
ExpiresAt: &expires,
|
||||||
|
}
|
||||||
|
if ip != "" {
|
||||||
|
row.DeviceIP = &ip
|
||||||
|
}
|
||||||
|
if ua != "" {
|
||||||
|
row.DeviceUserAgent = &ua
|
||||||
|
}
|
||||||
|
if name != "" {
|
||||||
|
row.DeviceName = &name
|
||||||
|
}
|
||||||
|
err = gdb.WithContext(ctx).Create(&row).Error
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if !errors.As(err, &pgErr) || pgErr.Code != "23505" || attempt+1 >= deviceInsertAttempts {
|
||||||
|
pluginLog.Error("golem15.user: store device session", "err", err)
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"data": map[string]any{
|
||||||
|
"token": token,
|
||||||
|
"short_code": *row.ShortCode,
|
||||||
|
"auth_url": classes.DeviceAuthURLFor(app, token, *row.ShortCode),
|
||||||
|
"expires_at": wire.Time{Time: expires},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceStatus is GET /devices/status/{token}, polled by the waiting
|
||||||
|
// device. It answers 404 {"error":"Session not found"} for an unknown token
|
||||||
|
// and PHP's flat {"success":true,"status":"expired","message":…} for a
|
||||||
|
// pending or confirmed session past expires_at (which it marks expired).
|
||||||
|
// The first poll after a signed-in user authorized the session receives a
|
||||||
|
// session token: {"success":true,"data":{"status":"confirmed","user_id",
|
||||||
|
// "message":"Device authorized - complete login","token","user"}}. One
|
||||||
|
// guarded confirmed-to-used UPDATE decides which poll that is, so of
|
||||||
|
// concurrent polls exactly one gets the token; the others, and every later
|
||||||
|
// poll, answer {"success":true,"data":{"status":"used"}}. Other statuses
|
||||||
|
// answer {"success":true,"data":{"status":…}}. Only a poll with the
|
||||||
|
// User-Agent the device initiated with receives the token: another client
|
||||||
|
// that learned the device token answers {"status":"confirmed"} and takes
|
||||||
|
// nothing, so the device still collects its session.
|
||||||
|
func DeviceStatus(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gdb, secret, ok := sessionDeps(w, app)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, found, err := deviceByToken(r, gdb, r.PathValue("token"))
|
||||||
|
if err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]any{"error": "Session not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.Status == models.DeviceStatusConfirmed && row.UserID != nil && !row.ExpiredAt(time.Now()) && sameDeviceClient(r, row) {
|
||||||
|
if handOverDevice(w, r, app, gdb, secret, row) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Another poll won the hand-over or the session just expired:
|
||||||
|
// answer what the row says now, never a token.
|
||||||
|
row, found, err = deviceByID(r, gdb, row.ID)
|
||||||
|
if err != nil || !found {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeDeviceStatus(w, r, gdb, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameDeviceClient reports whether r comes with the User-Agent the device
|
||||||
|
// initiated the session with (none for none), the binding of the token
|
||||||
|
// hand-over to the initiating client.
|
||||||
|
func sameDeviceClient(r *http.Request, row models.DeviceAuthSession) bool {
|
||||||
|
stored := ""
|
||||||
|
if row.DeviceUserAgent != nil {
|
||||||
|
stored = *row.DeviceUserAgent
|
||||||
|
}
|
||||||
|
return r.UserAgent() == stored
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceByToken loads the session whose token hash matches token.
|
||||||
|
func deviceByToken(r *http.Request, gdb *gorm.DB, token string) (models.DeviceAuthSession, bool, error) {
|
||||||
|
var row models.DeviceAuthSession
|
||||||
|
if token == "" {
|
||||||
|
return row, false, nil
|
||||||
|
}
|
||||||
|
res := gdb.WithContext(r.Context()).Where("token = ?", classes.HashAPIToken(token)).Limit(1).Find(&row)
|
||||||
|
return row, res.RowsAffected == 1, res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceByID(r *http.Request, gdb *gorm.DB, id uint) (models.DeviceAuthSession, bool, error) {
|
||||||
|
var row models.DeviceAuthSession
|
||||||
|
res := gdb.WithContext(r.Context()).Where("id = ?", id).Limit(1).Find(&row)
|
||||||
|
return row, res.RowsAffected == 1, res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeDeviceStatus answers a status poll that hands over no token. A
|
||||||
|
// pending or confirmed session past expires_at is marked expired (used and
|
||||||
|
// revoked sessions never are); an expired session answers PHP's flat
|
||||||
|
// expired body.
|
||||||
|
func writeDeviceStatus(w http.ResponseWriter, r *http.Request, gdb *gorm.DB, row models.DeviceAuthSession) {
|
||||||
|
expire := row.ExpiredAt(time.Now())
|
||||||
|
if expire || row.Status == models.DeviceStatusExpired {
|
||||||
|
if expire {
|
||||||
|
if err := gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+` SET status = 'expired', updated_at = NOW()
|
||||||
|
WHERE id = ? AND status IN ('pending', 'confirmed')`, row.ID).Error; err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"status": models.DeviceStatusExpired,
|
||||||
|
"message": "Authorization session has expired",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"success": true, "data": map[string]any{"status": row.Status}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handOverDevice gives the confirmed session's user a session token for the
|
||||||
|
// device (D-12a). It reports false, having written nothing, when another
|
||||||
|
// poll already took the session or it expired meanwhile; the token it
|
||||||
|
// minted is then dropped unsent. A user who is gone, deactivated or
|
||||||
|
// banned, or whose tokens_valid_after (a password change, a revocation)
|
||||||
|
// is later than the authorization, gets nothing: the session becomes
|
||||||
|
// revoked, as a token minted at the authorization would be refused.
|
||||||
|
func handOverDevice(w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, secret string, row models.DeviceAuthSession) bool {
|
||||||
|
ctx := r.Context()
|
||||||
|
var user models.User
|
||||||
|
res := gdb.WithContext(ctx).Where("id = ?", *row.UserID).Limit(1).Find(&user)
|
||||||
|
if res.Error != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
refused := res.RowsAffected == 0
|
||||||
|
if !refused {
|
||||||
|
banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, app), gdb.WithContext(ctx), []uint{user.ID})
|
||||||
|
if err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
refused = banned[user.ID] || authorizedBeforeCutoff(&user, row)
|
||||||
|
}
|
||||||
|
if refused {
|
||||||
|
// A deactivated, deleted or banned user signs no device in.
|
||||||
|
if err := gdb.WithContext(ctx).Exec(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW()
|
||||||
|
WHERE id = ? AND status = 'confirmed'`, row.ID).Error; err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
token, jti, err := mintWithJTI(app, secret, &user, requestURL(r))
|
||||||
|
if err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
payload, err := apiArray(ctx, app, &user)
|
||||||
|
if err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
res = gdb.WithContext(ctx).Exec(`UPDATE `+deviceTable+`
|
||||||
|
SET status = 'used', used_at = NOW(), last_activity_at = NOW(), jwt_jti = ?, updated_at = NOW()
|
||||||
|
WHERE id = ? AND status = 'confirmed' AND expires_at > NOW()`, jti, row.ID)
|
||||||
|
if res.Error != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if res.RowsAffected != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := FinishLogin(ctx, app, gdb, &user, deviceClientIP(app, r)); err != nil {
|
||||||
|
pluginLog.Warn("golem15.user: finish device login", "user_id", user.ID, "err", err)
|
||||||
|
}
|
||||||
|
if err := FireLoginEvent(ctx, app, &user, classes.LoginSourceDevice); err != nil {
|
||||||
|
pluginLog.Warn("golem15.user: login event listener", "user_id", user.ID, "err", err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"data": map[string]any{
|
||||||
|
"status": models.DeviceStatusConfirmed,
|
||||||
|
"user_id": user.ID,
|
||||||
|
"message": "Device authorized - complete login",
|
||||||
|
"token": token,
|
||||||
|
"user": payload,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizedBeforeCutoff reports whether row was confirmed before the
|
||||||
|
// user's tokens_valid_after, compared as the jwt guard compares a token's
|
||||||
|
// whole-second iat.
|
||||||
|
func authorizedBeforeCutoff(user *models.User, row models.DeviceAuthSession) bool {
|
||||||
|
if user.TokensValidAfter == nil || row.ConfirmedAt == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return row.ConfirmedAt.Truncate(time.Second).Before(*user.TokensValidAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceAuthorize is POST /devices/authorize: a signed-in user (a valid,
|
||||||
|
// not blacklisted session token; no second-factor check, D-12c) binds a
|
||||||
|
// pending device session to themselves by its short_code (hyphens and
|
||||||
|
// spaces optional, any case) or by its token (the QR code page). The token
|
||||||
|
// field also takes the short code, which the default auth_url carries in
|
||||||
|
// place of the device token. A short_code of
|
||||||
|
// other than 8 characters after removing hyphens answers 422 {"error":
|
||||||
|
// "Please enter a valid 8-character code"}; an unknown, expired, used,
|
||||||
|
// revoked or foreign session answers 400 {"error":"Invalid or expired
|
||||||
|
// code"}; success, and repeating it for a session the caller already
|
||||||
|
// confirmed, answers {"success":true,"message":"Device authorized
|
||||||
|
// successfully"}. Without a session it answers 401 {"error":"Unauthorized"}.
|
||||||
|
// The fields are read from the body only, never the query string.
|
||||||
|
func DeviceAuthorize(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gdb, secret, ok := sessionDeps(w, app)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _, ok := deviceCaller(w, r, app, gdb, secret)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields, err := readBodyFields(r)
|
||||||
|
if err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
invalid := func() {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Invalid or expired code"})
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
row models.DeviceAuthSession
|
||||||
|
found bool
|
||||||
|
)
|
||||||
|
if _, has := fields["short_code"]; has {
|
||||||
|
raw := strings.ReplaceAll(strField(fields, "short_code"), "-", "")
|
||||||
|
if len(raw) != 8 {
|
||||||
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "Please enter a valid 8-character code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code, valid := classes.NormalizeDeviceShortCode(raw)
|
||||||
|
if !valid {
|
||||||
|
invalid()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := gdb.WithContext(r.Context()).Where("short_code = ?", code).Limit(1).Find(&row)
|
||||||
|
if res.Error != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found = res.RowsAffected == 1
|
||||||
|
} else if _, has := fields["token"]; has {
|
||||||
|
raw := strField(fields, "token")
|
||||||
|
if code, isCode := classes.NormalizeDeviceShortCode(raw); isCode {
|
||||||
|
// The QR page of the default auth_url carries the short
|
||||||
|
// code; a 64-character device token never normalizes.
|
||||||
|
res := gdb.WithContext(r.Context()).Where("short_code = ?", code).Limit(1).Find(&row)
|
||||||
|
if res.Error != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found = res.RowsAffected == 1
|
||||||
|
} else {
|
||||||
|
row, found, err = deviceByToken(r, gdb, raw)
|
||||||
|
if err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
invalid()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+`
|
||||||
|
SET status = 'confirmed', user_id = ?, confirmed_at = NOW(), auth_ip = ?, updated_at = NOW()
|
||||||
|
WHERE id = ? AND status = 'pending' AND (user_id IS NULL OR user_id = ?) AND expires_at > NOW()`,
|
||||||
|
user.ID, nullableString(deviceClientIP(app, r)), row.ID, user.ID)
|
||||||
|
if res.Error != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.RowsAffected != 1 {
|
||||||
|
now, ok, err := deviceByID(r, gdb, row.ID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
again := now.Status == models.DeviceStatusConfirmed && now.UserID != nil && *now.UserID == user.ID && !now.ExpiredAt(time.Now())
|
||||||
|
if !again {
|
||||||
|
if now.ExpiredAt(time.Now()) {
|
||||||
|
_ = gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+` SET status = 'expired', updated_at = NOW()
|
||||||
|
WHERE id = ? AND status IN ('pending', 'confirmed')`, now.ID).Error
|
||||||
|
}
|
||||||
|
invalid()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "Device authorized successfully"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullableString is nil for "", else s.
|
||||||
|
func nullableString(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// mintWithJTI mints the session JWT as mintFor does and also returns its
|
||||||
|
// jti, which device sessions store.
|
||||||
|
func mintWithJTI(app *backpack.App, secret string, user *models.User, issuer string) (string, string, error) {
|
||||||
|
return bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, sessionTTL(app))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceIndex is GET /devices: the caller's authorized devices (sessions
|
||||||
|
// confirmed or used), most recent activity first, as {"success":true,
|
||||||
|
// "devices":[{id, device_name, device_ip, authorized_at, last_activity,
|
||||||
|
// is_current}]}. device_name is the stored name or one parsed from the user
|
||||||
|
// agent (classes.DeviceDisplayName); is_current is true for the session
|
||||||
|
// whose token is the one the request carries. Without a session it answers
|
||||||
|
// 401 {"error":"Unauthorized"}.
|
||||||
|
func DeviceIndex(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gdb, secret, ok := sessionDeps(w, app)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, jti, ok := deviceCaller(w, r, app, gdb, secret)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rows []models.DeviceAuthSession
|
||||||
|
if err := gdb.WithContext(r.Context()).
|
||||||
|
Where("user_id = ? AND status IN ?", user.ID, []string{models.DeviceStatusConfirmed, models.DeviceStatusUsed}).
|
||||||
|
Order("last_activity_at DESC NULLS LAST, id DESC").
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
devices := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
ua := ""
|
||||||
|
if row.DeviceUserAgent != nil {
|
||||||
|
ua = *row.DeviceUserAgent
|
||||||
|
}
|
||||||
|
devices = append(devices, map[string]any{
|
||||||
|
"id": row.ID,
|
||||||
|
"device_name": classes.DeviceDisplayName(row.DeviceName, ua),
|
||||||
|
"device_ip": row.DeviceIP,
|
||||||
|
"authorized_at": wireTimeOrNil(row.ConfirmedAt),
|
||||||
|
"last_activity": wireTimeOrNil(row.LastActivityAt),
|
||||||
|
"is_current": jti != "" && row.JWTJTI != nil && *row.JWTJTI == jti,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"success": true, "devices": wire.Slice(devices)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceDestroy is DELETE /devices/{id}: the caller revokes one of their
|
||||||
|
// device sessions. In one transaction the row is marked revoked and the jti
|
||||||
|
// of the session token the device holds (followed through refresh) is
|
||||||
|
// added to the JWT blacklist for the token's whole refresh window, so the
|
||||||
|
// device is signed out at once and cannot refresh. The jti is the one the
|
||||||
|
// revoking UPDATE returns, read under the row lock a concurrent refresh's
|
||||||
|
// hand-over also takes (classes.FollowDeviceJTI), so a refresh that moved
|
||||||
|
// the session first is blacklisted and one that comes after is refused. A
|
||||||
|
// blacklist failure answers the opaque 500 and leaves the row as it was.
|
||||||
|
// Success answers PHP's body with success true and the message Device
|
||||||
|
// revoked successfully, also for a row already revoked; a row of another
|
||||||
|
// user, or none, answers 404 with the error Device not found; no session
|
||||||
|
// answers 401 {"error":"Unauthorized"}.
|
||||||
|
func DeviceDestroy(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gdb, secret, ok := sessionDeps(w, app)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _, ok := deviceCaller(w, r, app, gdb, secret)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notFound := func() { writeJSON(w, http.StatusNotFound, map[string]any{"error": "Device not found"}) }
|
||||||
|
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
notFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
err = lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
// The jti current at the revoke, under the row lock: never one
|
||||||
|
// read before a concurrent refresh moved the session on.
|
||||||
|
var revoked []struct {
|
||||||
|
JWTJTI *string `gorm:"column:jwt_jti"`
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).Raw(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW()
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
RETURNING jwt_jti`, id, user.ID).Scan(&revoked).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(revoked) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
jti := revoked[0].JWTJTI
|
||||||
|
if jti == nil || *jti == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bl, ok := app.Lookup[bouncer.BlacklistStore]()
|
||||||
|
if !ok || bl == nil {
|
||||||
|
return errors.New("golem15.user: no JWT blacklist store is published")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
return bl.Add(ctx, *jti, now.Add(refreshTTL(app)+sessionTTL(app)+time.Minute), now)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.Error("golem15.user: revoke device session", "user_id", user.ID, "err", err)
|
||||||
|
writeOpaque500(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
notFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "Device revoked successfully"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireTimeOrNil is t as a +00:00 timestamp, or nil.
|
||||||
|
func wireTimeOrNil(t *time.Time) any {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return wire.Time{Time: *t}
|
||||||
|
}
|
||||||
26
controllers/login_gates.go
Normal file
26
controllers/login_gates.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckLoginGates consults the published *classes.LoginGates for a
|
||||||
|
// first-factor sign-in that already succeeded and returns its answer: nil,
|
||||||
|
// nil allows the sign-in, a result replaces the token response, and an error
|
||||||
|
// must fail the sign-in closed (answer the opaque 500, issue no token). It
|
||||||
|
// looks the registry up on every call and allows the sign-in when none is
|
||||||
|
// published. Password Login calls it; a social-login plugin calls it before
|
||||||
|
// it issues a token.
|
||||||
|
func CheckLoginGates(ctx context.Context, app *backpack.App, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
if app == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
gates, ok := app.Lookup[*classes.LoginGates]()
|
||||||
|
if !ok || gates == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return gates.Check(ctx, a)
|
||||||
|
}
|
||||||
@@ -102,8 +102,10 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
|
|||||||
// (a social sign-in). It restores a soft-deleted account and sends the
|
// (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
|
// 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
|
// 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
|
// caller does those checks, consults the login gates (CheckLoginGates)
|
||||||
// write is logged and does not fail it, as in Login.
|
// before it, and fires the login event (FireLoginEvent) after it minted the
|
||||||
|
// token. A failed last_seen write is logged and does not fail it, as in
|
||||||
|
// Login.
|
||||||
func FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *models.User, ip string) error {
|
func FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *models.User, ip string) error {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return errors.New("golem15.user: gorm handle is missing")
|
return errors.New("golem15.user: gorm handle is missing")
|
||||||
@@ -179,6 +181,17 @@ func FireDeactivatingEvent(ctx context.Context, app *backpack.App, user *models.
|
|||||||
return app.Events.Fire(ctx, &classes.DeactivatingEvent{User: user, Tx: tx})
|
return app.Events.Fire(ctx, &classes.DeactivatingEvent{User: user, Tx: tx})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FireLoginEvent fires classes.LoginEvent (golem15.user.login) for user
|
||||||
|
// with source, one of the classes.LoginSource constants, and returns the
|
||||||
|
// listeners' error. Call it after the session token was minted; a caller
|
||||||
|
// logs the error and still answers the sign-in.
|
||||||
|
func FireLoginEvent(ctx context.Context, app *backpack.App, user *models.User, source string) error {
|
||||||
|
if app == nil || app.Events == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return app.Events.Fire(ctx, &classes.LoginEvent{User: user, Source: source})
|
||||||
|
}
|
||||||
|
|
||||||
// IssueToken mints the session JWT for user exactly as /register and
|
// 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.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ update:
|
|||||||
preview:
|
preview:
|
||||||
headerPartial: hint
|
headerPartial: hint
|
||||||
|
|
||||||
recordActions: [activate, unban, unsuspend]
|
recordActions: [activate, unban, unsuspend, reset_two_factor]
|
||||||
|
|
||||||
messages:
|
messages:
|
||||||
create: golem15.user::lang.users.new_user
|
create: golem15.user::lang.users.new_user
|
||||||
|
|||||||
@@ -719,8 +719,15 @@ func (c usersAdminController) throttleState(ctx context.Context, userID uint) (b
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AdminRecordActions are the PHP preview handlers onActivate, onUnban and
|
// AdminRecordActions are the PHP preview handlers onActivate, onUnban and
|
||||||
// onUnsuspendUser. Each is offered only while it applies; the checks only
|
// onUnsuspendUser, plus reset_two_factor. Each is offered only while it
|
||||||
// read. There is no impersonate action and no guest action.
|
// applies; the checks only read. There is no impersonate action and no
|
||||||
|
// guest action.
|
||||||
|
//
|
||||||
|
// reset_two_factor ("Reset 2FA") needs classes.PermissionResetTwoFactor and
|
||||||
|
// asks for a danger confirmation. It is offered only when a two-factor
|
||||||
|
// plugin published a classes.TwoFactorAdmin (looked up per request) and the
|
||||||
|
// user has a second factor; Run hands the record to that service's Reset in
|
||||||
|
// the action's transaction.
|
||||||
func (c usersAdminController) AdminRecordActions() []pact.AdminRecordAction {
|
func (c usersAdminController) AdminRecordActions() []pact.AdminRecordAction {
|
||||||
asUser := func(record any) (*models.User, error) {
|
asUser := func(record any) (*models.User, error) {
|
||||||
user, ok := record.(*models.User)
|
user, ok := record.(*models.User)
|
||||||
@@ -785,9 +792,61 @@ func (c usersAdminController) AdminRecordActions() []pact.AdminRecordAction {
|
|||||||
func(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
func(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
||||||
return classes.UnsuspendUser(ctx, tx, user.ID)
|
return classes.UnsuspendUser(ctx, tx, user.ID)
|
||||||
}),
|
}),
|
||||||
|
c.resetTwoFactorAction(action),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetTwoFactorAction builds the reset_two_factor record action with the
|
||||||
|
// shared action builder, behind classes.PermissionResetTwoFactor and marked
|
||||||
|
// as dangerous. A member of a privileged group keeps the takeover guard of
|
||||||
|
// a password change and a delete: without
|
||||||
|
// golem15.users.manage_privileged_groups Run refuses with a 403 and its
|
||||||
|
// message before Reset runs, inside the action's transaction.
|
||||||
|
func (c usersAdminController) resetTwoFactorAction(action func(name, label, confirm, success string,
|
||||||
|
applies func(ctx context.Context, user *models.User) (bool, error),
|
||||||
|
run func(ctx context.Context, tx *gorm.DB, user *models.User) error,
|
||||||
|
) pact.AdminRecordAction) pact.AdminRecordAction {
|
||||||
|
reset := action("reset_two_factor", "reset_two_factor", "reset_two_factor_confirm", "reset_two_factor_success",
|
||||||
|
func(ctx context.Context, user *models.User) (bool, error) {
|
||||||
|
svc, ok := c.twoFactorAdmin()
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
db := requestDB(ctx, c.db)
|
||||||
|
if db == nil {
|
||||||
|
return false, errors.New("users admin: gorm handle is missing")
|
||||||
|
}
|
||||||
|
return svc.HasTwoFactor(ctx, db, user.ID)
|
||||||
|
},
|
||||||
|
func(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
||||||
|
svc, ok := c.twoFactorAdmin()
|
||||||
|
if !ok {
|
||||||
|
return errors.New("users admin: no two-factor plugin publishes classes.TwoFactorAdmin")
|
||||||
|
}
|
||||||
|
if err := c.guardPrivilegedMember(ctx, user.ID, usersLang+"privileged_member_reset_two_factor_forbidden", nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return svc.Reset(ctx, tx, user)
|
||||||
|
})
|
||||||
|
reset.Permissions = []string{classes.PermissionResetTwoFactor}
|
||||||
|
reset.Danger = true
|
||||||
|
return reset
|
||||||
|
}
|
||||||
|
|
||||||
|
// twoFactorAdmin looks up the classes.TwoFactorAdmin a two-factor plugin
|
||||||
|
// published, on every call, so a plugin that boots later is still found.
|
||||||
|
func (c usersAdminController) twoFactorAdmin() (classes.TwoFactorAdmin, bool) {
|
||||||
|
app := c.application()
|
||||||
|
if app == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
svc, ok := app.Lookup[classes.TwoFactorAdmin]()
|
||||||
|
if !ok || svc == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return svc, true
|
||||||
|
}
|
||||||
|
|
||||||
// usersHintView is the view model of controllers/users/_hint.htm, the status
|
// usersHintView is the view model of controllers/users/_hint.htm, the status
|
||||||
// callout above the preview: the callout kind (danger or warning) and the
|
// callout above the preview: the callout kind (danger or warning) and the
|
||||||
// phrase keys of its title and text. It is empty when no state applies, and
|
// phrase keys of its title and text. It is empty when no state applies, and
|
||||||
|
|||||||
1318
device_auth_test.go
Normal file
1318
device_auth_test.go
Normal file
File diff suppressed because it is too large
Load Diff
320
device_flow_smoke_test.go
Normal file
320
device_flow_smoke_test.go
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var deviceShortCodeRE = regexp.MustCompile(`^[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$`)
|
||||||
|
|
||||||
|
// TestDeviceCodesSmoke pins the device token, the short code, its
|
||||||
|
// normalisation and the auth_url template. No database.
|
||||||
|
func TestDeviceCodesSmoke(t *testing.T) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for range 1000 {
|
||||||
|
tok, err := classes.NewDeviceToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(tok) != 64 || strings.Trim(tok, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" {
|
||||||
|
t.Fatalf("token %q is not 64 base64url characters", tok)
|
||||||
|
}
|
||||||
|
if seen[tok] {
|
||||||
|
t.Fatalf("token %q repeated", tok)
|
||||||
|
}
|
||||||
|
seen[tok] = true
|
||||||
|
code, err := classes.NewDeviceShortCode()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !deviceShortCodeRE.MatchString(code) {
|
||||||
|
t.Fatalf("short code %q", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"abcd-efgh": "ABCD-EFGH",
|
||||||
|
"ABCDEFGH": "ABCD-EFGH",
|
||||||
|
" ab cd ef gh ": "ABCD-EFGH",
|
||||||
|
"2345-6789": "2345-6789",
|
||||||
|
} {
|
||||||
|
if got, ok := classes.NormalizeDeviceShortCode(in); !ok || got != want {
|
||||||
|
t.Fatalf("normalize %q = %q %v, want %q", in, got, ok, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, in := range []string{"ABCDEFG", "ABCDEFGHJ", "ABCDEFGO", "ABCDEFGI", "ABCDEFGL", "ABCDEFG0", "ABCDEFG1", ""} {
|
||||||
|
if got, ok := classes.NormalizeDeviceShortCode(in); ok {
|
||||||
|
t.Fatalf("normalize %q accepted as %q", in, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
if got := classes.DeviceAuthURL(app, "tok"); got != "/device/scan/tok" {
|
||||||
|
t.Fatalf("auth_url without urls = %q", got)
|
||||||
|
}
|
||||||
|
if err := app.Config.Set("app.url", "https://api.example.test/"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := classes.DeviceAuthURL(app, "tok"); got != "https://api.example.test/device/scan/tok" {
|
||||||
|
t.Fatalf("auth_url from app.url = %q", got)
|
||||||
|
}
|
||||||
|
if err := app.Config.Set("golem15.user.frontend_url", "https://www.example.test/"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := classes.DeviceAuthURL(app, "tok"); got != "https://www.example.test/device/scan/tok" {
|
||||||
|
t.Fatalf("auth_url from frontend_url = %q", got)
|
||||||
|
}
|
||||||
|
if err := app.Config.Set("golem15.user.device_auth.auth_url", "{frontend_url}/link?device={token}"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := classes.DeviceAuthURL(app, "tok"); got != "https://www.example.test/link?device=tok" {
|
||||||
|
t.Fatalf("auth_url from a custom template = %q", got)
|
||||||
|
}
|
||||||
|
if got := classes.DeviceAuthTTL(app); got != 5*time.Minute {
|
||||||
|
t.Fatalf("default ttl = %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
tv := "Living room TV"
|
||||||
|
for _, c := range []struct {
|
||||||
|
stored *string
|
||||||
|
ua string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{&tv, "Mozilla/5.0 (iPhone)", tv},
|
||||||
|
{nil, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", "iPhone"},
|
||||||
|
{nil, "Mozilla/5.0 (iPad; CPU OS 17_0)", "iPad"},
|
||||||
|
{nil, "Mozilla/5.0 (Linux; Android 14)", "Android Device"},
|
||||||
|
{nil, "Mozilla/5.0 (Windows NT 10.0)", "Windows PC"},
|
||||||
|
{nil, "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", "Mac"},
|
||||||
|
{nil, "Mozilla/5.0 (X11; Linux x86_64)", "Linux PC"},
|
||||||
|
{nil, "curl/8.0", "Unknown Device"},
|
||||||
|
} {
|
||||||
|
if got := classes.DeviceDisplayName(c.stored, c.ua); got != c.want {
|
||||||
|
t.Fatalf("display name of %q = %q, want %q", c.ua, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceCall runs one device handler with an optional bearer token and JSON
|
||||||
|
// body; pathToken fills {token} or {id}.
|
||||||
|
func deviceCall(t *testing.T, h http.HandlerFunc, method, path, bearerToken, body string, pathValues map[string]string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var req *http.Request
|
||||||
|
if body != "" {
|
||||||
|
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
} else {
|
||||||
|
req = httptest.NewRequest(method, path, nil)
|
||||||
|
}
|
||||||
|
req.Host = "example.com"
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) SmokeTV")
|
||||||
|
if bearerToken != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
||||||
|
}
|
||||||
|
for k, v := range pathValues {
|
||||||
|
req.SetPathValue(k, v)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceInitiated struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ShortCode string `json:"short_code"`
|
||||||
|
AuthURL string `json:"auth_url"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func initiateDevice(t *testing.T, app *backpack.App, name string) deviceInitiated {
|
||||||
|
t.Helper()
|
||||||
|
body := `{}`
|
||||||
|
if name != "" {
|
||||||
|
body = `{"device_name":"` + name + `"}`
|
||||||
|
}
|
||||||
|
rec := deviceCall(t, controllers.DeviceInitiate(app), http.MethodPost, "/_user/api/v1/devices/initiate", "", body, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data deviceInitiated `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || !out.Success {
|
||||||
|
t.Fatalf("initiate body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return out.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceStatus(t *testing.T, app *backpack.App, token string) (int, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
rec := deviceCall(t, controllers.DeviceStatus(app), http.MethodGet, "/_user/api/v1/devices/status/"+token, "", "", map[string]string{"token": token})
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||||
|
return rec.Code, body
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizeDevice(t *testing.T, app *backpack.App, bearerToken, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
return deviceCall(t, controllers.DeviceAuthorize(app), http.MethodPost, "/_user/api/v1/devices/authorize", bearerToken, body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceRow(t *testing.T, gdb *gorm.DB, token string) models.DeviceAuthSession {
|
||||||
|
t.Helper()
|
||||||
|
var row models.DeviceAuthSession
|
||||||
|
if err := gdb.Where("token = ?", classes.HashAPIToken(token)).Take(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusData(body map[string]any) map[string]any {
|
||||||
|
data, _ := body["data"].(map[string]any)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeviceFlowSmoke drives initiate, authorize and the one-time hand-over
|
||||||
|
// on Postgres.
|
||||||
|
func TestDeviceFlowSmoke(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Postgres (testcontainers)")
|
||||||
|
}
|
||||||
|
app, gdb := sessionApp(t, "0s")
|
||||||
|
email := "device-owner-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.test"
|
||||||
|
session := loginToken(t, app, gdb, email)
|
||||||
|
var owner models.User
|
||||||
|
if err := gdb.Where("email = ?", email).Take(&owner).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dev := initiateDevice(t, app, "Living room TV")
|
||||||
|
if len(dev.Token) != 64 || !deviceShortCodeRE.MatchString(dev.ShortCode) || !strings.HasSuffix(dev.AuthURL, "/device/scan/"+dev.ShortCode) || strings.Contains(dev.AuthURL, dev.Token) || !strings.HasSuffix(dev.ExpiresAt, "+00:00") {
|
||||||
|
t.Fatalf("initiate data = %+v", dev)
|
||||||
|
}
|
||||||
|
row := deviceRow(t, gdb, dev.Token)
|
||||||
|
if row.TokenHash != classes.HashAPIToken(dev.Token) || row.Status != models.DeviceStatusPending || row.UserID != nil ||
|
||||||
|
row.DeviceName == nil || *row.DeviceName != "Living room TV" || row.DeviceUserAgent == nil {
|
||||||
|
t.Fatalf("stored row = %+v", row)
|
||||||
|
}
|
||||||
|
if code, body := deviceStatus(t, app, dev.Token); code != http.StatusOK || body["success"] != true || statusData(body)["status"] != "pending" {
|
||||||
|
t.Fatalf("pending status = %d %v", code, body)
|
||||||
|
}
|
||||||
|
if code, body := deviceStatus(t, app, strings.Repeat("z", 64)); code != http.StatusNotFound || body["error"] != "Session not found" {
|
||||||
|
t.Fatalf("unknown status = %d %v", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize needs a session, answering PHP's 401 body.
|
||||||
|
if rec := authorizeDevice(t, app, "", `{"short_code":"`+dev.ShortCode+`"}`); rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Unauthorized"}` {
|
||||||
|
t.Fatalf("authorize without a session = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := authorizeDevice(t, app, session, `{"short_code":"ABC"}`); rec.Code != http.StatusUnprocessableEntity || rec.Body.String() != `{"error":"Please enter a valid 8-character code"}` {
|
||||||
|
t.Fatalf("short code of 3 = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(dev.ShortCode)
|
||||||
|
if rec := authorizeDevice(t, app, session, `{"short_code":"`+lower+`"}`); rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Device authorized successfully","success":true}` {
|
||||||
|
t.Fatalf("authorize = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := authorizeDevice(t, app, session, `{"short_code":"`+lower+`"}`); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("authorizing again for the same user = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first poll after confirmation receives the token, once.
|
||||||
|
code, body := deviceStatus(t, app, dev.Token)
|
||||||
|
data := statusData(body)
|
||||||
|
if code != http.StatusOK || data["status"] != "confirmed" || data["message"] != "Device authorized - complete login" || data["user_id"] != float64(owner.ID) {
|
||||||
|
t.Fatalf("hand-over = %d %v", code, body)
|
||||||
|
}
|
||||||
|
jwtToken, _ := data["token"].(string)
|
||||||
|
sub, _, _, jti, err := bouncer.VerifyClaims(jwtToken, sessionSecret)
|
||||||
|
if err != nil || sub != strconv.FormatUint(uint64(owner.ID), 10) {
|
||||||
|
t.Fatalf("device token sub=%q err=%v", sub, err)
|
||||||
|
}
|
||||||
|
if user, _ := data["user"].(map[string]any); user["email"] != email {
|
||||||
|
t.Fatalf("hand-over user = %v", data["user"])
|
||||||
|
}
|
||||||
|
row = deviceRow(t, gdb, dev.Token)
|
||||||
|
if row.Status != models.DeviceStatusUsed || row.JWTJTI == nil || *row.JWTJTI != jti || row.UsedAt == nil {
|
||||||
|
t.Fatalf("row after hand-over = %+v", row)
|
||||||
|
}
|
||||||
|
if _, body := deviceStatus(t, app, dev.Token); statusData(body)["status"] != "used" || statusData(body)["token"] != nil {
|
||||||
|
t.Fatalf("second poll = %v", body)
|
||||||
|
}
|
||||||
|
// The used session is past nothing: even past expires_at it stays used.
|
||||||
|
if err := gdb.Model(&models.DeviceAuthSession{}).Where("id = ?", row.ID).Update("expires_at", time.Now().Add(-time.Hour)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, body := deviceStatus(t, app, dev.Token); statusData(body)["status"] != "used" {
|
||||||
|
t.Fatalf("used session past expires_at = %v", body)
|
||||||
|
}
|
||||||
|
// A used session cannot be authorized again.
|
||||||
|
if rec := authorizeDevice(t, app, session, `{"token":"`+dev.Token+`"}`); rec.Code != http.StatusBadRequest || rec.Body.String() != `{"error":"Invalid or expired code"}` {
|
||||||
|
t.Fatalf("authorize a used session = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ten concurrent polls of a confirmed session yield exactly one token.
|
||||||
|
race := initiateDevice(t, app, "")
|
||||||
|
if rec := authorizeDevice(t, app, session, `{"token":"`+race.Token+`"}`); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("authorize by token = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
mu sync.Mutex
|
||||||
|
tokens int
|
||||||
|
used int
|
||||||
|
)
|
||||||
|
for range 10 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, body := deviceStatus(t, app, race.Token)
|
||||||
|
d := statusData(body)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if tok, _ := d["token"].(string); tok != "" {
|
||||||
|
tokens++
|
||||||
|
} else if d["status"] == "used" {
|
||||||
|
used++
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if tokens != 1 || used != 9 {
|
||||||
|
t.Fatalf("concurrent polls: %d tokens, %d used", tokens, used)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pending session past expires_at answers PHP's flat expired body.
|
||||||
|
stale := initiateDevice(t, app, "")
|
||||||
|
if err := gdb.Model(&models.DeviceAuthSession{}).Where("token = ?", classes.HashAPIToken(stale.Token)).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec := authorizeDevice(t, app, session, `{"short_code":"`+stale.ShortCode+`"}`); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("authorize an expired session = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
rec := deviceCall(t, controllers.DeviceStatus(app), http.MethodGet, "/_user/api/v1/devices/status/"+stale.Token, "", "", map[string]string{"token": stale.Token})
|
||||||
|
if rec.Body.String() != `{"message":"Authorization session has expired","status":"expired","success":true}` {
|
||||||
|
t.Fatalf("expired status = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if row := deviceRow(t, gdb, stale.Token); row.Status != models.DeviceStatusExpired {
|
||||||
|
t.Fatalf("expired row status = %s", row.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// device_name is bounded.
|
||||||
|
long := strings.Repeat("n", 256)
|
||||||
|
if rec := deviceCall(t, controllers.DeviceInitiate(app), http.MethodPost, "/_user/api/v1/devices/initiate", "", `{"device_name":"`+long+`"}`, nil); rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("device_name of 256 = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
183
device_revoke_smoke_test.go
Normal file
183
device_revoke_smoke_test.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// envDevice signs a device in through the assembled routes: initiate,
|
||||||
|
// authorize by session, first poll. It returns the device's session token,
|
||||||
|
// its jti and the session row id.
|
||||||
|
func envDevice(t *testing.T, env *adminEnv, session, name string) (string, string, uint) {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.call(t, http.MethodPost, "/_user/api/v1/devices/initiate", "", map[string]string{"device_name": name})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var started struct {
|
||||||
|
Data deviceInitiated `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &started); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/devices/authorize", session, map[string]string{"token": started.Data.Token})
|
||||||
|
rec = env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/devices/status/"+started.Data.Token, "", nil)
|
||||||
|
var polled struct {
|
||||||
|
Data struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &polled); err != nil || polled.Data.Token == "" {
|
||||||
|
t.Fatalf("hand-over %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
_, _, _, jti, err := bouncer.VerifyClaims(polled.Data.Token, sessionSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var row models.DeviceAuthSession
|
||||||
|
if err := env.gdb.Where("jwt_jti = ?", jti).Take(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return polled.Data.Token, jti, row.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceListBody struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Devices []struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
DeviceIP *string `json:"device_ip"`
|
||||||
|
AuthorizedAt *string `json:"authorized_at"`
|
||||||
|
LastActivity *string `json:"last_activity"`
|
||||||
|
IsCurrent bool `json:"is_current"`
|
||||||
|
} `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDevices(t *testing.T, env *adminEnv, token string) deviceListBody {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/devices", token, nil)
|
||||||
|
var body deviceListBody
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeviceRevokeSmoke lists and revokes device sessions through the
|
||||||
|
// assembled routes, including a device that refreshed its token first.
|
||||||
|
func TestDeviceRevokeSmoke(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Postgres")
|
||||||
|
}
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
owner := env.seedUser(t, "dev-owner", nil)
|
||||||
|
other := env.seedUser(t, "dev-other", nil)
|
||||||
|
status, session := env.userLogin(t, owner.Email, "correct-horse")
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("owner login = %d", status)
|
||||||
|
}
|
||||||
|
otherStatus, otherSession := env.userLogin(t, other.Email, "correct-horse")
|
||||||
|
if otherStatus != http.StatusOK {
|
||||||
|
t.Fatalf("other login = %d", otherStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
tvToken, _, tvID := envDevice(t, env, session, "Living room TV")
|
||||||
|
// A second used session with no name and an older activity, a pending
|
||||||
|
// session of the owner and a device of another user.
|
||||||
|
hourAgo := time.Now().Add(-time.Hour)
|
||||||
|
ua := "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)"
|
||||||
|
oldJTI := "dev-old-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||||
|
ownerID := owner.ID
|
||||||
|
phone := models.DeviceAuthSession{TokenHash: "a" + strconv.FormatInt(time.Now().UnixNano(), 10), UserID: &ownerID, Status: models.DeviceStatusUsed,
|
||||||
|
ExpiresAt: &hourAgo, DeviceUserAgent: &ua, ConfirmedAt: &hourAgo, LastActivityAt: &hourAgo, JWTJTI: &oldJTI}
|
||||||
|
pendingRow := models.DeviceAuthSession{TokenHash: "b" + strconv.FormatInt(time.Now().UnixNano(), 10), UserID: &ownerID, Status: models.DeviceStatusPending, ExpiresAt: &hourAgo}
|
||||||
|
if err := env.gdb.Create(&phone).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := env.gdb.Create(&pendingRow).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _, foreignID := envDevice(t, env, otherSession, "Other TV")
|
||||||
|
|
||||||
|
t.Run("list", func(t *testing.T) {
|
||||||
|
if rec := env.call(t, http.MethodGet, "/_user/api/v1/devices", "", nil); rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Unauthorized"}` {
|
||||||
|
t.Fatalf("list without a session = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
list := envDevices(t, env, tvToken)
|
||||||
|
if !list.Success || len(list.Devices) != 2 {
|
||||||
|
t.Fatalf("list = %+v", list)
|
||||||
|
}
|
||||||
|
first, second := list.Devices[0], list.Devices[1]
|
||||||
|
if first.ID != tvID || first.DeviceName != "Living room TV" || !first.IsCurrent || first.AuthorizedAt == nil || first.LastActivity == nil {
|
||||||
|
t.Fatalf("first device = %+v", first)
|
||||||
|
}
|
||||||
|
if second.ID != phone.ID || second.DeviceName != "iPhone" || second.IsCurrent {
|
||||||
|
t.Fatalf("second device = %+v", second)
|
||||||
|
}
|
||||||
|
for _, d := range envDevices(t, env, session).Devices {
|
||||||
|
if d.IsCurrent {
|
||||||
|
t.Fatalf("device %d is current for the owner's own session", d.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("revoke", func(t *testing.T) {
|
||||||
|
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", tvToken, nil)
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(tvID), 10), session, nil)
|
||||||
|
if rec.Body.String() != `{"message":"Device revoked successfully","success":true}` {
|
||||||
|
t.Fatalf("revoke body = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", tvToken, nil)
|
||||||
|
env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(tvID), 10), session, nil)
|
||||||
|
rec = env.expect(t, http.StatusNotFound, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(foreignID), 10), session, nil)
|
||||||
|
if rec.Body.String() != `{"error":"Device not found"}` {
|
||||||
|
t.Fatalf("foreign revoke body = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
// The id constraint keeps a non-numeric id away from the handler,
|
||||||
|
// which would answer 401 without a session.
|
||||||
|
if rec := env.call(t, http.MethodDelete, "/_user/api/v1/devices/abc", "", nil); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("DELETE /devices/abc = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var row models.DeviceAuthSession
|
||||||
|
if err := env.gdb.Where("id = ?", tvID).Take(&row).Error; err != nil || row.Status != models.DeviceStatusRevoked {
|
||||||
|
t.Fatalf("revoked row = %+v %v", row, err)
|
||||||
|
}
|
||||||
|
for _, d := range envDevices(t, env, session).Devices {
|
||||||
|
if d.ID == tvID {
|
||||||
|
t.Fatal("a revoked device is still listed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("revoke_after_refresh", func(t *testing.T) {
|
||||||
|
deskToken, deskJTI, deskID := envDevice(t, env, session, "Desktop")
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/refresh", deskToken, nil)
|
||||||
|
var refreshed struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &refreshed); err != nil || refreshed.Token == "" {
|
||||||
|
t.Fatalf("refresh %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
_, _, _, newJTI, err := bouncer.VerifyClaims(refreshed.Token, sessionSecret)
|
||||||
|
if err != nil || newJTI == deskJTI {
|
||||||
|
t.Fatalf("refreshed jti %q err %v", newJTI, err)
|
||||||
|
}
|
||||||
|
var row models.DeviceAuthSession
|
||||||
|
if err := env.gdb.Where("id = ?", deskID).Take(&row).Error; err != nil || row.JWTJTI == nil || *row.JWTJTI != newJTI {
|
||||||
|
t.Fatalf("row after refresh = %+v %v, want jti %s", row, err, newJTI)
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", refreshed.Token, nil)
|
||||||
|
env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(deskID), 10), session, nil)
|
||||||
|
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", deskToken, nil)
|
||||||
|
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", refreshed.Token, nil)
|
||||||
|
env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_user/api/v1/refresh", refreshed.Token, nil)
|
||||||
|
// The owner's own session is untouched.
|
||||||
|
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ plugin:
|
|||||||
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
|
manage_backend_links: Link frontend accounts to administrators
|
||||||
|
reset_two_factor: Reset two-factor authentication of users
|
||||||
users:
|
users:
|
||||||
menu_label: Users
|
menu_label: Users
|
||||||
new_user: New User
|
new_user: New User
|
||||||
@@ -52,8 +53,12 @@ users:
|
|||||||
unsuspend_user: Unsuspend user
|
unsuspend_user: Unsuspend user
|
||||||
unsuspend_confirm: Unsuspend this user?
|
unsuspend_confirm: Unsuspend this user?
|
||||||
unsuspend_success: User has been unsuspended.
|
unsuspend_success: User has been unsuspended.
|
||||||
|
reset_two_factor: Reset 2FA
|
||||||
|
reset_two_factor_confirm: Remove every second factor of this user? Their authenticator apps, security keys, recovery codes and trusted devices stop working, and they sign in with the password alone until they set up two-factor authentication again.
|
||||||
|
reset_two_factor_success: Two-factor authentication of the user has been reset.
|
||||||
privileged_member_forbidden: This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved.
|
privileged_member_forbidden: This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved.
|
||||||
privileged_member_delete_forbidden: Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted.
|
privileged_member_delete_forbidden: Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted.
|
||||||
|
privileged_member_reset_two_factor_forbidden: This user belongs to a privileged group. Resetting their two-factor authentication needs the permission to manage privileged groups. Nothing was changed.
|
||||||
privileged_group_forbidden: You do not have permission to change membership of privileged groups. Nothing was saved.
|
privileged_group_forbidden: You do not have permission to change membership of privileged groups. Nothing was saved.
|
||||||
user:
|
user:
|
||||||
label: User
|
label: User
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ plugin:
|
|||||||
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
|
manage_backend_links: Łącz konta na stronie z administratorami
|
||||||
|
reset_two_factor: Resetowanie uwierzytelniania dwuskładnikowego użytkowników
|
||||||
users:
|
users:
|
||||||
menu_label: Użytkownicy
|
menu_label: Użytkownicy
|
||||||
new_user: Nowy użytkownik
|
new_user: Nowy użytkownik
|
||||||
@@ -52,8 +53,12 @@ users:
|
|||||||
unsuspend_user: Odblokuj użytkownika
|
unsuspend_user: Odblokuj użytkownika
|
||||||
unsuspend_confirm: Odblokować tego użytkownika?
|
unsuspend_confirm: Odblokować tego użytkownika?
|
||||||
unsuspend_success: Udało się odblokować użytkownika.
|
unsuspend_success: Udało się odblokować użytkownika.
|
||||||
|
reset_two_factor: Resetuj 2FA
|
||||||
|
reset_two_factor_confirm: Usunąć wszystkie drugie składniki tego użytkownika? Jego aplikacje uwierzytelniające, klucze bezpieczeństwa, kody odzyskiwania i zaufane urządzenia przestaną działać, a do czasu ponownego włączenia uwierzytelniania dwuskładnikowego będzie logować się samym hasłem.
|
||||||
|
reset_two_factor_success: Uwierzytelnianie dwuskładnikowe użytkownika zostało zresetowane.
|
||||||
privileged_member_forbidden: Ten użytkownik należy do grupy uprzywilejowanej. Nie masz uprawnień do zmiany jego adresu e-mail ani hasła. Nic nie zostało zapisane.
|
privileged_member_forbidden: Ten użytkownik należy do grupy uprzywilejowanej. Nie masz uprawnień do zmiany jego adresu e-mail ani hasła. Nic nie zostało zapisane.
|
||||||
privileged_member_delete_forbidden: Użytkowników należących do grupy uprzywilejowanej nie można usunąć bez uprawnienia do zarządzania grupami uprzywilejowanymi. Nic nie zostało usunięte.
|
privileged_member_delete_forbidden: Użytkowników należących do grupy uprzywilejowanej nie można usunąć bez uprawnienia do zarządzania grupami uprzywilejowanymi. Nic nie zostało usunięte.
|
||||||
|
privileged_member_reset_two_factor_forbidden: Ten użytkownik należy do grupy uprzywilejowanej. Zresetowanie jego uwierzytelniania dwuskładnikowego wymaga uprawnienia do zarządzania grupami uprzywilejowanymi. Nic nie zostało zmienione.
|
||||||
privileged_group_forbidden: Nie masz uprawnień do zmiany członkostwa w grupach uprzywilejowanych. Nic nie zostało zapisane.
|
privileged_group_forbidden: Nie masz uprawnień do zmiany członkostwa w grupach uprzywilejowanych. Nic nie zostało zapisane.
|
||||||
user:
|
user:
|
||||||
label: Użytkownik
|
label: Użytkownik
|
||||||
|
|||||||
273
login_gate_smoke_test.go
Normal file
273
login_gate_smoke_test.go
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/postcard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gateFunc adapts a function to classes.LoginGate.
|
||||||
|
type gateFunc func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error)
|
||||||
|
|
||||||
|
func (f gateFunc) CheckLogin(ctx context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
return f(ctx, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginGatesSmoke pins the registry: nil and empty allow, the first
|
||||||
|
// non-nil result or error wins and stops the chain.
|
||||||
|
func TestLoginGatesSmoke(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var nilGates *classes.LoginGates
|
||||||
|
if res, err := nilGates.Check(ctx, classes.LoginAttempt{}); res != nil || err != nil {
|
||||||
|
t.Fatalf("nil registry = %v, %v", res, err)
|
||||||
|
}
|
||||||
|
empty := classes.NewLoginGates()
|
||||||
|
if res, err := empty.Check(ctx, classes.LoginAttempt{}); res != nil || err != nil {
|
||||||
|
t.Fatalf("empty registry = %v, %v", res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := &classes.LoginGateResult{Body: map[string]any{"two_factor_required": true}}
|
||||||
|
calls := 0
|
||||||
|
g := classes.NewLoginGates()
|
||||||
|
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
calls++
|
||||||
|
return nil, nil
|
||||||
|
}))
|
||||||
|
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
calls++
|
||||||
|
return want, nil
|
||||||
|
}))
|
||||||
|
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
calls++
|
||||||
|
return nil, nil
|
||||||
|
}))
|
||||||
|
res, err := g.Check(ctx, classes.LoginAttempt{})
|
||||||
|
if err != nil || res != want {
|
||||||
|
t.Fatalf("A(nil) then B(result) = %v, %v", res, err)
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("gates called %d times, want 2 (the chain stops at the result)", calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
boom := errors.New("gate failed")
|
||||||
|
later := 0
|
||||||
|
e := classes.NewLoginGates()
|
||||||
|
e.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return nil, boom }))
|
||||||
|
e.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
later++
|
||||||
|
return nil, nil
|
||||||
|
}))
|
||||||
|
if res, err := e.Check(ctx, classes.LoginAttempt{}); res != nil || !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("erroring gate = %v, %v", res, err)
|
||||||
|
}
|
||||||
|
if later != 0 {
|
||||||
|
t.Fatal("a gate after the erroring one was called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginEvents records every classes.LoginEvent fired on app.
|
||||||
|
type loginEvents struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
got []classes.LoginEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenLoginEvents(app *backpack.App, owner string) *loginEvents {
|
||||||
|
rec := &loginEvents{}
|
||||||
|
app.Events.Listen[*classes.LoginEvent](owner, func(_ context.Context, e *classes.LoginEvent) error {
|
||||||
|
rec.mu.Lock()
|
||||||
|
rec.got = append(rec.got, *e)
|
||||||
|
rec.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loginEvents) count() int {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
return len(l.got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gateApp is sessionApp with a published login-gate registry (bootPlugin
|
||||||
|
// runs Boot only, so the test publishes what Register would).
|
||||||
|
func gateApp(t *testing.T) (*backpack.App, *classes.LoginGates) {
|
||||||
|
t.Helper()
|
||||||
|
app, _ := sessionApp(t, "0s")
|
||||||
|
gates := classes.NewLoginGates()
|
||||||
|
if err := app.Publish(gates); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return app, gates
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBody(t *testing.T, raw []byte) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &body); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", raw, err)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginGateSmoke drives POST /login through a registered gate.
|
||||||
|
func TestLoginGateSmoke(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Postgres (testcontainers)")
|
||||||
|
}
|
||||||
|
_, gdb := sessionApp(t, "0s")
|
||||||
|
|
||||||
|
t.Run("no_gate", func(t *testing.T) {
|
||||||
|
app, _ := gateApp(t)
|
||||||
|
events := listenLoginEvents(app, "test.login-gate.no-gate")
|
||||||
|
user := insertUser(t, gdb, "gate-none@example.test", "correct-horse")
|
||||||
|
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-none@example.test","password":"correct-horse"}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := decodeBody(t, rec.Body.Bytes())
|
||||||
|
if tok, _ := body["token"].(string); tok == "" {
|
||||||
|
t.Fatalf("no token: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := body["user"].(map[string]any); !ok {
|
||||||
|
t.Fatalf("no user: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if events.count() != 1 {
|
||||||
|
t.Fatalf("login events = %d, want 1", events.count())
|
||||||
|
}
|
||||||
|
if e := events.got[0]; e.Source != classes.LoginSourcePassword || e.User == nil || e.User.ID != user.ID {
|
||||||
|
t.Fatalf("event = %+v", e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
challengeBody := map[string]any{
|
||||||
|
"two_factor_required": true,
|
||||||
|
"challenge_token": strings.Repeat("x", 64),
|
||||||
|
"available_methods": []any{"totp"},
|
||||||
|
"expires_at": "2026-10-21T12:00:00+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("challenge", func(t *testing.T) {
|
||||||
|
app, gates := gateApp(t)
|
||||||
|
events := listenLoginEvents(app, "test.login-gate.challenge")
|
||||||
|
var seen classes.LoginAttempt
|
||||||
|
gates.Add(gateFunc(func(_ context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
seen = a
|
||||||
|
return &classes.LoginGateResult{Status: http.StatusOK, Body: challengeBody}, nil
|
||||||
|
}))
|
||||||
|
insertUser(t, gdb, "gate-challenge@example.test", "correct-horse")
|
||||||
|
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login",
|
||||||
|
`{"email":"gate-challenge@example.test","password":"correct-horse","trusted_device_token":"td-123"}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := decodeBody(t, rec.Body.Bytes())
|
||||||
|
if !reflect.DeepEqual(body, challengeBody) {
|
||||||
|
t.Fatalf("body = %#v", body)
|
||||||
|
}
|
||||||
|
if _, ok := body["token"]; ok {
|
||||||
|
t.Fatal("a challenged login answered a token")
|
||||||
|
}
|
||||||
|
if seen.Source != classes.LoginSourcePassword || seen.User == nil || seen.Request == nil {
|
||||||
|
t.Fatalf("attempt = %+v", seen)
|
||||||
|
}
|
||||||
|
if seen.Fields["trusted_device_token"] != "td-123" {
|
||||||
|
t.Fatalf("fields = %#v", seen.Fields)
|
||||||
|
}
|
||||||
|
if events.count() != 0 {
|
||||||
|
t.Fatalf("a challenged login fired %d login events", events.count())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refusal", func(t *testing.T) {
|
||||||
|
app, gates := gateApp(t)
|
||||||
|
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
return &classes.LoginGateResult{Status: http.StatusUnauthorized, Body: map[string]any{"error": "Invalid or expired challenge."}}, nil
|
||||||
|
}))
|
||||||
|
insertUser(t, gdb, "gate-refusal@example.test", "correct-horse")
|
||||||
|
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-refusal@example.test","password":"correct-horse"}`)
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Invalid or expired challenge."}` {
|
||||||
|
t.Fatalf("refusal = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("gate_error", func(t *testing.T) {
|
||||||
|
app, gates := gateApp(t)
|
||||||
|
events := listenLoginEvents(app, "test.login-gate.error")
|
||||||
|
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
return nil, errors.New("challenge store down")
|
||||||
|
}))
|
||||||
|
insertUser(t, gdb, "gate-error@example.test", "correct-horse")
|
||||||
|
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-error@example.test","password":"correct-horse"}`)
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("gate error = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), `"token"`) {
|
||||||
|
t.Fatalf("gate error answered a token: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if events.count() != 0 {
|
||||||
|
t.Fatal("a failed gate fired a login event")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("soft_deleted_stays", func(t *testing.T) {
|
||||||
|
app, gates := gateApp(t)
|
||||||
|
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
return &classes.LoginGateResult{Body: challengeBody}, nil
|
||||||
|
}))
|
||||||
|
mail := &captureMail{}
|
||||||
|
if err := app.Publish[postcard.Mailer](mail); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user := insertUser(t, gdb, "gate-soft@example.test", "correct-horse")
|
||||||
|
if err := gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-soft@example.test","password":"correct-horse"}`)
|
||||||
|
if rec.Code != http.StatusOK || decodeBody(t, rec.Body.Bytes())["two_factor_required"] != true {
|
||||||
|
t.Fatalf("challenge = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var still models.User
|
||||||
|
if err := gdb.Unscoped().First(&still, user.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !still.DeletedAt.Valid {
|
||||||
|
t.Fatal("a challenged login restored the deactivated account")
|
||||||
|
}
|
||||||
|
if len(mail.got) != 0 {
|
||||||
|
t.Fatalf("a challenged login sent %v", mail.got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a gate the same login restores the account, as before.
|
||||||
|
plain, _ := gateApp(t)
|
||||||
|
plainMail := &captureMail{}
|
||||||
|
if err := plain.Publish[postcard.Mailer](plainMail); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec = postJSON(t, controllers.Login(plain), "/_user/api/v1/login", `{"email":"gate-soft@example.test","password":"correct-horse"}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("restore = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var restored models.User
|
||||||
|
if err := gdb.Unscoped().First(&restored, user.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if restored.DeletedAt.Valid {
|
||||||
|
t.Fatal("login without a gate did not restore the account")
|
||||||
|
}
|
||||||
|
if len(plainMail.got) != 1 || !strings.HasPrefix(plainMail.got[0], "golem15.user::mail.reactivate") {
|
||||||
|
t.Fatalf("reactivate mails = %v", plainMail.got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
401
login_gate_test.go
Normal file
401
login_gate_test.go
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLoginGates pins the registry of login gates (D-02) without a
|
||||||
|
// database: a nil or empty registry allows, gates run in the order they were
|
||||||
|
// added, the first result wins and stops the chain, an error stops the chain
|
||||||
|
// and is returned, nil gates are ignored, and Add and Check are safe for
|
||||||
|
// concurrent use (run with -race). CheckLoginGates allows a sign-in when no
|
||||||
|
// app or no registry is published.
|
||||||
|
func TestLoginGates(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
attempt := classes.LoginAttempt{Source: classes.LoginSourcePassword}
|
||||||
|
|
||||||
|
t.Run("nil_and_empty_allow", func(t *testing.T) {
|
||||||
|
var nilGates *classes.LoginGates
|
||||||
|
if res, err := nilGates.Check(ctx, attempt); res != nil || err != nil {
|
||||||
|
t.Fatalf("nil registry = %v, %v", res, err)
|
||||||
|
}
|
||||||
|
nilGates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
t.Fatal("a gate added to a nil registry ran")
|
||||||
|
return nil, nil
|
||||||
|
}))
|
||||||
|
if res, err := classes.NewLoginGates().Check(ctx, attempt); res != nil || err != nil {
|
||||||
|
t.Fatalf("empty registry = %v, %v", res, err)
|
||||||
|
}
|
||||||
|
g := classes.NewLoginGates()
|
||||||
|
g.Add(nil)
|
||||||
|
if res, err := g.Check(ctx, attempt); res != nil || err != nil {
|
||||||
|
t.Fatalf("registry with a nil gate = %v, %v", res, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("order_and_first_result_wins", func(t *testing.T) {
|
||||||
|
var order []string
|
||||||
|
gate := func(name string, res *classes.LoginGateResult) classes.LoginGate {
|
||||||
|
return gateFunc(func(_ context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
if a.Source != classes.LoginSourcePassword {
|
||||||
|
t.Fatalf("gate %s saw source %q", name, a.Source)
|
||||||
|
}
|
||||||
|
order = append(order, name)
|
||||||
|
return res, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
first := &classes.LoginGateResult{Status: http.StatusOK, Body: map[string]any{"two_factor_required": true}}
|
||||||
|
second := &classes.LoginGateResult{Status: http.StatusTooManyRequests, Body: map[string]any{"error": "later"}}
|
||||||
|
g := classes.NewLoginGates()
|
||||||
|
g.Add(gate("a", nil))
|
||||||
|
g.Add(gate("b", nil))
|
||||||
|
g.Add(gate("c", first))
|
||||||
|
g.Add(gate("d", second))
|
||||||
|
res, err := g.Check(ctx, attempt)
|
||||||
|
if err != nil || res != first {
|
||||||
|
t.Fatalf("Check = %v, %v; want the first result", res, err)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(order) != "[a b c]" {
|
||||||
|
t.Fatalf("gates ran %v, want [a b c]", order)
|
||||||
|
}
|
||||||
|
order = nil
|
||||||
|
all := classes.NewLoginGates()
|
||||||
|
all.Add(gate("x", nil))
|
||||||
|
all.Add(gate("y", nil))
|
||||||
|
if res, err := all.Check(ctx, attempt); res != nil || err != nil {
|
||||||
|
t.Fatalf("allowing gates = %v, %v", res, err)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(order) != "[x y]" {
|
||||||
|
t.Fatalf("allowing gates ran %v, want [x y]", order)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error_stops_the_chain", func(t *testing.T) {
|
||||||
|
boom := errors.New("challenge store down")
|
||||||
|
later := 0
|
||||||
|
g := classes.NewLoginGates()
|
||||||
|
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return nil, nil }))
|
||||||
|
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
// A gate that answers both a result and an error fails closed.
|
||||||
|
return &classes.LoginGateResult{Body: map[string]any{"ignored": true}}, boom
|
||||||
|
}))
|
||||||
|
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
later++
|
||||||
|
return nil, nil
|
||||||
|
}))
|
||||||
|
res, err := g.Check(ctx, attempt)
|
||||||
|
if res != nil || !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("erroring gate = %v, %v; want nil and the error", res, err)
|
||||||
|
}
|
||||||
|
if later != 0 {
|
||||||
|
t.Fatal("a gate after the erroring one ran")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("concurrent_add_and_check", func(t *testing.T) {
|
||||||
|
g := classes.NewLoginGates()
|
||||||
|
var calls atomic.Int64
|
||||||
|
allow := gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range 64 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if i%2 == 0 {
|
||||||
|
g.Add(allow)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res, err := g.Check(ctx, attempt); res != nil || err != nil {
|
||||||
|
t.Errorf("concurrent Check = %v, %v", res, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
calls.Store(0)
|
||||||
|
if res, err := g.Check(ctx, attempt); res != nil || err != nil {
|
||||||
|
t.Fatalf("Check after the adds = %v, %v", res, err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 32 {
|
||||||
|
t.Fatalf("Check ran %d gates, want the 32 added", calls.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("check_login_gates_lookup", func(t *testing.T) {
|
||||||
|
if res, err := controllers.CheckLoginGates(ctx, nil, attempt); res != nil || err != nil {
|
||||||
|
t.Fatalf("no app = %v, %v", res, err)
|
||||||
|
}
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
if res, err := controllers.CheckLoginGates(ctx, app, attempt); res != nil || err != nil {
|
||||||
|
t.Fatalf("no published registry = %v, %v", res, err)
|
||||||
|
}
|
||||||
|
gates := classes.NewLoginGates()
|
||||||
|
if err := app.Publish(gates); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := &classes.LoginGateResult{Body: map[string]any{"two_factor_required": true}}
|
||||||
|
// A gate added after publishing is seen: the registry is looked up
|
||||||
|
// on every call.
|
||||||
|
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return want, nil }))
|
||||||
|
if res, err := controllers.CheckLoginGates(ctx, app, attempt); res != want || err != nil {
|
||||||
|
t.Fatalf("published registry = %v, %v", res, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// countingGate counts its calls, records the attempts it saw and answers
|
||||||
|
// with result (nil allows).
|
||||||
|
type countingGate struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls int
|
||||||
|
seen []classes.LoginAttempt
|
||||||
|
result *classes.LoginGateResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *countingGate) CheckLogin(_ context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
g.calls++
|
||||||
|
snapshot := a
|
||||||
|
if a.User != nil {
|
||||||
|
user := *a.User
|
||||||
|
snapshot.User = &user
|
||||||
|
}
|
||||||
|
g.seen = append(g.seen, snapshot)
|
||||||
|
return g.result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *countingGate) count() int {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
return g.calls
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatedEnv boots the plugin through the admin harness with one counting
|
||||||
|
// gate added to the registry its Register published.
|
||||||
|
func gatedEnv(t *testing.T, result *classes.LoginGateResult) (*adminEnv, *countingGate) {
|
||||||
|
t.Helper()
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
gates, ok := env.app.Lookup[*classes.LoginGates]()
|
||||||
|
if !ok || gates == nil {
|
||||||
|
t.Fatal("the plugin's Register published no *classes.LoginGates")
|
||||||
|
}
|
||||||
|
gate := &countingGate{result: result}
|
||||||
|
gates.Add(gate)
|
||||||
|
return env, gate
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginGateOrder pins where POST /login consults the gates (D-02, D-18
|
||||||
|
// A10): after the ban check, the password, the activation and the bcrypt
|
||||||
|
// rehash, and before the restore of a deactivated account, its reactivate
|
||||||
|
// mail, the mint and the login event. A gate's answer passes through with
|
||||||
|
// its status and body; JWT refresh never consults a gate.
|
||||||
|
func TestLoginGateOrder(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Postgres")
|
||||||
|
}
|
||||||
|
challenge := &classes.LoginGateResult{Body: map[string]any{
|
||||||
|
"two_factor_required": true,
|
||||||
|
"challenge_token": "c",
|
||||||
|
"available_methods": []string{"totp"},
|
||||||
|
"expires_at": "2026-10-26T12:00:00+00:00",
|
||||||
|
}}
|
||||||
|
|
||||||
|
t.Run("first_factor_failures_never_reach_the_gate", func(t *testing.T) {
|
||||||
|
env, gate := gatedEnv(t, challenge)
|
||||||
|
user := env.seedUser(t, "lgo-wrong", nil)
|
||||||
|
if status, _ := env.userLogin(t, user.Email, "wrong-password"); status != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("wrong password = %d", status)
|
||||||
|
}
|
||||||
|
if status, _ := env.userLogin(t, "lgo-nobody-"+adminStamp()+"@example.test", "correct-horse"); status != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("unknown email = %d", status)
|
||||||
|
}
|
||||||
|
inactive := env.seedUser(t, "lgo-inactive", func(u *models.User) { u.IsActivated = false })
|
||||||
|
if status, _ := env.userLogin(t, inactive.Email, "correct-horse"); status != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("not activated = %d", status)
|
||||||
|
}
|
||||||
|
banned := env.seedUser(t, "lgo-banned", nil)
|
||||||
|
env.seedThrottle(t, banned, "", true, nil)
|
||||||
|
if status, _ := env.userLogin(t, banned.Email, "correct-horse"); status != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("banned = %d", status)
|
||||||
|
}
|
||||||
|
if gate.count() != 0 {
|
||||||
|
t.Fatalf("the gate ran %d times for refused first factors", gate.count())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("gate_sees_the_attempt", func(t *testing.T) {
|
||||||
|
env, gate := gatedEnv(t, challenge)
|
||||||
|
user := env.seedUser(t, "lgo-attempt", nil)
|
||||||
|
rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{
|
||||||
|
"email": user.Email, "password": "correct-horse", "trusted_device_token": "td-attempt",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if gate.count() != 1 {
|
||||||
|
t.Fatalf("gate calls = %d, want 1", gate.count())
|
||||||
|
}
|
||||||
|
seen := gate.seen[0]
|
||||||
|
if seen.Source != classes.LoginSourcePassword || seen.User == nil || seen.User.ID != user.ID || seen.Request == nil {
|
||||||
|
t.Fatalf("attempt = %+v", seen)
|
||||||
|
}
|
||||||
|
if seen.Fields["trusted_device_token"] != "td-attempt" || seen.Fields["email"] != user.Email {
|
||||||
|
t.Fatalf("attempt fields = %#v", seen.Fields)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rehash_before_a_challenge", func(t *testing.T) {
|
||||||
|
env, gate := gatedEnv(t, challenge)
|
||||||
|
probe := &passwordProbe{}
|
||||||
|
env.app.Events.Listen("test.login-gate-order.rehash", func(_ context.Context, ev *classes.AccessRevokedEvent) error {
|
||||||
|
probe.mu.Lock()
|
||||||
|
probe.seen = append(probe.seen, *ev)
|
||||||
|
probe.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
user := env.seedUser(t, "lgo-rehash", nil)
|
||||||
|
if err := env.app.Config.Set("golem15.user.password.bcrypt_cost", 11); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"})
|
||||||
|
if rec.Code != http.StatusOK || decodeBody(t, rec.Body.Bytes())["two_factor_required"] != true {
|
||||||
|
t.Fatalf("challenged login = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if gate.count() != 1 {
|
||||||
|
t.Fatalf("gate calls = %d", gate.count())
|
||||||
|
}
|
||||||
|
stored := env.reload(t, user.ID).Password
|
||||||
|
if stored == user.Password || bouncer.NeedsRehash(stored, 11) || !bouncer.CheckPassword(stored, "correct-horse") {
|
||||||
|
t.Fatal("a challenged login did not rehash the password at the higher cost")
|
||||||
|
}
|
||||||
|
if seen := probe.take(); len(seen) != 0 {
|
||||||
|
t.Fatalf("the rehash fired %+v", seen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("challenged_soft_deleted_stays_deleted", func(t *testing.T) {
|
||||||
|
env, gate := gatedEnv(t, challenge)
|
||||||
|
events := listenLoginEvents(env.app, "test.login-gate-order.soft")
|
||||||
|
user := env.seedUser(t, "lgo-soft", nil)
|
||||||
|
if err := env.gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"})
|
||||||
|
body := decodeBody(t, rec.Body.Bytes())
|
||||||
|
if rec.Code != http.StatusOK || body["two_factor_required"] != true || body["token"] != nil {
|
||||||
|
t.Fatalf("challenged login = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !gate.seen[0].User.DeletedAt.Valid {
|
||||||
|
t.Fatal("the gate saw a restored account; the restore must wait for the gates")
|
||||||
|
}
|
||||||
|
if !env.reload(t, user.ID).DeletedAt.Valid {
|
||||||
|
t.Fatal("a challenged login restored the deactivated account")
|
||||||
|
}
|
||||||
|
if mails := env.mails(user.Email); len(mails) != 0 {
|
||||||
|
t.Fatalf("a challenged login sent %d mails", len(mails))
|
||||||
|
}
|
||||||
|
if events.count() != 0 {
|
||||||
|
t.Fatalf("a challenged login fired %d login events", events.count())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allowed_soft_deleted_restored_once", func(t *testing.T) {
|
||||||
|
env, gate := gatedEnv(t, nil)
|
||||||
|
events := listenLoginEvents(env.app, "test.login-gate-order.allowed")
|
||||||
|
user := env.seedUser(t, "lgo-allowed", nil)
|
||||||
|
if err := env.gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
status, token := env.userLogin(t, user.Email, "correct-horse")
|
||||||
|
if status != http.StatusOK || token == "" {
|
||||||
|
t.Fatalf("allowed login = %d", status)
|
||||||
|
}
|
||||||
|
if gate.count() != 1 {
|
||||||
|
t.Fatalf("gate calls = %d", gate.count())
|
||||||
|
}
|
||||||
|
if env.reload(t, user.ID).DeletedAt.Valid {
|
||||||
|
t.Fatal("an allowed login did not restore the account")
|
||||||
|
}
|
||||||
|
if mails := env.mails(user.Email); len(mails) != 1 {
|
||||||
|
t.Fatalf("reactivate mails = %d, want 1", len(mails))
|
||||||
|
}
|
||||||
|
if events.count() != 1 || events.got[0].Source != classes.LoginSourcePassword || events.got[0].User.ID != user.ID {
|
||||||
|
t.Fatalf("login events = %+v", events.got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("gate_answer_passes_through", func(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
result *classes.LoginGateResult
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"status_zero_is_200", &classes.LoginGateResult{Body: map[string]any{"two_factor_required": true}}, http.StatusOK, `{"two_factor_required":true}`},
|
||||||
|
{"refusal", &classes.LoginGateResult{Status: http.StatusUnauthorized, Body: map[string]any{"error": "Invalid or expired challenge."}}, http.StatusUnauthorized, `{"error":"Invalid or expired challenge."}`},
|
||||||
|
{"other_status", &classes.LoginGateResult{Status: http.StatusTooManyRequests, Body: map[string]any{"error": "slow down", "retry": 3}}, http.StatusTooManyRequests, `{"error":"slow down","retry":3}`},
|
||||||
|
} {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
env, _ := gatedEnv(t, c.result)
|
||||||
|
events := listenLoginEvents(env.app, "test.login-gate-order."+c.name)
|
||||||
|
user := env.seedUser(t, "lgo-"+c.name, nil)
|
||||||
|
rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"})
|
||||||
|
if rec.Code != c.status || rec.Body.String() != c.body {
|
||||||
|
t.Fatalf("answer = %d %s, want %d %s", rec.Code, rec.Body.String(), c.status, c.body)
|
||||||
|
}
|
||||||
|
if events.count() != 0 {
|
||||||
|
t.Fatal("a gate answer fired a login event")
|
||||||
|
}
|
||||||
|
if env.reload(t, user.ID).LastSeen != nil {
|
||||||
|
t.Fatal("a gate answer stamped last_seen")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refresh_never_consults_a_gate", func(t *testing.T) {
|
||||||
|
env, gate := gatedEnv(t, nil)
|
||||||
|
user := env.seedUser(t, "lgo-refresh", nil)
|
||||||
|
status, token := env.userLogin(t, user.Email, "correct-horse")
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("login = %d", status)
|
||||||
|
}
|
||||||
|
if gate.count() != 1 {
|
||||||
|
t.Fatalf("gate calls after login = %d", gate.count())
|
||||||
|
}
|
||||||
|
// From now on the gate would challenge: refresh must not ask it.
|
||||||
|
gate.mu.Lock()
|
||||||
|
gate.result = challenge
|
||||||
|
gate.mu.Unlock()
|
||||||
|
for range 2 {
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/refresh", token, nil)
|
||||||
|
var body struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" {
|
||||||
|
t.Fatalf("refresh body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
token = body.Token
|
||||||
|
}
|
||||||
|
if gate.count() != 1 {
|
||||||
|
t.Fatalf("refresh consulted the gate: %d calls", gate.count())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
66
models/device_auth_session.go
Normal file
66
models/device_auth_session.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Device authorization session statuses, PHP's DeviceAuthSession constants.
|
||||||
|
// pending becomes confirmed when a signed-in user authorizes the device,
|
||||||
|
// confirmed becomes used on the device's first poll (which receives the
|
||||||
|
// session token), used becomes revoked when the user revokes the device,
|
||||||
|
// and pending or confirmed become expired once expires_at passes. used and
|
||||||
|
// revoked never become expired.
|
||||||
|
const (
|
||||||
|
DeviceStatusPending = "pending"
|
||||||
|
DeviceStatusConfirmed = "confirmed"
|
||||||
|
DeviceStatusUsed = "used"
|
||||||
|
DeviceStatusExpired = "expired"
|
||||||
|
DeviceStatusRevoked = "revoked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeviceAuthSession is one QR or short-code device authorization
|
||||||
|
// (golem15_user_device_auth_sessions). TokenHash is the SHA-256 hex of the
|
||||||
|
// device token (column token, PHP's name); the token itself is never
|
||||||
|
// stored. JWTJTI is the jti of the session token the device received,
|
||||||
|
// followed through refresh.
|
||||||
|
type DeviceAuthSession struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
TokenHash string `gorm:"column:token" json:"-"`
|
||||||
|
ShortCode *string `gorm:"column:short_code" json:"-"`
|
||||||
|
UserID *uint `gorm:"column:user_id"`
|
||||||
|
Status string `gorm:"column:status"`
|
||||||
|
ExpiresAt *time.Time `gorm:"column:expires_at"`
|
||||||
|
DeviceIP *string `gorm:"column:device_ip"`
|
||||||
|
DeviceUserAgent *string `gorm:"column:device_user_agent"`
|
||||||
|
DeviceName *string `gorm:"column:device_name"`
|
||||||
|
AuthIP *string `gorm:"column:auth_ip"`
|
||||||
|
ConfirmedAt *time.Time `gorm:"column:confirmed_at"`
|
||||||
|
UsedAt *time.Time `gorm:"column:used_at"`
|
||||||
|
SessionID *string `gorm:"column:session_id"`
|
||||||
|
LastActivityAt *time.Time `gorm:"column:last_activity_at"`
|
||||||
|
JWTJTI *string `gorm:"column:jwt_jti" json:"-"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DeviceAuthSession) TableName() string { return "golem15_user_device_auth_sessions" }
|
||||||
|
|
||||||
|
func (DeviceAuthSession) Fillable() []string {
|
||||||
|
return []string{
|
||||||
|
"token", "short_code", "user_id", "status", "expires_at", "device_ip",
|
||||||
|
"device_user_agent", "device_name", "auth_ip", "confirmed_at", "used_at",
|
||||||
|
"session_id", "last_activity_at", "jwt_jti",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DeviceAuthSession) Hidden() []string { return []string{"token", "short_code", "jwt_jti"} }
|
||||||
|
|
||||||
|
// ExpiredAt reports whether a pending or confirmed session is past
|
||||||
|
// expires_at at now. A used, revoked or expired session, and one without
|
||||||
|
// expires_at, is never newly expired.
|
||||||
|
func (s DeviceAuthSession) ExpiredAt(now time.Time) bool {
|
||||||
|
if s.Status != DeviceStatusPending && s.Status != DeviceStatusConfirmed {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.ExpiresAt != nil && !s.ExpiresAt.After(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { Register(DeviceAuthSession{}) }
|
||||||
153
password_changed_smoke_test.go
Normal file
153
password_changed_smoke_test.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// passwordProbe records the AccessRevokedEvents of an app (the D-07
|
||||||
|
// password-change hook) and can make the listener fail.
|
||||||
|
type passwordProbe struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seen []classes.AccessRevokedEvent
|
||||||
|
fail atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *passwordProbe) take() []classes.AccessRevokedEvent {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
out := p.seen
|
||||||
|
p.seen = nil
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// onePasswordChange asserts exactly one event for id with reason, fired in
|
||||||
|
// a transaction and reporting PasswordChanged.
|
||||||
|
func onePasswordChange(t *testing.T, probe *passwordProbe, id uint, reason string) {
|
||||||
|
t.Helper()
|
||||||
|
seen := probe.take()
|
||||||
|
if len(seen) != 1 {
|
||||||
|
t.Fatalf("%s fired %d events, want 1: %+v", reason, len(seen), seen)
|
||||||
|
}
|
||||||
|
ev := seen[0]
|
||||||
|
if ev.Reason != reason || len(ev.UserIDs) != 1 || ev.UserIDs[0] != id || ev.Tx == nil || !ev.PasswordChanged() {
|
||||||
|
t.Fatalf("%s event = %+v (PasswordChanged %v)", reason, ev, ev.PasswordChanged())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasswordChangedEventSmoke pins the D-07 hook a two-factor plugin
|
||||||
|
// listens to: a password reset, the user's own change and an administrator
|
||||||
|
// setting a password each fire one AccessRevokedEvent that reports
|
||||||
|
// PasswordChanged, inside the write's transaction; the bcrypt rehash on
|
||||||
|
// login fires none; a failing listener rolls the change back. The event
|
||||||
|
// itself predates this test (TestAccessRevocation pins its other fields).
|
||||||
|
func TestPasswordChangedEventSmoke(t *testing.T) {
|
||||||
|
for reason, want := range map[string]bool{
|
||||||
|
classes.AccessRevokedPasswordReset: true,
|
||||||
|
classes.AccessRevokedPasswordChanged: true,
|
||||||
|
classes.AccessRevokedPasswordSetByAdmin: true,
|
||||||
|
classes.AccessRevokedBan: false,
|
||||||
|
"": false,
|
||||||
|
} {
|
||||||
|
if got := (&classes.AccessRevokedEvent{Reason: reason}).PasswordChanged(); got != want {
|
||||||
|
t.Fatalf("PasswordChanged(%q) = %v, want %v", reason, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var nilEvent *classes.AccessRevokedEvent
|
||||||
|
if nilEvent.PasswordChanged() {
|
||||||
|
t.Fatal("a nil event reports a password change")
|
||||||
|
}
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Postgres")
|
||||||
|
}
|
||||||
|
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
probe := &passwordProbe{}
|
||||||
|
env.app.Events.Listen("test.password_changed", func(_ context.Context, ev *classes.AccessRevokedEvent) error {
|
||||||
|
if probe.fail.Load() {
|
||||||
|
return errors.New("listener refused")
|
||||||
|
}
|
||||||
|
probe.mu.Lock()
|
||||||
|
probe.seen = append(probe.seen, *ev)
|
||||||
|
probe.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
admin := env.superAdmin(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
session := func(user *models.User) string {
|
||||||
|
return craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "pc-"+strconv.FormatInt(time.Now().UnixNano(), 10), time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("reset", func(t *testing.T) {
|
||||||
|
user := env.seedUser(t, "pc-reset", nil)
|
||||||
|
code, err := classes.IssueResetCode(ctx, env.gdb, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
probe.take()
|
||||||
|
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{"code": packed, "password": "pc-new-password", "password_confirmation": "pc-new-password"})
|
||||||
|
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordReset)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("change", func(t *testing.T) {
|
||||||
|
user := env.seedUser(t, "pc-change", func(u *models.User) { u.HasSelfSetPassword = true })
|
||||||
|
probe.take()
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{"current_password": "correct-horse", "password": "pc-changed-password", "password_confirmation": "pc-changed-password"})
|
||||||
|
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordChanged)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("admin_set", func(t *testing.T) {
|
||||||
|
user := env.seedUser(t, "pc-admin", nil)
|
||||||
|
probe.take()
|
||||||
|
const set = "pc-admin-password"
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{"name": user.Name, "email": user.Email, "password": set, "password_confirmation": set})
|
||||||
|
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordSetByAdmin)
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{"name": user.Name, "email": user.Email})
|
||||||
|
if seen := probe.take(); len(seen) != 0 {
|
||||||
|
t.Fatalf("an update without a password fired %+v", seen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rehash_fires_none", func(t *testing.T) {
|
||||||
|
user := env.seedUser(t, "pc-rehash", nil)
|
||||||
|
if err := env.app.Config.Set("golem15.user.password.bcrypt_cost", 11); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = env.app.Config.Set("golem15.user.password.bcrypt_cost", 10) })
|
||||||
|
probe.take()
|
||||||
|
if status, _ := env.userLogin(t, user.Email, "correct-horse"); status != http.StatusOK {
|
||||||
|
t.Fatalf("login = %d", status)
|
||||||
|
}
|
||||||
|
stored := env.reload(t, user.ID).Password
|
||||||
|
if stored == user.Password || bouncer.NeedsRehash(stored, 11) {
|
||||||
|
t.Fatal("the login did not rehash the password at the higher cost")
|
||||||
|
}
|
||||||
|
if seen := probe.take(); len(seen) != 0 {
|
||||||
|
t.Fatalf("the rehash fired %+v", seen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("listener_error_rolls_back", func(t *testing.T) {
|
||||||
|
user := env.seedUser(t, "pc-rollback", func(u *models.User) { u.HasSelfSetPassword = true })
|
||||||
|
probe.fail.Store(true)
|
||||||
|
rec := env.call(t, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{"current_password": "correct-horse", "password": "pc-never-set", "password_confirmation": "pc-never-set"})
|
||||||
|
probe.fail.Store(false)
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("change with a failing listener = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if stored := env.reload(t, user.ID).Password; stored != user.Password {
|
||||||
|
t.Fatal("the stored hash changed although the listener failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
224
password_changed_test.go
Normal file
224
password_changed_test.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// passwordSource is what a D-07 listener observed for one event: the event
|
||||||
|
// itself, whether the stored hash it read through ev.Tx already checks
|
||||||
|
// against the new password, and the marker it wrote through ev.Tx.
|
||||||
|
type passwordSource struct {
|
||||||
|
ev classes.AccessRevokedEvent
|
||||||
|
newHash bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordListener is a D-07 listener: for every password-change event it
|
||||||
|
// reads the stored hash through ev.Tx, writes a marker row through ev.Tx
|
||||||
|
// (jwt_blacklist, jti = marker) and then fails when fail is set.
|
||||||
|
type passwordListener struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seen []passwordSource
|
||||||
|
fail bool
|
||||||
|
plain string
|
||||||
|
marker string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *passwordListener) arm(plain, marker string, fail bool) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.seen, l.plain, l.marker, l.fail = nil, plain, marker, fail
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *passwordListener) take() []passwordSource {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
out := l.seen
|
||||||
|
l.seen = nil
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *passwordListener) handle(ctx context.Context, ev *classes.AccessRevokedEvent) error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if !ev.PasswordChanged() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
got := passwordSource{ev: *ev}
|
||||||
|
if ev.Tx != nil && len(ev.UserIDs) == 1 {
|
||||||
|
var hash string
|
||||||
|
if err := ev.Tx.WithContext(ctx).Raw(`SELECT password FROM users WHERE id = ?`, ev.UserIDs[0]).Scan(&hash).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
got.newHash = l.plain != "" && bouncer.CheckPassword(hash, l.plain)
|
||||||
|
if l.marker != "" {
|
||||||
|
if err := ev.Tx.WithContext(ctx).Exec(`INSERT INTO jwt_blacklist (jti, expires_at, valid_until) VALUES (?, NOW(), NOW())`, l.marker).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.seen = append(l.seen, got)
|
||||||
|
if l.fail {
|
||||||
|
return errors.New("listener refused")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func markerExists(t *testing.T, gdb *gorm.DB, marker string) bool {
|
||||||
|
t.Helper()
|
||||||
|
var n int64
|
||||||
|
if err := gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, marker).Scan(&n).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return n == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasswordChangedSources pins the D-07 hook from every source. A
|
||||||
|
// password reset, the user's own change and an administrator setting a
|
||||||
|
// password each fire exactly one AccessRevokedEvent with its own reason and
|
||||||
|
// PasswordChanged() true, inside the write's transaction: a marker the
|
||||||
|
// listener writes through ev.Tx commits with the write, and a listener error
|
||||||
|
// rolls back the write and the marker. Reset and change write the hash
|
||||||
|
// before they fire, so the listener reads the new hash through ev.Tx; the
|
||||||
|
// administrator's save fires from FormBeforeUpdate, before cabana writes
|
||||||
|
// the record in the same transaction. The bcrypt rehash on login and a new
|
||||||
|
// registration fire no password-change event.
|
||||||
|
func TestPasswordChangedSources(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Postgres")
|
||||||
|
}
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
listener := &passwordListener{}
|
||||||
|
env.app.Events.Listen("test.password_changed_sources", listener.handle)
|
||||||
|
admin := env.superAdmin(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
session := func(user *models.User) string {
|
||||||
|
return craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "pcs-"+adminStamp(), time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
|
||||||
|
}
|
||||||
|
reset := func(t *testing.T, user *models.User, password string) int {
|
||||||
|
t.Helper()
|
||||||
|
code, err := classes.IssueResetCode(ctx, env.gdb, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||||
|
return env.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{
|
||||||
|
"code": packed, "password": password, "password_confirmation": password,
|
||||||
|
}).Code
|
||||||
|
}
|
||||||
|
change := func(t *testing.T, user *models.User, password string) int {
|
||||||
|
t.Helper()
|
||||||
|
return env.call(t, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{
|
||||||
|
"current_password": "correct-horse", "password": password, "password_confirmation": password,
|
||||||
|
}).Code
|
||||||
|
}
|
||||||
|
adminSet := func(t *testing.T, user *models.User, password string) int {
|
||||||
|
t.Helper()
|
||||||
|
return env.call(t, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{
|
||||||
|
"name": user.Name, "email": user.Email, "password": password, "password_confirmation": password,
|
||||||
|
}).Code
|
||||||
|
}
|
||||||
|
sources := []struct {
|
||||||
|
name string
|
||||||
|
reason string
|
||||||
|
write func(*testing.T, *models.User, string) int
|
||||||
|
mutate func(*models.User)
|
||||||
|
newHash bool
|
||||||
|
}{
|
||||||
|
{"reset", classes.AccessRevokedPasswordReset, reset, nil, true},
|
||||||
|
{"change", classes.AccessRevokedPasswordChanged, change, func(u *models.User) { u.HasSelfSetPassword = true }, true},
|
||||||
|
{"admin_set", classes.AccessRevokedPasswordSetByAdmin, adminSet, nil, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, src := range sources {
|
||||||
|
t.Run(src.name+"_fires_once_in_the_transaction", func(t *testing.T) {
|
||||||
|
user := env.seedUser(t, "pcs-"+src.name, src.mutate)
|
||||||
|
plain := "pcs-new-" + src.name + "-password"
|
||||||
|
marker := "pcs-marker-" + src.name + "-" + adminStamp()
|
||||||
|
listener.arm(plain, marker, false)
|
||||||
|
if status := src.write(t, user, plain); status != http.StatusOK {
|
||||||
|
t.Fatalf("%s = %d", src.name, status)
|
||||||
|
}
|
||||||
|
seen := listener.take()
|
||||||
|
if len(seen) != 1 {
|
||||||
|
t.Fatalf("%s fired %d password-change events, want 1", src.name, len(seen))
|
||||||
|
}
|
||||||
|
ev := seen[0].ev
|
||||||
|
if ev.Reason != src.reason || !ev.PasswordChanged() || ev.Tx == nil || len(ev.UserIDs) != 1 || ev.UserIDs[0] != user.ID {
|
||||||
|
t.Fatalf("event = %+v", ev)
|
||||||
|
}
|
||||||
|
if ev.Before.IsZero() || time.Since(ev.Before) > time.Minute {
|
||||||
|
t.Fatalf("event cutoff = %s", ev.Before)
|
||||||
|
}
|
||||||
|
if seen[0].newHash != src.newHash {
|
||||||
|
t.Fatalf("listener read the new hash through ev.Tx = %v, want %v", seen[0].newHash, src.newHash)
|
||||||
|
}
|
||||||
|
if !markerExists(t, env.gdb, marker) {
|
||||||
|
t.Fatal("the listener's write through ev.Tx did not commit with the password write")
|
||||||
|
}
|
||||||
|
if !bouncer.CheckPassword(env.reload(t, user.ID).Password, plain) {
|
||||||
|
t.Fatal("the new password was not stored")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run(src.name+"_listener_error_rolls_back", func(t *testing.T) {
|
||||||
|
user := env.seedUser(t, "pcs-rb-"+src.name, src.mutate)
|
||||||
|
marker := "pcs-rb-marker-" + src.name + "-" + adminStamp()
|
||||||
|
listener.arm("pcs-never-set", marker, true)
|
||||||
|
if status := src.write(t, user, "pcs-never-set"); status == http.StatusOK {
|
||||||
|
t.Fatalf("%s with a failing listener answered 200", src.name)
|
||||||
|
}
|
||||||
|
if len(listener.take()) != 1 {
|
||||||
|
t.Fatal("the failing listener did not run exactly once")
|
||||||
|
}
|
||||||
|
if stored := env.reload(t, user.ID).Password; stored != user.Password {
|
||||||
|
t.Fatalf("%s: the stored hash changed although the listener failed", src.name)
|
||||||
|
}
|
||||||
|
if markerExists(t, env.gdb, marker) {
|
||||||
|
t.Fatalf("%s: the listener's write survived the rollback", src.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("rehash_fires_none", func(t *testing.T) {
|
||||||
|
user := env.seedUser(t, "pcs-rehash", nil)
|
||||||
|
if err := env.app.Config.Set("golem15.user.password.bcrypt_cost", 11); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = env.app.Config.Set("golem15.user.password.bcrypt_cost", 10) })
|
||||||
|
listener.arm("", "", false)
|
||||||
|
if status, _ := env.userLogin(t, user.Email, "correct-horse"); status != http.StatusOK {
|
||||||
|
t.Fatalf("login = %d", status)
|
||||||
|
}
|
||||||
|
if bouncer.NeedsRehash(env.reload(t, user.ID).Password, 11) {
|
||||||
|
t.Fatal("the login did not rehash")
|
||||||
|
}
|
||||||
|
if seen := listener.take(); len(seen) != 0 {
|
||||||
|
t.Fatalf("the rehash fired %d password-change events", len(seen))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("register_fires_none", func(t *testing.T) {
|
||||||
|
if err := env.app.Config.Set("golem15.user.registration.use_register_throttle", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
listener.arm("", "", false)
|
||||||
|
email := "pcs-register-" + adminStamp() + "@example.test"
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/register", "", map[string]string{
|
||||||
|
"email": email, "password": "correct-horse", "name": "Registrant",
|
||||||
|
})
|
||||||
|
if seen := listener.take(); len(seen) != 0 {
|
||||||
|
t.Fatalf("a registration fired %d password-change events", len(seen))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -97,9 +97,11 @@ func TestPasswordPublicRoutes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// 18 routes up to v0.1.9, plus GET /backend-link/{code}, POST
|
// 18 routes up to v0.1.9, plus GET /backend-link/{code}, POST
|
||||||
// /backend-link/show and POST /backend-link/confirm (the account link),
|
// /backend-link/show and POST /backend-link/confirm (the account link),
|
||||||
// GET /token/me and GET /tokens/scopes.
|
// GET /token/me and GET /tokens/scopes, plus POST /devices/initiate,
|
||||||
if len(base) != 23 {
|
// GET /devices/status/{token}, POST /devices/authorize, GET /devices and
|
||||||
t.Fatalf("unset: %d routes, want 23", len(base))
|
// DELETE /devices/{id} (device authorization).
|
||||||
|
if len(base) != 28 {
|
||||||
|
t.Fatalf("unset: %d routes, want 28", len(base))
|
||||||
}
|
}
|
||||||
seen := 0
|
seen := 0
|
||||||
for _, route := range base {
|
for _, route := range base {
|
||||||
@@ -111,8 +113,8 @@ func TestPasswordPublicRoutes(t *testing.T) {
|
|||||||
t.Fatalf("unset: found %d of the password routes, want %d", seen, len(passwordRouteKeys))
|
t.Fatalf("unset: found %d of the password routes, want %d", seen, len(passwordRouteKeys))
|
||||||
}
|
}
|
||||||
off := withoutPasswordRoutes(base)
|
off := withoutPasswordRoutes(base)
|
||||||
if len(off) != 20 {
|
if len(off) != 25 {
|
||||||
t.Fatalf("base without the password routes has %d routes, want 20", len(off))
|
t.Fatalf("base without the password routes has %d routes, want 25", len(off))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range []struct {
|
for _, c := range []struct {
|
||||||
|
|||||||
33
plugin.go
33
plugin.go
@@ -60,9 +60,14 @@ type Plugin struct {
|
|||||||
func (p *Plugin) ID() string { return "golem15.user" }
|
func (p *Plugin) ID() string { return "golem15.user" }
|
||||||
func (p *Plugin) Requires() []string { return nil }
|
func (p *Plugin) Requires() []string { return nil }
|
||||||
|
|
||||||
|
// Register publishes the login-gate registry (*classes.LoginGates), so a
|
||||||
|
// plugin that requires this one can add its gate in its Boot.
|
||||||
func (p *Plugin) Register(app *backpack.App) error {
|
func (p *Plugin) Register(app *backpack.App) error {
|
||||||
p.app = app
|
p.app = app
|
||||||
return nil
|
if gates, ok := app.Lookup[*classes.LoginGates](); ok && gates != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return app.Publish(classes.NewLoginGates())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Boot(app *backpack.App) error {
|
func (p *Plugin) Boot(app *backpack.App) error {
|
||||||
@@ -111,6 +116,7 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
|||||||
if err := p.bootBackendLinks(app); err != nil {
|
if err := p.bootBackendLinks(app); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
p.bootDeviceRevocation(app)
|
||||||
if err := p.bootAPITokenScopes(app); err != nil {
|
if err := p.bootAPITokenScopes(app); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -120,6 +126,31 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
|||||||
return classes.RegisterUserAuthorizer(app)
|
return classes.RegisterUserAuthorizer(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bootDeviceRevocation revokes the pending and confirmed device sessions of
|
||||||
|
// users who lose access (a ban or a password change, AccessRevokedEvent)
|
||||||
|
// inside the write's transaction, so a device authorized before the change
|
||||||
|
// is never handed a session token after it; a failed write rolls the change
|
||||||
|
// back.
|
||||||
|
func (p *Plugin) bootDeviceRevocation(app *backpack.App) {
|
||||||
|
if app == nil || app.Events == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.Events.Listen(p.ID(), func(ctx context.Context, ev *classes.AccessRevokedEvent) error {
|
||||||
|
if ev == nil || len(ev.UserIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db := ev.Tx
|
||||||
|
if db == nil {
|
||||||
|
gdb, ok := app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || gdb == nil {
|
||||||
|
return fmt.Errorf("golem15.user: revoke device sessions: database is not configured")
|
||||||
|
}
|
||||||
|
db = gdb
|
||||||
|
}
|
||||||
|
return classes.RevokeDeviceSessions(ctx, db, ev.UserIDs...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// bootAPITokenScopes compiles the member token scope catalog from the
|
// bootAPITokenScopes compiles the member token scope catalog from the
|
||||||
// activated plugins (pact.HasFrontendTokenScopes), checks that every phrase
|
// activated plugins (pact.HasFrontendTokenScopes), checks that every phrase
|
||||||
// key it names exists, and publishes it. A bad declaration fails boot.
|
// key it names exists, and publishes it. A bad declaration fails boot.
|
||||||
|
|||||||
117
reset_two_factor_smoke_test.go
Normal file
117
reset_two_factor_smoke_test.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const permResetTwoFactor = "golem15.users.reset_two_factor"
|
||||||
|
|
||||||
|
// fakeTwoFactorAdmin stands in for a two-factor plugin's service.
|
||||||
|
type fakeTwoFactorAdmin struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
has bool
|
||||||
|
resets []uint
|
||||||
|
txs []*gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTwoFactorAdmin) HasTwoFactor(_ context.Context, tx *gorm.DB, _ uint) (bool, error) {
|
||||||
|
if tx == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.has, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTwoFactorAdmin) Reset(_ context.Context, tx *gorm.DB, user *models.User) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.resets = append(f.resets, user.ID)
|
||||||
|
f.txs = append(f.txs, tx)
|
||||||
|
f.has = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAction returns the offered record action named name from the show
|
||||||
|
// response of user id, or nil.
|
||||||
|
func (e *adminEnv) recordAction(t *testing.T, token string, id uint, name string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
rec := e.expect(t, http.StatusOK, http.MethodGet, userPath(id, ""), token, nil)
|
||||||
|
var meta struct {
|
||||||
|
Actions []map[string]any `json:"actions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(adminDecode(t, rec).Meta, &meta); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, action := range meta.Actions {
|
||||||
|
if action["name"] == name {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResetTwoFactorSlotSmoke pins the D-15 slot: reset_two_factor is
|
||||||
|
// offered only when a classes.TwoFactorAdmin is published and the user has
|
||||||
|
// a second factor, only to an administrator holding
|
||||||
|
// golem15.users.reset_two_factor, with the danger confirmation, and running
|
||||||
|
// it calls Reset once inside the action's transaction.
|
||||||
|
func TestResetTwoFactorSlotSmoke(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Postgres")
|
||||||
|
}
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
token := env.admin(t, permAccessUsers, permResetTwoFactor)
|
||||||
|
plain := env.admin(t, permAccessUsers)
|
||||||
|
user := env.seedUser(t, "r2fa", nil)
|
||||||
|
actionPath := userPath(user.ID, "/actions/reset_two_factor")
|
||||||
|
|
||||||
|
// No two-factor plugin: never offered, never runs.
|
||||||
|
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
|
||||||
|
t.Fatal("reset_two_factor offered without a published TwoFactorAdmin")
|
||||||
|
}
|
||||||
|
if rec := env.call(t, http.MethodPost, actionPath, token, map[string]any{}); rec.Code == http.StatusOK {
|
||||||
|
t.Fatalf("reset_two_factor ran without a TwoFactorAdmin: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &fakeTwoFactorAdmin{has: true}
|
||||||
|
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
action := env.recordAction(t, token, user.ID, "reset_two_factor")
|
||||||
|
if action == nil {
|
||||||
|
t.Fatalf("reset_two_factor not offered: %v", env.offered(t, token, user.ID))
|
||||||
|
}
|
||||||
|
if action["danger"] != true || action["label"] != "Reset 2FA" {
|
||||||
|
t.Fatalf("offered action = %v", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An administrator with only access_users neither sees nor runs it.
|
||||||
|
if slices.Contains(env.offered(t, plain, user.ID), "reset_two_factor") {
|
||||||
|
t.Fatal("reset_two_factor offered without its permission")
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodPost, actionPath, plain, map[string]any{})
|
||||||
|
if len(svc.resets) != 0 {
|
||||||
|
t.Fatal("a refused administrator reached Reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, actionPath, token, map[string]any{})
|
||||||
|
if len(svc.resets) != 1 || svc.resets[0] != user.ID || svc.txs[0] == nil {
|
||||||
|
t.Fatalf("Reset calls = %v (tx %v)", svc.resets, svc.txs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing left to reset: no longer offered.
|
||||||
|
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
|
||||||
|
t.Fatal("reset_two_factor offered for a user without a second factor")
|
||||||
|
}
|
||||||
|
}
|
||||||
242
reset_two_factor_test.go
Normal file
242
reset_two_factor_test.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scriptedTwoFactorAdmin is a classes.TwoFactorAdmin whose answers a test
|
||||||
|
// sets: has is what HasTwoFactor reports; Reset renames the user through
|
||||||
|
// the transaction it is given (to prove which transaction that is) and then
|
||||||
|
// returns resetErr.
|
||||||
|
type scriptedTwoFactorAdmin struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
has bool
|
||||||
|
resetErr error
|
||||||
|
resets []uint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scriptedTwoFactorAdmin) HasTwoFactor(_ context.Context, tx *gorm.DB, _ uint) (bool, error) {
|
||||||
|
if tx == nil {
|
||||||
|
return false, errors.New("no handle")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.has, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scriptedTwoFactorAdmin) Reset(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.resets = append(s.resets, user.ID)
|
||||||
|
if err := tx.WithContext(ctx).Exec(`UPDATE users SET name = 'reset-marker' WHERE id = ?`, user.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.resetErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scriptedTwoFactorAdmin) calls() []uint {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return slices.Clone(s.resets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetAction returns the Users controller's reset_two_factor record action
|
||||||
|
// on app.
|
||||||
|
func resetAction(t *testing.T, app *backpack.App) pact.AdminRecordAction {
|
||||||
|
t.Helper()
|
||||||
|
ctl := adminControllersOn(func() *backpack.App { return app })["golem15.user.users"]
|
||||||
|
for _, action := range ctl.(pact.HasAdminRecordActions).AdminRecordActions() {
|
||||||
|
if action.Name == "reset_two_factor" {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("the Users controller has no reset_two_factor record action")
|
||||||
|
return pact.AdminRecordAction{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResetTwoFactorAction pins the D-15 record action on the Users
|
||||||
|
// screen: never offered without a published classes.TwoFactorAdmin;
|
||||||
|
// offered with the danger confirmation when one is published and reports a
|
||||||
|
// second factor; hidden when it reports none; refused without
|
||||||
|
// golem15.users.reset_two_factor; Run needs the action's transaction, runs
|
||||||
|
// Reset in it, and Reset's error rolls the transaction back. A member of a
|
||||||
|
// privileged group keeps the takeover guard: without
|
||||||
|
// golem15.users.manage_privileged_groups Run answers 403 with the guard's
|
||||||
|
// message before Reset.
|
||||||
|
func TestResetTwoFactorAction(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Postgres")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("not_offered_without_a_service", func(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
token := env.admin(t, permAccessUsers, permResetTwoFactor)
|
||||||
|
user := env.seedUser(t, "rta-none", nil)
|
||||||
|
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
|
||||||
|
t.Fatal("offered without a published TwoFactorAdmin")
|
||||||
|
}
|
||||||
|
if applies, err := resetAction(t, env.app).Applies(context.Background(), user); applies || err != nil {
|
||||||
|
t.Fatalf("Applies without a service = %v, %v", applies, err)
|
||||||
|
}
|
||||||
|
if rec := env.call(t, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), token, map[string]any{}); rec.Code == http.StatusOK {
|
||||||
|
t.Fatalf("ran without a service: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if env.reload(t, user.ID).Name == nil || *env.reload(t, user.ID).Name != "rta-none" {
|
||||||
|
t.Fatal("the user changed although nothing ran")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("offered_with_danger_and_hidden_without_a_factor", func(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
svc := &scriptedTwoFactorAdmin{has: true}
|
||||||
|
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := env.admin(t, permAccessUsers, permResetTwoFactor)
|
||||||
|
user := env.seedUser(t, "rta-offer", nil)
|
||||||
|
action := env.recordAction(t, token, user.ID, "reset_two_factor")
|
||||||
|
if action == nil {
|
||||||
|
t.Fatalf("not offered: %v", env.offered(t, token, user.ID))
|
||||||
|
}
|
||||||
|
if action["danger"] != true || action["label"] != "Reset 2FA" || action["confirm"] == nil || action["confirm"] == "" {
|
||||||
|
t.Fatalf("offered action = %v", action)
|
||||||
|
}
|
||||||
|
svc.mu.Lock()
|
||||||
|
svc.has = false
|
||||||
|
svc.mu.Unlock()
|
||||||
|
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
|
||||||
|
t.Fatal("offered for a user without a second factor")
|
||||||
|
}
|
||||||
|
// A superuser holds every permission and sees it too.
|
||||||
|
svc.mu.Lock()
|
||||||
|
svc.has = true
|
||||||
|
svc.mu.Unlock()
|
||||||
|
if !slices.Contains(env.offered(t, env.superAdmin(t), user.ID), "reset_two_factor") {
|
||||||
|
t.Fatal("not offered to a superuser")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("permission_required", func(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
svc := &scriptedTwoFactorAdmin{has: true}
|
||||||
|
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plain := env.admin(t, permAccessUsers)
|
||||||
|
user := env.seedUser(t, "rta-perm", nil)
|
||||||
|
if slices.Contains(env.offered(t, plain, user.ID), "reset_two_factor") {
|
||||||
|
t.Fatal("offered without golem15.users.reset_two_factor")
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), plain, map[string]any{})
|
||||||
|
if len(svc.calls()) != 0 {
|
||||||
|
t.Fatal("a refused administrator reached Reset")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("run_needs_the_transaction", func(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
svc := &scriptedTwoFactorAdmin{has: true}
|
||||||
|
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user := env.seedUser(t, "rta-notx", nil)
|
||||||
|
_, err := resetAction(t, env.app).Run(context.Background(), pact.AdminRecordActionInput{Record: user})
|
||||||
|
wantPlainError(t, "reset_two_factor without a transaction", err)
|
||||||
|
if len(svc.calls()) != 0 {
|
||||||
|
t.Fatal("Reset ran without the action's transaction")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("run_commits_with_the_action", func(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
svc := &scriptedTwoFactorAdmin{has: true}
|
||||||
|
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := env.admin(t, permAccessUsers, permResetTwoFactor)
|
||||||
|
user := env.seedUser(t, "rta-ok", nil)
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), token, map[string]any{})
|
||||||
|
if calls := svc.calls(); len(calls) != 1 || calls[0] != user.ID {
|
||||||
|
t.Fatalf("Reset calls = %v", calls)
|
||||||
|
}
|
||||||
|
if name := env.reload(t, user.ID).Name; name == nil || *name != "reset-marker" {
|
||||||
|
t.Fatalf("Reset's write did not commit: name = %v", name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("privileged_member_needs_the_privileged_permission", func(t *testing.T) {
|
||||||
|
code := "rta-priv-" + adminStamp()
|
||||||
|
env := newAdminEnv(t, func(cfg *compass.Config) {
|
||||||
|
if err := cfg.Set("golem15.user.privileged_groups", []string{"admin", code}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
svc := &scriptedTwoFactorAdmin{has: true}
|
||||||
|
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
limited := env.admin(t, permAccessUsers, permResetTwoFactor)
|
||||||
|
manager := env.admin(t, permAccessUsers, permResetTwoFactor, permManagePrivileged)
|
||||||
|
member := env.seedUser(t, "rta-privileged", nil)
|
||||||
|
env.join(t, member, env.seedGroup(t, "Privileged "+code, code))
|
||||||
|
ordinary := env.seedUser(t, "rta-ordinary", nil)
|
||||||
|
|
||||||
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, userPath(member.ID, "/actions/reset_two_factor"), limited, map[string]any{})
|
||||||
|
answer := adminDecode(t, rec)
|
||||||
|
const want = "This user belongs to a privileged group. Resetting their two-factor authentication needs the permission to manage privileged groups. Nothing was changed."
|
||||||
|
if answer.Error.Code != "forbidden" || answer.Error.Message != want {
|
||||||
|
t.Fatalf("refusal = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if len(svc.calls()) != 0 {
|
||||||
|
t.Fatalf("a refused reset reached Reset: %v", svc.calls())
|
||||||
|
}
|
||||||
|
if name := env.reload(t, member.ID).Name; name == nil || *name != "rta-privileged" {
|
||||||
|
t.Fatalf("the refused reset changed the member: name = %v", name)
|
||||||
|
}
|
||||||
|
// An ordinary user stays resettable with the reset permission alone.
|
||||||
|
if !slices.Contains(env.offered(t, limited, ordinary.ID), "reset_two_factor") {
|
||||||
|
t.Fatal("not offered for an ordinary user")
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, userPath(ordinary.ID, "/actions/reset_two_factor"), limited, map[string]any{})
|
||||||
|
// With the privileged permission the member is offered and reset.
|
||||||
|
if !slices.Contains(env.offered(t, manager, member.ID), "reset_two_factor") {
|
||||||
|
t.Fatal("not offered to an administrator who manages privileged groups")
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, userPath(member.ID, "/actions/reset_two_factor"), manager, map[string]any{})
|
||||||
|
if calls := svc.calls(); len(calls) != 2 || calls[0] != ordinary.ID || calls[1] != member.ID {
|
||||||
|
t.Fatalf("Reset calls = %v", calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("run_error_rolls_back", func(t *testing.T) {
|
||||||
|
env := newAdminEnv(t)
|
||||||
|
svc := &scriptedTwoFactorAdmin{has: true, resetErr: errors.New("credential delete failed")}
|
||||||
|
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := env.admin(t, permAccessUsers, permResetTwoFactor)
|
||||||
|
user := env.seedUser(t, "rta-rollback", nil)
|
||||||
|
rec := env.call(t, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), token, map[string]any{})
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("failing Reset = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if len(svc.calls()) != 1 {
|
||||||
|
t.Fatalf("Reset calls = %v", svc.calls())
|
||||||
|
}
|
||||||
|
if name := env.reload(t, user.ID).Name; name == nil || *name != "rta-rollback" {
|
||||||
|
t.Fatalf("Reset's write survived its error: name = %v", name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -48,6 +48,14 @@ func (p *Plugin) Routes(r pact.Router) error {
|
|||||||
g.Post("/backend-link/show", controllers.BackendLinkShowPost(p.app))
|
g.Post("/backend-link/show", controllers.BackendLinkShowPost(p.app))
|
||||||
g.Post("/backend-link/confirm", controllers.BackendLinkConfirm(p.app), "jwt.auth")
|
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")
|
g.Get("/token/me", controllers.TokenMe(p.app), "user.api_token", "user.scope:read")
|
||||||
|
// Device authorization (QR and short code). The routes authenticate
|
||||||
|
// inside the handlers, as PHP's controller does.
|
||||||
|
g.Post("/devices/initiate", controllers.DeviceInitiate(p.app))
|
||||||
|
g.Get("/devices/status/{token}", controllers.DeviceStatus(p.app))
|
||||||
|
g.Post("/devices/authorize", controllers.DeviceAuthorize(p.app))
|
||||||
|
g.Get("/devices", controllers.DeviceIndex(p.app))
|
||||||
|
g.Delete("/devices/{id}", controllers.DeviceDestroy(p.app))
|
||||||
|
g.Where("id", "[0-9]+")
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
50
updates/202610260001_create_device_auth_sessions.go
Normal file
50
updates/202610260001_create_device_auth_sessions.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deviceAuthSessionMigrations create golem15_user_device_auth_sessions, the
|
||||||
|
// QR and short-code device authorization sessions, with the PHP plugin's
|
||||||
|
// table and column names (the v2.1.0 to v2.7.1 migrations: short_code, a
|
||||||
|
// nullable user_id and expires_at). The token column holds the SHA-256 hex
|
||||||
|
// of the 64-character device token, never the token itself; jwt_jti is
|
||||||
|
// added: the jti of the session token the device received, so a revoke can
|
||||||
|
// blacklist it. session_id is kept for a Winter import and stays NULL.
|
||||||
|
var deviceAuthSessionMigrations = []*gormigrate.Migration{
|
||||||
|
{
|
||||||
|
ID: "202610260001_create_device_auth_sessions",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec(`CREATE TABLE golem15_user_device_auth_sessions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
short_code VARCHAR(9) NULL UNIQUE,
|
||||||
|
user_id INTEGER NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'confirmed', 'used', 'expired', 'revoked')),
|
||||||
|
expires_at TIMESTAMPTZ NULL,
|
||||||
|
device_ip VARCHAR(255) NULL,
|
||||||
|
device_user_agent TEXT NULL,
|
||||||
|
device_name VARCHAR(255) NULL,
|
||||||
|
auth_ip VARCHAR(255) NULL,
|
||||||
|
confirmed_at TIMESTAMPTZ NULL,
|
||||||
|
used_at TIMESTAMPTZ NULL,
|
||||||
|
session_id VARCHAR(255) NULL,
|
||||||
|
last_activity_at TIMESTAMPTZ NULL,
|
||||||
|
jwt_jti VARCHAR(64) NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX golem15_user_device_auth_sessions_user_id_index ON golem15_user_device_auth_sessions (user_id);
|
||||||
|
CREATE INDEX golem15_user_device_auth_sessions_status_index ON golem15_user_device_auth_sessions (status);
|
||||||
|
CREATE INDEX golem15_user_device_auth_sessions_expires_at_index ON golem15_user_device_auth_sessions (expires_at);
|
||||||
|
CREATE INDEX golem15_user_device_auth_sessions_jwt_jti_index ON golem15_user_device_auth_sessions (jwt_jti)`).Error
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec("DROP TABLE IF EXISTS golem15_user_device_auth_sessions").Error
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { Register(deviceAuthSessionMigrations...) }
|
||||||
192
updates/device_auth_sessions_test.go
Normal file
192
updates/device_auth_sessions_test.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeviceAuthSessionsMigration pins 202610260001_create_device_auth_sessions
|
||||||
|
// (D-16, D-06, D-12b) on a database of its own: the PHP table and column
|
||||||
|
// names plus jwt_jti, the column types and nullability of PHP's v2.1.0 to
|
||||||
|
// v2.7.1 migrations, the unique token and short_code, the CHECK on status,
|
||||||
|
// the indexes, the cascade on user delete, the rollback and a second up.
|
||||||
|
func TestDeviceAuthSessionsMigration(t *testing.T) {
|
||||||
|
sqlDB := dedicatedDB(t, "user_device_auth_sessions")
|
||||||
|
db, err := lagoon.Use(t.Context(), sqlDB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := gormigrate.New(db, &gormigrate.Options{TableName: "summer_migrations_golem15_user", UseTransaction: true}, All())
|
||||||
|
if err := m.Migrate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const table = "golem15_user_device_auth_sessions"
|
||||||
|
if len(deviceAuthSessionMigrations) != 1 || deviceAuthSessionMigrations[0].ID != "202610260001_create_device_auth_sessions" {
|
||||||
|
t.Fatalf("device migrations = %v", deviceAuthSessionMigrations)
|
||||||
|
}
|
||||||
|
|
||||||
|
type column struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Length *int
|
||||||
|
Nullable string
|
||||||
|
}
|
||||||
|
var columns []column
|
||||||
|
if err := db.Raw(`SELECT column_name AS name, data_type AS type, character_maximum_length AS length, is_nullable AS nullable
|
||||||
|
FROM information_schema.columns WHERE table_name = ? ORDER BY ordinal_position`, table).Scan(&columns).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]column{}
|
||||||
|
var names []string
|
||||||
|
for _, c := range columns {
|
||||||
|
got[c.Name] = c
|
||||||
|
names = append(names, c.Name)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
// PHP's columns (v2.1.0, v2.3.1 short_code).
|
||||||
|
"id", "token", "short_code", "user_id", "status", "expires_at", "device_ip", "device_user_agent", "device_name",
|
||||||
|
"auth_ip", "confirmed_at", "used_at", "session_id", "last_activity_at",
|
||||||
|
// D-12b.
|
||||||
|
"jwt_jti",
|
||||||
|
"created_at", "updated_at",
|
||||||
|
}
|
||||||
|
if !slices.Equal(names, want) {
|
||||||
|
t.Fatalf("columns = %v, want %v", names, want)
|
||||||
|
}
|
||||||
|
for name, spec := range map[string]struct {
|
||||||
|
typ string
|
||||||
|
length int
|
||||||
|
nullable string
|
||||||
|
}{
|
||||||
|
"token": {"character varying", 64, "NO"},
|
||||||
|
"short_code": {"character varying", 9, "YES"},
|
||||||
|
"user_id": {"integer", 0, "YES"},
|
||||||
|
"status": {"character varying", 16, "NO"},
|
||||||
|
"expires_at": {"timestamp with time zone", 0, "YES"},
|
||||||
|
"device_name": {"character varying", 255, "YES"},
|
||||||
|
"device_user_agent": {"text", 0, "YES"},
|
||||||
|
"jwt_jti": {"character varying", 64, "YES"},
|
||||||
|
"session_id": {"character varying", 255, "YES"},
|
||||||
|
} {
|
||||||
|
c := got[name]
|
||||||
|
length := 0
|
||||||
|
if c.Length != nil {
|
||||||
|
length = *c.Length
|
||||||
|
}
|
||||||
|
if c.Type != spec.typ || length != spec.length || c.Nullable != spec.nullable {
|
||||||
|
t.Fatalf("%s = %s(%d) nullable %s, want %s(%d) nullable %s", name, c.Type, length, c.Nullable, spec.typ, spec.length, spec.nullable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexes []string
|
||||||
|
if err := db.Raw(`SELECT indexdef FROM pg_indexes WHERE tablename = ?`, table).Scan(&indexes).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
joined := strings.Join(indexes, "\n")
|
||||||
|
for _, def := range []string{
|
||||||
|
"UNIQUE INDEX " + table + "_token_key ON public." + table + " USING btree (token)",
|
||||||
|
"UNIQUE INDEX " + table + "_short_code_key ON public." + table + " USING btree (short_code)",
|
||||||
|
"INDEX " + table + "_user_id_index ON public." + table + " USING btree (user_id)",
|
||||||
|
"INDEX " + table + "_status_index ON public." + table + " USING btree (status)",
|
||||||
|
"INDEX " + table + "_expires_at_index ON public." + table + " USING btree (expires_at)",
|
||||||
|
"INDEX " + table + "_jwt_jti_index ON public." + table + " USING btree (jwt_jti)",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(joined, def) {
|
||||||
|
t.Fatalf("index %q missing from:\n%s", def, joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pgCode := func(err error) string {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
return pgErr.Code
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
insert := func(token string, shortCode any, status string, userID any) error {
|
||||||
|
return db.Exec(`INSERT INTO `+table+` (token, short_code, status, user_id, expires_at) VALUES (?, ?, ?, ?, NOW())`,
|
||||||
|
token, shortCode, status, userID).Error
|
||||||
|
}
|
||||||
|
if err := insert("t1", "AAAA-AAAA", "pending", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if code := pgCode(insert("t1", "BBBB-BBBB", "pending", nil)); code != "23505" {
|
||||||
|
t.Fatalf("duplicate token: SQLSTATE %q, want 23505", code)
|
||||||
|
}
|
||||||
|
if code := pgCode(insert("t2", "AAAA-AAAA", "pending", nil)); code != "23505" {
|
||||||
|
t.Fatalf("duplicate short_code: SQLSTATE %q, want 23505", code)
|
||||||
|
}
|
||||||
|
if err := insert("t3", nil, "used", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := insert("t4", nil, "revoked", nil); err != nil {
|
||||||
|
t.Fatalf("a second NULL short_code: %v", err)
|
||||||
|
}
|
||||||
|
for _, status := range []string{"confirmed", "expired"} {
|
||||||
|
if err := insert("t-"+status, nil, status, nil); err != nil {
|
||||||
|
t.Fatalf("status %s refused: %v", status, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, status := range []string{"unknown", "PENDING", ""} {
|
||||||
|
if code := pgCode(insert("t-bad-"+status, nil, status, nil)); code != "23514" {
|
||||||
|
t.Fatalf("status %q: SQLSTATE %q, want 23514 (check)", status, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
if err := db.Raw(`INSERT INTO ` + table + ` (token) VALUES ('t-default') RETURNING status`).Scan(&status).Error; err != nil || status != "pending" {
|
||||||
|
t.Fatalf("default status = %q (%v)", status, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &models.User{Email: "device-migration@example.test", Password: "x"}
|
||||||
|
if err := db.Omit("Groups").Create(user).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if code := pgCode(insert("t-fk", nil, "pending", 987654321)); code != "23503" {
|
||||||
|
t.Fatalf("unknown user_id: SQLSTATE %q, want 23503", code)
|
||||||
|
}
|
||||||
|
if err := insert("t-owned", nil, "used", user.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(`DELETE FROM users WHERE id = ?`, user.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var owned int64
|
||||||
|
if err := db.Raw(`SELECT COUNT(*) FROM ` + table + ` WHERE token = 't-owned'`).Scan(&owned).Error; err != nil || owned != 0 {
|
||||||
|
t.Fatalf("rows of a deleted user = %d (%v), want 0 (ON DELETE CASCADE)", owned, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history := func() int64 {
|
||||||
|
var n int64
|
||||||
|
if err := db.Raw(`SELECT COUNT(*) FROM summer_migrations_golem15_user WHERE id = ?`, deviceAuthSessionMigrations[0].ID).Scan(&n).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
if history() != 1 {
|
||||||
|
t.Fatal("the migration is not recorded")
|
||||||
|
}
|
||||||
|
if err := m.RollbackMigration(deviceAuthSessionMigrations[0]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if db.Migrator().HasTable(table) || history() != 0 {
|
||||||
|
t.Fatal("rollback left the table or its history row")
|
||||||
|
}
|
||||||
|
if err := m.Migrate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasTable(table) || history() != 1 {
|
||||||
|
t.Fatal("a second up did not recreate the table")
|
||||||
|
}
|
||||||
|
var rows int64
|
||||||
|
if err := db.Session(&gorm.Session{}).Raw(`SELECT COUNT(*) FROM ` + table).Scan(&rows).Error; err != nil || rows != 0 {
|
||||||
|
t.Fatalf("the recreated table has %d rows (%v)", rows, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
|||||||
"202610160002_add_handle_to_users",
|
"202610160002_add_handle_to_users",
|
||||||
"202610210001_add_users_email_verified_at",
|
"202610210001_add_users_email_verified_at",
|
||||||
"202610220001_create_golem15_user_backend_links",
|
"202610220001_create_golem15_user_backend_links",
|
||||||
|
"202610260001_create_device_auth_sessions",
|
||||||
}
|
}
|
||||||
if len(ids) != len(want) {
|
if len(ids) != len(want) {
|
||||||
t.Fatalf("history = %v", ids)
|
t.Fatalf("history = %v", ids)
|
||||||
@@ -63,9 +64,10 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend links, email proof, handle, OAuth identities, the three admin migrations, API tokens, user groups,
|
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email proof, handle, OAuth
|
||||||
// jwt blacklist, throttle, extend-users, then organisations.
|
// identities, the three admin migrations, API tokens, user groups, jwt blacklist, throttle, extend-users,
|
||||||
for range 13 {
|
// then organisations.
|
||||||
|
for range 14 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package updates
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -22,9 +23,13 @@ var (
|
|||||||
userPGErr error
|
userPGErr error
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestMain starts the Postgres container only without -short. go test
|
||||||
|
// passes -short as -test.short=true, so the flag is read through
|
||||||
|
// flag.Parse and testing.Short, never by scanning os.Args.
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
|
flag.Parse()
|
||||||
code := 1
|
code := 1
|
||||||
if !testShort() {
|
if !testing.Short() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
userPGErr = startUserPostgres(ctx)
|
userPGErr = startUserPostgres(ctx)
|
||||||
cancel()
|
cancel()
|
||||||
@@ -39,15 +44,6 @@ func TestMain(m *testing.M) {
|
|||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testShort() bool {
|
|
||||||
for _, a := range os.Args {
|
|
||||||
if a == "-test.short" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func startUserPostgres(ctx context.Context) error {
|
func startUserPostgres(ctx context.Context) error {
|
||||||
ctr, err := postgres.Run(ctx,
|
ctr, err := postgres.Run(ctx,
|
||||||
"postgres:16-alpine",
|
"postgres:16-alpine",
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ func TestUserGroupsMigration(t *testing.T) {
|
|||||||
t.Fatalf("users_groups primary key = %q", pk)
|
t.Fatalf("users_groups primary key = %q", pk)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend links, email verified at, handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
|
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
|
||||||
for range 9 {
|
// identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
|
||||||
|
for range 10 {
|
||||||
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)
|
||||||
}
|
}
|
||||||
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups,
|
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
|
||||||
// jwt blacklist, throttle, then extend-users.
|
// identities, the three admin migrations, API tokens, user groups, jwt blacklist, throttle, then extend-users.
|
||||||
for range 12 {
|
for range 13 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -88,8 +88,9 @@ func TestCreateUserThrottleMigration(t *testing.T) {
|
|||||||
t.Fatalf("missing column %s", col)
|
t.Fatalf("missing column %s", col)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
|
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
|
||||||
for range 11 {
|
// identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
|
||||||
|
for range 12 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -120,8 +121,9 @@ func TestCreateJwtBlacklistMigration(t *testing.T) {
|
|||||||
t.Fatalf("missing column %s", col)
|
t.Fatalf("missing column %s", col)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
|
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
|
||||||
for range 10 {
|
// identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
|
||||||
|
for range 11 {
|
||||||
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