8 Commits

Author SHA1 Message Date
Jakub Zych
3828a65481 feat: golem15.user.password.public_routes false leaves POST /login, /forgot-password and /reset-password unregistered, so they 404 like any unknown path
The key defaults to true, so the route table is unchanged. False skips only
those three registrations: they get the router's own 404, with no custom
handler and no framework change. /register keeps allow_registration and the
turnstile guard. The key is read at boot, and a value other than true or
false fails boot like golem15.user.turnstile.
2026-10-23 00:09:38 +02:00
Jakub Zych
8bdf2c7d1a feat: opt-in golem15.user.jwt.token_cookie sets the HttpOnly session cookie on /login, /register and /activate-by-code instead of returning the token
Default off, so body-token clients are unchanged. When on, the cookie carries the auth.cookie attributes via bouncer.LoadCookieConfig and the body keeps user (and message); SameSite stays Lax, with no CSRF token; personal API tokens are untouched.
2026-10-22 23:44:44 +02:00
Jakub Zych
846087db45 fix: a /refresh carried by the auth.cookie session cookie rewrites that cookie, so an HttpOnly session survives the rotation
8e4b863 answered a cookie-carried refresh in the body only and blacklisted the old jti, so an HttpOnly cookie (golem15.sociallogin token_cookie) kept a dead token. Now the cookie named auth.cookie.name gets the rotated token and the body is {}; bearer and auth_token refreshes are unchanged, and refusals write no cookie.
2026-10-22 23:39:18 +02:00
Jakub Zych
a484d0e9a2 fix: reset-password and the admin password set stamp tokens_valid_after in whole seconds, so a token minted in the same second is accepted
JWT iat is whole seconds and every cutoff check is iat before tokens_valid_after, so a sub-second cutoff refused the login that followed a reset within the same second. POST /reset-password and the admin Users password set now truncate the cutoff to the second, like RevokeUserAccess; email_verified_at keeps full precision. Change-password and bouncer's comparison are unchanged.
2026-10-22 22:56:38 +02:00
Jakub Zych
b6f8055c56 fix: /login throttles failed logins per client address behind trusted proxies (surf.ClientIP), like /register
Behind a reverse proxy every visitor had the proxy's RemoteAddr, so one client's failed logins locked the account for all. The throttle key is now surf.ClientIP with http.trusted_proxies, the expression /register uses; X-Forwarded-For from an untrusted peer is still ignored. RemoteIP is unchanged.
2026-10-22 22:53:29 +02:00
Jakub Zych
4bf44fc6ea fix: logout revokes the session under serve: the jwt blacklist and GORM hooks are wired when the database is published
Boot runs before summer serve publishes the database, so the plugin's GORM
hooks, the Postgres jwt_blacklist store and its sweep now go through
lagoon.OnDatabase, and the jwt guard resolves the published store on every
request. A logged-out token is refused on jwt.auth, /fetch and /refresh.

The jwt guard reads the cookie names from classes.SessionCookieNames().
2026-10-22 22:29:59 +02:00
Jakub Zych
8e4b863a3f fix: /fetch, /refresh, /update and /change-password accept the session cookies the jwt guard reads
The four routes (and the other signed-in routes through authenticate) read the bearer header, then the token and auth_token cookies, through classes.SessionToken and one cookie-aware guard.
A bearer header still wins and no credentials stays 401 with the old bodies.
A cookie-carried /refresh answers the rotated token in the body and writes no cookie.
2026-10-22 21:45:52 +02:00
Jakub Zych
15ff9793ba fix: login refuses a not-activated account while require_activation is on
The refusal is the same 401 invalid-login answer as a wrong password and is counted by the login throttle.
The activation is checked after the password and before the deactivation restore, so a refused account stays deactivated.
require_activation false keeps the previous behaviour.
2026-10-22 21:25:53 +02:00
22 changed files with 2141 additions and 118 deletions

View File

@@ -16,14 +16,14 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
## 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.
- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. A background sweep prunes the blacklist. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh.
- **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password.
- **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.
- **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password. With `golem15.user.password.public_routes` false, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered, so they answer the router's own 404 like any unknown path (any method, so no 405 shows they exist), while the `controllers` handlers stay exported.
- **Must-change-password lock.** The `user:require-password-change` console command flags an account. The flag rides on the authenticated principal (`bouncer.Principal.MustChangePassword`) for guards and other plugins to act on, and a successful `POST /change-password` clears it.
- **Login throttling.** Failed logins are counted per user and IP in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time.
- **Login throttling.** Failed logins are counted per user and client address in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. The client address is `surf.ClientIP` with the framework's `http.trusted_proxies`, as for the `/register` IP limit and the `user-api` rate limit: behind a listed reverse proxy it is the `X-Forwarded-For` client, otherwise the connection address, and an `X-Forwarded-For` header from any other peer is ignored. A deployment behind a proxy lists the proxy's address in `http.trusted_proxies`; otherwise every visitor shares the proxy's counter.
- **Rate limiting.** Every route runs behind the `user-api` bucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls.
- **Profile.** `POST /update`, `POST /marketing-consent`, `POST /avatar` and `POST /avatar/remove` (the avatar is stored through the framework's attachment storage).
- **Deactivation and bans.** Deactivation is self-service and reversible: a password login or a `golem15.sociallogin` sign-in restores a deactivated account and sends the reactivate mail, and an administrator can restore it too. It ends every earlier session JWT and personal API token of the user (`RevokeUserAccess` stamps `tokens_valid_after` and revokes the `user_api_tokens` rows), so the user signs in again and gets a fresh token, while the old API tokens stay revoked. A ban is the lock: password login, social sign-in and `POST /refresh` refuse a banned user, and banning revokes the user's sessions and API tokens. Neither unban nor restore revives a token. 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. Neither unban nor restore revives a token. Activation gates login, deactivation does not: while `require_activation` is on, an account that is both deactivated and not activated is refused and stays deactivated, with no reactivate mail. Upgrade note: accounts deactivated or banned before this version keep their API tokens until they are deactivated or banned again (both actions are idempotent) or `RevokeUserAccess` runs for them.
- **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins. An organisation has a name, a slug (filled from the name by `Slugify` when it is left empty), a description, an avatar and its members: the users whose `organisation_id` points at it. `organisation_role` belongs to that membership: the admin clears it whenever a user's organisation changes (see the Organisations screen), and an application that sets `organisation_id` itself sets or clears the role with it.
- **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No user API route reads or writes groups, and the user payload still answers `"groups":[]`. Membership is written in one place only: the `groups` field of the admin user form. Apart from that, rows leave `users_groups` only when a user or a group is deleted for good. The admin Users list can filter by group.
- **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.
@@ -108,6 +108,10 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle
| `GET /tokens` | `controllers.APITokenIndex` (`jwt.auth`) |
| `DELETE /tokens/{id}` | `controllers.APITokenDestroy` (`jwt.auth`) |
`/fetch`, `/refresh`, `/update`, `/change-password`, `/activate`, `/marketing-consent`, `/avatar`, `/avatar/remove` and the `jwt.auth` routes read the session token from the bearer header, then the `token` and `auth_token` cookies.
With `golem15.user.password.public_routes` false the `/login`, `/forgot-password` and `/reset-password` rows are not registered.
### Host-mounted OAuth identities
The host application mounts these constructors on a path it chooses. They are not registered under `/_user/api/v1`. `OAuthIdentitiesOptions.WriteNotFound` is the host's 404 writer so missing, foreign and unknown providers share one body. `OAuthIdentitiesOptions.Providers` is the unlink allow-list; when empty it is read on every request from a published `classes.OAuthProviderSource` (its `KnownOAuthProviders`), else it is `google`, `facebook`, `github` and `microsoft`. Unlinking follows the PHP rule: it succeeds when the user has a self-set password (`has_self_set_password`) or another identity remains, and otherwise answers 409 with the phrase `golem15.user::lang.oauth.last_method_blocked` (body unchanged). The guarded delete runs in a transaction that first locks the user's `users` row, so two concurrent unlinks never remove the last sign-in method.
@@ -178,7 +182,7 @@ Packages:
- `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners.
- `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks.
- `RegistrationSettings(app)` returns `allow_registration`, `use_register_throttle`, `activate_mode` and `require_activation` with their defaults (`true`, `true`, `auto`, `true`), and `RegistrationsFromIP(ctx, db, ip)` counts the accounts created from an IP in the last 60 minutes (`/register` refuses at 3 with the throttle on).
- `RefreshTTL(app)` is the refresh window, `golem15.user.jwt.refresh_ttl` minutes (default 20160), and `RemoteIP(r)` the host part of `r.RemoteAddr` that `/login` uses for its throttle.
- `RefreshTTL(app)` is the refresh window, `golem15.user.jwt.refresh_ttl` minutes (default 20160), and `RemoteIP(r)` the host part of `r.RemoteAddr`, with no proxy handling (`/login` and `/register` use `surf.ClientIP` with `http.trusted_proxies`).
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `HashAPIToken` and `RevokeAPIToken`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
- `MergedPermissions(ctx, db, userID)`, `HasPermission(merged, code)` and `UserHasPermission(ctx, db, userID, code)`, the frontend permission resolver, and `FrontendPermissionOptions(ctx, db)`, the catalogue by tab and code.
- `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule.
@@ -187,6 +191,7 @@ Packages:
- `TouchLastSeen(ctx, db, userID)`.
- `OAuthProvider` (`Name`, `Label`) and `OAuthProviderSource` (`EnabledOAuthProviders(ctx)`, `KnownOAuthProviders()`), the seam a social-login plugin publishes with an explicit type argument, `app.Publish[classes.OAuthProviderSource](src)`, so the lookup finds it. `GET /oauth-providers` lists its enabled providers in order; the unlink routes accept its known names.
- `EndSession(ctx, app, w, r)` is what `POST /logout` runs: it blacklists every session JWT the request carries (bearer header, `token` and `auth_token` cookies) that verifies, an expired one too while its refresh window is open, until that window closes, writes the forget cookies described under JWT sessions, and returns the subject of the token the guard would read (`""` when there is none or it does not verify); only a failing blacklist write is an error.
- `SessionToken(r)`, the session JWT the `jwt` guard reads from a request (bearer header, then the `token` and `auth_token` cookies; `""` when there is none); `SessionTokenCookie(r)`, the name of the session cookie that supplied that token (`""` when the bearer header did or there is none), which `POST /refresh` uses to rewrite the `auth.cookie` cookie; and `SessionCookieNames()`, a copy of that cookie list to pass to `bouncer.NewJWTGuard`, so a handler's own guard reads the same token.
- `RequestUser(ctx, app, r)` and `EndRequestSession(ctx, app, w, r)`, for handlers that serve signed-in and anonymous browsers on one route (an OAuth provider's authorize page). `RequestUser` reads and checks the session JWT exactly as the `jwt` guard does (bearer header, then the `token` and `auth_token` cookies, the blacklist, `tokens_valid_after`) and returns the user, or `nil` for an anonymous, invalid, expired or blacklisted request and for a soft-deleted user; only an infrastructure failure is an error. `EndRequestSession` is the best-effort `EndSession`: it revokes and forgets the same way (the forget cookie of `bouncer.LoadCookieConfig`), but a blacklist failure is logged, never returned.
- `AvatarURL(ctx, app, user, size)`, the display picture rule of the user payload at any size: the avatar thumbnail (size by size, mode auto) when the user has an avatar and a file bucket is published, the file path when the thumbnail fails, otherwise the gravatar URL. The URL may be protocol-relative or relative. The payload's `avatar_url` comes from it at 128.
- `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
@@ -207,7 +212,8 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
| `golem15.user.jwt.blacklist_grace` | `0` | Grace period in seconds for a blacklisted token. |
| `golem15.user.jwt.leeway` | `0` | Clock leeway in seconds (declared for parity, not read yet). |
| `golem15.user.jwt.blacklist_sweep_interval` | `10m` | How often expired blacklist rows are deleted (a Go duration). |
| `golem15.user.activation.require_activation` | `true` | Whether a new account must be activated before login. |
| `golem15.user.jwt.token_cookie` | `false` | When on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set the HttpOnly session cookie with the `auth.cookie.*` attributes (`bouncer.LoadCookieConfig`; Max-Age `refresh_ttl`) and leave `token` out of the body (`user`, and `message` on activate-by-code, stay). When off, the token is in the body and no cookie is set. The twin of `golem15.sociallogin.token_cookie`. |
| `golem15.user.activation.require_activation` | `true` | Whether an account must be activated before login. When `true`, `POST /login` refuses a not-activated account (the opaque invalid-login answer, counted as a failed attempt) and `/register` in `admin` mode returns no token; when `false`, both sign such an account in. |
| `golem15.user.activation.activate_mode` | `auto` | `auto`, `user` (mailed code) or `admin`. |
| `golem15.user.activation.reset_ttl_minutes` | `60` | Password reset code lifetime. |
| `golem15.user.activation.activation_ttl_hours` | `72` | Activation code lifetime. |
@@ -218,9 +224,10 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
| `golem15.user.throttle.use_throttle` | `true` | Whether login throttling is on. |
| `golem15.user.password.bcrypt_cost` | `10` | bcrypt cost for new password hashes. |
| `golem15.user.password.min_length` | `8` | Minimum password length. |
| `golem15.user.password.public_routes` | `true` | When `false`, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered and answer 404 like any unknown path; `POST /register` (still gated by `allow_registration` and guarded by `golem15.user.turnstile`) and every other route stay. It is read when the routes are built, so a change needs a restart. It takes `true` or `false`, also as a string (`SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false`); an empty value means `true`; any other value fails boot. |
| `golem15.user.api_tokens.prefix` | `usr_` | Prefix for newly minted and accepted shared API-token secrets. Override an existing application's legacy prefix without creating a plugin-specific token table. |
| `golem15.user.privileged_groups` | `[admin]` | Codes of the privileged user groups. Membership of such a group, the group's code and its delete, and the email, password and permanent delete of its members need `golem15.users.manage_privileged_groups`. Read on every request. An empty list protects no group. |
| `golem15.user.turnstile` | `""` | Cloudflare Turnstile on `POST /login` and `POST /register`. 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. |
## CLI commands
@@ -256,7 +263,21 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
`TestOffboarding` runs the admin deactivate and ban bulk actions and checks that the user's older session JWT and personal API token stop working, that a password login restores a deactivated account with a working token while the old credentials stay dead, and that unban revives nothing; `TestRefreshSubjectChecks` covers the `/refresh` subject checks; `TestRevokeUserAccess` (in `classes`) covers the revocation writes.
`TestLogoutEndsTheSession` signs in through `POST /login` and logs out with the bearer header, with only the `token` or `auth_token` cookie, and with both a bearer and a different cookie token, then checks that the token is refused through every carrier on a `jwt.auth` route, at `/fetch` and at `/refresh`, that the forget cookies match the token cookie's attributes (also with a configured domain and SameSite), that the blacklist row lasts for the refresh window so `/refresh` stays refused after the sweep that follows the access token's expiry, that an expired but refreshable token is revoked, and which tokens a logout refuses with 401.
`TestLogoutEndsTheSession` signs in through `POST /login` and logs out with the bearer header, with only the `token` or `auth_token` cookie, and with both a bearer and a different cookie token, then checks that the token is refused on a `jwt.auth` route, at `/fetch` and at `/refresh`, each through every carrier (bearer header, `token` cookie, `auth_token` cookie), that the forget cookies match the token cookie's attributes (also with a configured domain and SameSite), that the blacklist row lasts for the refresh window so `/refresh` stays refused after the sweep that follows the access token's expiry, that an expired but refreshable token is revoked, and which tokens a logout refuses with 401.
`TestBootBeforeDatabase` boots in the serve order (`party.Activate` with no database, then `lagoon.Publish`, through `newServeOrderEnv` in `admin_harness_test.go`). It checks that the blacklist store and the GORM hooks are installed on Publish and not before, and that a token ended by `POST /logout` is refused on a `jwt.auth` route through every carrier, at `/fetch` and at `/refresh`, with its `jwt_blacklist` row. `TestAppBlacklist` (in `-short`) covers the guard's per-request store lookup: no store, delegation and a failing store.
`TestSessionRoutesAcceptCookies` checks that a session `jwt.auth` accepts through only the `token` or `auth_token` cookie is served by `GET /fetch`, `POST /refresh` (through `token`, the `auth.cookie` default, the rotated token comes back in a rewritten cookie with a `{}` body; through `auth_token` it comes in the body with no cookie; the old token is refused afterwards), `POST /update`, `POST /change-password` (the calling token stays valid) and `POST /marketing-consent`; that a request without credentials still gets the old 401 bodies; that the bearer header wins over a cookie exactly as on `jwt.auth`, an invalid bearer included; and that a cut-off token and a banned user are refused through the cookie.
`TestLoginRequiresActivation` pins the activation gate of `POST /login`: under the default and an explicit `require_activation: true` a not-activated account's correct password gets the byte-identical 401 (status, body, `Content-Type` and `Cache-Control`) of a wrong password, stamps no `last_seen`, adds one throttle attempt and suspends the account after `attempt_limit` refusals; `require_activation: false` and activated accounts sign in; a deactivated activated account is restored with the reactivate mail, and a deactivated not-activated account is refused and stays deactivated unless the key is off.
`TestLoginThrottleClientIP` sends failed logins through one trusted proxy (`http.trusted_proxies`). It checks that the throttle row carries the `X-Forwarded-For` client, and that this client is suspended while another client behind the same proxy still signs in. It also checks that without trusted proxies a changing `X-Forwarded-For` neither moves the counter nor escapes the suspension.
`TestRefreshRewritesAuthCookie` covers the cookie-carried `POST /refresh`: the `token` cookie rewritten with the rotated token and the `LoadCookieConfig` attributes (the old token refused, the new one served by `/fetch` and `jwt.auth`), `auth_token` and bearer refreshes left body-only (the bearer wins over a cookie), a configured cookie name and refresh window, refusals that set no cookie, and a broken `auth.cookie` block that answers the opaque 500 and rotates nothing. `TestSessionTokenCookie` (in `-short`) is the carrier table of `classes.SessionTokenCookie`.
`TestTokenCookie` covers `golem15.user.jwt.token_cookie`: off by default (body tokens, no cookie); on for `POST /login`, `POST /register` and `POST /activate-by-code`, with the session cookie serving `/fetch` and rotating through a cookie refresh and no `token` in the body; `POST /tokens` unaffected; a configured cookie name and refresh window; and a broken `auth.cookie` block that answers the opaque 500 and signs in, creates and activates nothing.
`TestPasswordPublicRoutes` (in `-short`) and `TestPasswordPublicRoutesAnswer404` cover `golem15.user.password.public_routes`: unset, `true` and the `config.yaml` default register every route; `false` (also from a `SUMMER_` variable) drops exactly the three password routes and keeps every other route and its middleware, the turnstile on `/register` included; an invalid value fails `Routes`. Through the assembled router the dropped routes (and a `GET /login`) answer exactly like an unknown path and sign in, mail and reset nothing, while the default answers are unchanged and `/register`, `/fetch`, `/refresh` and `/logout` still work.
`TestAdminOrganisationMembershipClearsRole` checks that the members manager's unlink and link and a Users form save that changes the organisation clear `organisation_role`, that a save keeping the organisation keeps it, and that deleting an organisation clears both columns.

View File

@@ -81,7 +81,7 @@ func TestResetPassword(t *testing.T) {
func TestActivate(t *testing.T) {
app, gdb := sessionApp(t, "0s")
token := loginToken(t, app, gdb, "activate-auth@example.test")
token := inactiveSessionToken(t, app, gdb, "activate-auth@example.test")
rec := bearerJSON(t, controllers.Activate(app), "/_user/api/v1/activate", token, `{"code":"wrong"}`)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"is_activated":false`) {
t.Fatalf("activate %d %s", rec.Code, rec.Body.String())
@@ -97,7 +97,7 @@ func TestActivate(t *testing.T) {
func TestActivateByCode(t *testing.T) {
app, gdb := sessionApp(t, "0s")
user := insertUser(t, gdb, "activate-code@example.test", "correct-horse")
user := insertInactiveUser(t, gdb, "activate-code@example.test", "correct-horse")
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
if err != nil {
t.Fatal(err)

View File

@@ -126,11 +126,10 @@ func adminHarnessDB(t *testing.T) (*gorm.DB, *sql.DB) {
return adminHarnessGDB, adminHarnessSQL
}
// newAdminEnv boots the plugin alone with the admin API mounted. configure
// functions adjust the config before the plugin is activated.
func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
// adminEnvConfig is the config every harness boot starts from: the plugin's
// JWT secret, the admin secret, body limits and the log mail driver.
func adminEnvConfig(t *testing.T) *compass.Config {
t.Helper()
gdb, sqlDB := adminHarnessDB(t)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-admin\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
t.Fatal(err)
@@ -158,7 +157,15 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
t.Fatal(err)
}
}
app := backpack.New(cfg)
return cfg
}
// newAdminEnv boots the plugin alone with the admin API mounted. configure
// functions adjust the config before the plugin is activated.
func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
t.Helper()
gdb, sqlDB := adminHarnessDB(t)
app := backpack.New(adminEnvConfig(t))
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
t.Fatal(err)
}
@@ -191,6 +198,44 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
return &adminEnv{h: h, app: app, gdb: gdb, bucket: bucket, mail: mail}
}
// newServeOrderEnv boots the plugin the way surf's serve command does after
// the generated main's party.Activate: every Boot runs with no database
// published, then lagoon.Publish, the uploads bucket and the router.
// beforePublish, when set, runs between Activate and Publish. Migrate is
// there only because the shared harness database may not be migrated yet.
func newServeOrderEnv(t *testing.T, beforePublish func(*backpack.App)) *adminEnv {
t.Helper()
gdb, sqlDB := adminHarnessDB(t)
app := backpack.New(adminEnvConfig(t))
mail := &mailCapture{next: slog.Default().Handler()}
if err := app.Publish(slog.New(mail)); err != nil {
t.Fatal(err)
}
plugins, err := party.Activate(app, []string{"golem15.user"})
if err != nil {
t.Fatal(err)
}
if beforePublish != nil {
beforePublish(app)
}
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
if err := attach.Publish(app, bucket); err != nil {
t.Fatal(err)
}
if err := lagoon.Migrate(gdb, plugins); err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
return &adminEnv{h: h, app: app, gdb: gdb, bucket: bucket, mail: mail}
}
// admin inserts a backend role holding exactly permissions and a backend
// user in it, logs the user in and returns the bearer token. No permission
// at all gives an administrator who may sign in and open nothing.

View File

@@ -1000,7 +1000,18 @@ func TestAdminUserPassword(t *testing.T) {
} {
assertNoSecret(t, rec.Body.String(), first, user.Password)
}
// A request cannot turn the activation on, and cannot store a hash directly.
// Escalation G2: the account the administrator created is not activated,
// so its password does not sign it in while require_activation is on
// (the default); it gets the invalid-login answer of a wrong password.
// A request still cannot turn the activation on, and cannot store a hash
// directly. Once an administrator activates it, the password signs in.
refused := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": email, "password": first})
if refused.Code != http.StatusUnauthorized || refused.Body.String() != invalidLoginBody {
t.Fatalf("login of the not-activated user = %d %s", refused.Code, refused.Body.String())
}
if rec := env.bulk(t, token, "activate", user); rec.Code != http.StatusOK {
t.Fatalf("activate = %d %s", rec.Code, rec.Body.String())
}
if status, _ := env.userLogin(t, email, first); status != http.StatusOK {
t.Fatalf("login with the created password = %d", status)
}
@@ -1094,8 +1105,8 @@ func TestAdminUserPassword(t *testing.T) {
if status, _ := env.userLogin(t, email, second); status != http.StatusOK {
t.Fatalf("the new password does not sign in: %d", status)
}
// The reset did not activate the user or touch anything else.
if after.IsActivated || after.Email != email {
// The reset kept the activation and the email.
if !after.IsActivated || after.Email != email {
t.Fatalf("the reset changed more than the password: %+v", after)
}
}

61
blacklist.go Normal file
View File

@@ -0,0 +1,61 @@
package user
import (
"context"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
var _ bouncer.BlacklistStore = (*appBlacklist)(nil)
// appBlacklist is the blacklist store the jwt guard is built with. The guard
// is built in Boot, which under summer serve runs before the database is
// published, so the store the plugin publishes on lagoon.Publish is resolved
// on every call. While none is published, no token counts as blacklisted and
// Add and Sweep do nothing. An appBlacklist must never be published as the
// app's bouncer.BlacklistStore: the lookup would then find itself.
type appBlacklist struct {
app *backpack.App
}
// store returns the published blacklist store, or nil when there is none
// (or the published value is an appBlacklist).
func (b *appBlacklist) store() bouncer.BlacklistStore {
if b == nil || b.app == nil {
return nil
}
s, ok := b.app.Lookup[bouncer.BlacklistStore]()
if !ok || s == nil {
return nil
}
if _, self := s.(*appBlacklist); self {
return nil
}
return s
}
func (b *appBlacklist) Add(ctx context.Context, jti string, expiresAt, validUntil time.Time) error {
s := b.store()
if s == nil {
return nil
}
return s.Add(ctx, jti, expiresAt, validUntil)
}
func (b *appBlacklist) IsBlacklisted(ctx context.Context, jti string) (bool, error) {
s := b.store()
if s == nil {
return false, nil
}
return s.IsBlacklisted(ctx, jti)
}
func (b *appBlacklist) Sweep(ctx context.Context, now time.Time) error {
s := b.store()
if s == nil {
return nil
}
return s.Sweep(ctx, now)
}

86
blacklist_test.go Normal file
View File

@@ -0,0 +1,86 @@
package user
import (
"errors"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
// TestAppBlacklist: the jwt guard's store resolves the app's published
// bouncer.BlacklistStore on every call. With none published (or only an
// appBlacklist, which would recurse) no token is blacklisted and Add and
// Sweep do nothing; otherwise calls are delegated and a store error comes
// back unchanged, so the guard still fails closed.
func TestAppBlacklist(t *testing.T) {
ctx := t.Context()
now := time.Now()
newApp := func() *appBlacklist {
return &appBlacklist{app: sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)}
}
assertNoStore := func(t *testing.T, bl *appBlacklist) {
t.Helper()
if got, err := bl.IsBlacklisted(ctx, "jti-none"); got || err != nil {
t.Fatalf("IsBlacklisted with no store = %v, %v", got, err)
}
if err := bl.Add(ctx, "jti-none", now.Add(time.Hour), now); err != nil {
t.Fatalf("Add with no store = %v", err)
}
if err := bl.Sweep(ctx, now); err != nil {
t.Fatalf("Sweep with no store = %v", err)
}
}
t.Run("no store", func(t *testing.T) {
assertNoStore(t, newApp())
})
t.Run("wrapper published as the store", func(t *testing.T) {
bl := newApp()
if err := bl.app.Publish[bouncer.BlacklistStore](&appBlacklist{app: bl.app}); err != nil {
t.Fatal(err)
}
assertNoStore(t, bl)
})
t.Run("delegates", func(t *testing.T) {
bl := newApp()
mem := bouncer.NewMemoryBlacklist()
if err := bl.app.Publish[bouncer.BlacklistStore](mem); err != nil {
t.Fatal(err)
}
// Blacklisted from now on, kept for an hour.
if err := bl.Add(ctx, "jti-ended", now.Add(time.Hour), now); err != nil {
t.Fatal(err)
}
if got, err := mem.IsBlacklisted(ctx, "jti-ended"); !got || err != nil {
t.Fatalf("memory store after Add through the wrapper = %v, %v", got, err)
}
if got, err := bl.IsBlacklisted(ctx, "jti-ended"); !got || err != nil {
t.Fatalf("IsBlacklisted(jti-ended) = %v, %v", got, err)
}
if got, err := bl.IsBlacklisted(ctx, "jti-other"); got || err != nil {
t.Fatalf("IsBlacklisted(jti-other) = %v, %v", got, err)
}
if err := bl.Sweep(ctx, now); err != nil {
t.Fatal(err)
}
})
t.Run("store error passes through", func(t *testing.T) {
bl := newApp()
if err := bl.app.Publish[bouncer.BlacklistStore](failingBlacklist{}); err != nil {
t.Fatal(err)
}
if _, err := bl.IsBlacklisted(ctx, "jti"); !errors.Is(err, errBlacklistDown) {
t.Fatalf("IsBlacklisted error = %v", err)
}
if err := bl.Add(ctx, "jti", now.Add(time.Hour), now); !errors.Is(err, errBlacklistDown) {
t.Fatalf("Add error = %v", err)
}
if err := bl.Sweep(ctx, now); !errors.Is(err, errBlacklistDown) {
t.Fatalf("Sweep error = %v", err)
}
})
}

View File

@@ -20,6 +20,14 @@ import (
// the Authorization header, in the same order (plugin.go Boot).
var sessionCookieNames = []string{"token", "auth_token"}
// SessionCookieNames returns a copy of the cookie names the plugin's "jwt"
// guard (plugin.go Boot) reads after the Authorization header, in order.
// A handler that builds its own guard passes them to bouncer.NewJWTGuard so
// it reads the same token as SessionToken and jwt.auth.
func SessionCookieNames() []string {
return slices.Clone(sessionCookieNames)
}
// RequestUser returns the signed-in user of r for handlers that run without
// the jwt.auth middleware and must also serve anonymous requests (an OAuth
// provider's authorize page, for example). The token is read and checked
@@ -35,7 +43,7 @@ func RequestUser(ctx context.Context, app *backpack.App, r *http.Request) (*mode
if r == nil {
return nil, nil
}
raw := sessionToken(r)
raw := SessionToken(r)
if raw == "" {
return nil, nil
}
@@ -209,15 +217,44 @@ func refreshWindow(app *backpack.App) time.Duration {
return time.Duration(mins) * time.Minute
}
// sessionToken extracts the session JWT as the "jwt" guard does: the
// Authorization bearer header, then each session cookie in order.
func sessionToken(r *http.Request) string {
// SessionToken returns the session JWT the plugin's "jwt" guard reads from r:
// the Authorization bearer header, then each cookie of SessionCookieNames in
// order, trimmed, empty values skipped. It returns "" when r carries none.
// A controller that builds its own guard passes SessionCookieNames() to
// bouncer.NewJWTGuard, so the guard and SessionToken read the same token.
func SessionToken(r *http.Request) string {
if tokens := sessionTokens(r); len(tokens) > 0 {
return tokens[0]
}
return ""
}
// SessionTokenCookie names the session cookie that supplied the token
// SessionToken returns, for a handler that writes that cookie back (POST
// /refresh rewrites it with the rotated token). It is "" when the bearer
// header supplied the token or the request carries none.
func SessionTokenCookie(r *http.Request) string {
if r == nil || bearerToken(r) != "" {
return ""
}
for _, name := range sessionCookieNames {
if c, err := r.Cookie(name); err == nil && strings.TrimSpace(c.Value) != "" {
return name
}
}
return ""
}
// bearerToken returns the trimmed token of r's Authorization bearer header,
// "" when there is none.
func bearerToken(r *http.Request) string {
h := strings.TrimSpace(r.Header.Get("Authorization"))
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(token)
}
return ""
}
// sessionTokens returns every distinct session JWT r carries, in the order
// the "jwt" guard tries them: the Authorization bearer header, then each
// session cookie. Empty values are skipped.
@@ -228,10 +265,7 @@ func sessionTokens(r *http.Request) []string {
out = append(out, v)
}
}
h := strings.TrimSpace(r.Header.Get("Authorization"))
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
add(token)
}
add(bearerToken(r))
for _, name := range sessionCookieNames {
if c, err := r.Cookie(name); err == nil {
add(c.Value)

View File

@@ -5,6 +5,11 @@ jwt:
blacklist_grace: 0
leeway: 0
blacklist_sweep_interval: 10m
# On: POST /login, /register (when it signs the account in) and
# /activate-by-code set the HttpOnly session cookie from auth.cookie.* and
# leave token out of the body. A POST /refresh carried by that cookie
# rewrites it either way. Keep auth.cookie.name token or auth_token.
token_cookie: false
activation:
require_activation: true
activate_mode: auto
@@ -20,6 +25,10 @@ throttle:
password:
bcrypt_cost: 10
min_length: 8
# false leaves POST /login, /forgot-password and /reset-password
# unregistered, so they 404 like any unknown path; /register and the other
# routes stay. Read at boot (a change needs a restart). Takes true or false.
public_routes: true
api_tokens:
prefix: usr_
privileged_groups:

View File

@@ -44,12 +44,33 @@ var errNoAvatar = errors.New("golem15.user: no avatar")
const invalidLoginBody = "Invalid email or password"
// Login authenticates email and password and returns a JWT plus the user payload.
//
// It checks in this order: a ban or an active suspension
// (classes.RejectIfThrottled), the password (bcrypt), the activation, then
// restores a deactivated (deleted_at) account and sends it the reactivate
// mail, records the successful attempt and mints the token. With
// golem15.user.activation.require_activation on (the default) the correct
// password of a not-activated account gets the same 401 invalid-login answer
// as a wrong password and counts as a failed attempt for the throttle; an
// account that is also deactivated is refused before the restore, so it
// stays deactivated and gets no mail. With the key off such an account signs
// in as before. Activation gates login, deactivation does not. The throttle
// counts failed attempts per user and client address, which is
// surf.ClientIP with http.trusted_proxies, as for /register. With
// golem15.user.jwt.token_cookie on, the token goes into the auth.cookie
// session cookie instead of the body.
func Login(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
cc, err := sessionCookie(app)
if err != nil {
pluginLog.Error("golem15.user: token cookie config", "err", err)
writeOpaque500(w)
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
@@ -63,7 +84,7 @@ func Login(app *backpack.App) http.HandlerFunc {
writeOpaque500(w)
return
}
ip := remoteIP(r)
ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))
if user != nil {
if err := classes.RejectIfThrottled(ctx, gdb, user, ip); err != nil {
if classes.IsLoginRejected(err) {
@@ -75,13 +96,14 @@ func Login(app *backpack.App) http.HandlerFunc {
}
}
if user == nil || !bouncer.CheckPassword(user.Password, password) {
if user != nil {
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, false); err != nil && !classes.IsLoginRejected(err) {
writeOpaque500(w)
return
}
}
writeInvalidLogin(w, r, app)
refuseLogin(ctx, w, r, app, gdb, user, ip)
return
}
// The activation gate runs after the password check, so a wrong
// password and a not-activated account cost the same and answer the
// same, and before the restore, so a refused account is not revived.
if _, _, _, requireActivation := registrationSettings(app); requireActivation && !user.IsActivated {
refuseLogin(ctx, w, r, app, gdb, user, ip)
return
}
if user.DeletedAt.Valid {
@@ -122,7 +144,7 @@ func Login(app *backpack.App) http.HandlerFunc {
writeOpaque500(w)
return
}
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload})
writeSession(w, app, cc, token, map[string]any{"user": payload})
}
}
@@ -174,20 +196,44 @@ func Fetch(app *backpack.App) http.HandlerFunc {
}
}
// Refresh slides a still-refreshable bearer token of a user who may still
// sign in: deactivated and banned users and tokens issued before the user's
// tokens_valid_after are refused with the token-failure 401 body.
// Refresh slides a still-refreshable session token of a user who may still
// sign in. The token is read as the plugin's jwt guard reads it: the bearer
// header, then the token and auth_token cookies. When it came from the cookie
// named auth.cookie.name with no bearer header, that cookie is rewritten with
// the rotated token (bouncer.LoadCookieConfig attributes, HttpOnly, Max-Age
// golem15.user.jwt.refresh_ttl) and the body is {}, whoever wrote the
// cookie. A bearer-carried or other-cookie refresh answers the rotated token
// in the body and writes no cookie; the old token is blacklisted after
// golem15.user.jwt.blacklist_grace, so that caller stores the new token
// itself. Refusals write and forget no cookie, and a broken auth.cookie
// block answers a cookie-carried refresh with the opaque 500 before anything
// is rotated. Deactivated and banned users and tokens issued before the
// user's tokens_valid_after are refused with the token-failure 401 body.
func Refresh(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
raw := bearerToken(r)
raw := classes.SessionToken(r)
if raw == "" {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "Token not found"})
return
}
// The auth.cookie block is read before bouncer.Refresh, so a broken
// block rotates and blacklists nothing; a bearer refresh never reads it.
var rotate *bouncer.CookieConfig
if name := classes.SessionTokenCookie(r); name != "" {
cc, err := tokenCookieConfig(app)
if err != nil {
pluginLog.Error("golem15.user: refresh: auth.cookie config", "err", err)
writeOpaque500(w)
return
}
if cc.Name == name {
rotate = &cc
}
}
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
@@ -223,7 +269,7 @@ func Refresh(app *backpack.App) http.HandlerFunc {
if err := classes.TouchLastSeen(r.Context(), gdb, userID); err != nil {
pluginLog.Warn("golem15.user: stamp last_seen on refresh", "user_id", userID, "err", err)
}
writeJSON(w, http.StatusOK, map[string]any{"token": next})
writeSession(w, app, rotate, next, map[string]any{})
}
}
@@ -398,11 +444,14 @@ func ResetPassword(app *backpack.App) http.HandlerFunc {
}
// The reset code was mailed to the account's address, so the write
// that stores the new password also records the proof of that
// address (D-27). A reset that fails here records nothing.
// address (D-27). A reset that fails here records nothing. The
// session cutoff is truncated to whole seconds, as in
// RevokeUserAccess, because JWT iat has whole-second precision, so
// the login that follows the reset is not refused.
now := time.Now()
if err := gdb.WithContext(r.Context()).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{
"password": hashed,
"tokens_valid_after": now,
"tokens_valid_after": now.Truncate(time.Second),
"email_verified_at": now,
}).Error; err != nil {
writeOpaque500(w)
@@ -448,12 +497,20 @@ func Activate(app *backpack.App) http.HandlerFunc {
}
// ActivateByCode activates from a public "{id}!{code}" value and mints a token.
// With golem15.user.jwt.token_cookie on, the token goes into the auth.cookie
// session cookie instead of the body.
func ActivateByCode(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
cc, err := sessionCookie(app)
if err != nil {
pluginLog.Error("golem15.user: token cookie config", "err", err)
writeOpaque500(w)
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
@@ -499,11 +556,7 @@ func ActivateByCode(app *backpack.App) http.HandlerFunc {
writeOpaque500(w)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"message": "Account activated",
"token": token,
"user": payload,
})
writeSession(w, app, cc, token, map[string]any{"message": "Account activated", "user": payload})
}
}
@@ -571,24 +624,29 @@ func Update(app *backpack.App) http.HandlerFunc {
}
// ChangePassword replaces a self-set password and invalidates older tokens.
// The token that made this call stays valid.
// The token is read as the plugin's jwt guard reads it (the bearer header,
// then the token and auth_token cookies), and the token that made this call,
// header or cookie, stays valid.
func ChangePassword(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
raw := bearerToken(r)
raw := classes.SessionToken(r)
sub, iat, _, _, err := bouncer.VerifyClaims(raw, secret)
if err != nil {
writeUnauthorized(w)
return
}
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
principal, err := sessionGuard(app, secret).Authenticate(r)
if err != nil || principal == nil {
writeUnauthorized(w)
return
}
if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil {
// The guard and SessionToken read the same token; a differing
// subject fails closed.
if strconv.FormatUint(uint64(principal.ID), 10) != sub {
writeUnauthorized(w)
return
}
@@ -714,12 +772,20 @@ func MarketingConsent(app *backpack.App) http.HandlerFunc {
}
// Register creates an account and follows activate_mode: auto, user, or admin.
// With golem15.user.jwt.token_cookie on, the token of an account it signs in
// goes into the auth.cookie session cookie instead of the body.
func Register(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, _, ok := sessionDeps(w, app)
if !ok {
return
}
cc, err := sessionCookie(app)
if err != nil {
pluginLog.Error("golem15.user: token cookie config", "err", err)
writeOpaque500(w)
return
}
allow, throttle, mode, require := registrationSettings(app)
ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))
if !allow {
@@ -783,7 +849,7 @@ func Register(app *backpack.App) http.HandlerFunc {
writeOpaque500(w)
return
}
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload})
writeSession(w, app, cc, token, map[string]any{"user": payload})
return
}
writeJSON(w, http.StatusOK, map[string]any{})
@@ -865,38 +931,41 @@ func appConfig(app *backpack.App) *compass.Config {
return app.Config
}
// authenticate resolves the signed-in user of r for the session routes that
// run without jwt.auth: /fetch, /update, /activate, /marketing-consent,
// /avatar and /avatar/remove. It reads the token as the plugin's jwt guard
// does (the bearer header, then the token and auth_token cookies), applies
// the blacklist and the user's tokens_valid_after cutoff, and answers 401
// otherwise.
func authenticate(w http.ResponseWriter, r *http.Request, app *backpack.App) (*models.User, bool) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return nil, false
}
raw := bearerToken(r)
sub, _, _, _, err := bouncer.VerifyClaims(raw, secret)
if err != nil {
writeUnauthorized(w)
return nil, false
}
id, err := strconv.ParseUint(sub, 10, 64)
if err != nil || id == 0 {
writeUnauthorized(w)
return nil, false
}
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
}
if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil {
principal, err := sessionGuard(app, secret).Authenticate(r)
if err != nil || principal == nil || principal.ID == 0 {
writeUnauthorized(w)
return nil, false
}
var user models.User
if err := gdb.WithContext(r.Context()).Where("id = ?", id).Take(&user).Error; err != nil {
if err := gdb.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error; err != nil {
writeUnauthorized(w)
return nil, false
}
return &user, true
}
// sessionGuard is the plugin's jwt guard as plugin.go Boot registers it: the
// bearer header, then the cookies of classes.SessionCookieNames, checked
// against the published bouncer.BlacklistStore when there is one.
func sessionGuard(app *backpack.App, secret string) bouncer.Guard {
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
}
return bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, classes.SessionCookieNames()...)
}
func sessionDeps(w http.ResponseWriter, app *backpack.App) (*gorm.DB, string, bool) {
if app == nil {
writeOpaque500(w)
@@ -1123,16 +1192,6 @@ func jsonNumber(v any) string {
}
}
func bearerToken(r *http.Request) string {
h := strings.TrimSpace(r.Header.Get("Authorization"))
token, ok := strings.CutPrefix(h, "Bearer ")
token = strings.TrimSpace(token)
if !ok || token == "" {
return ""
}
return token
}
func requestURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
@@ -1144,9 +1203,10 @@ func requestURL(r *http.Request) string {
return scheme + "://" + r.Host + r.URL.Path
}
// RemoteIP returns the host part of r.RemoteAddr, the address /login uses
// for its throttle (r.RemoteAddr itself when it has no port, "" when it is
// empty). /register records surf.ClientIP with the trusted proxies instead.
// RemoteIP returns the host part of r.RemoteAddr (r.RemoteAddr itself when
// it has no port, "" when it is empty), with no proxy handling. /login and
// /register key their throttles on surf.ClientIP with the trusted proxies;
// RemoteIP stays for callers that want the connection address.
func RemoteIP(r *http.Request) string {
return remoteIP(r)
}
@@ -1162,6 +1222,47 @@ func remoteIP(r *http.Request) string {
return host
}
// tokenCookieConfig is the auth.cookie block the plugin writes the session
// cookie with: bouncer.LoadCookieConfig of the application config, or its
// defaults when there is no config.
func tokenCookieConfig(app *backpack.App) (bouncer.CookieConfig, error) {
if app == nil || app.Config == nil {
return bouncer.LoadCookieConfig(nil)
}
return bouncer.LoadCookieConfig(app.Config)
}
// sessionCookie is the golem15.sociallogin token_cookie twin: with
// golem15.user.jwt.token_cookie on it returns the auth.cookie config the
// session routes write their token cookie with, or the error that block
// gives. A nil config means the token goes into the body; auth.cookie is
// then not read.
func sessionCookie(app *backpack.App) (*bouncer.CookieConfig, error) {
if app == nil || app.Config == nil || !app.Config.Bool("golem15.user.jwt.token_cookie") {
return nil, nil
}
cc, err := tokenCookieConfig(app)
if err != nil {
return nil, err
}
return &cc, nil
}
// writeSession answers 200 with a session token, the user plugin's twin of
// golem15.sociallogin's writeSession. With cc nil the token goes into
// body["token"], the key set the session routes have always answered. With
// cc set it goes only into the HttpOnly cookie cc writes (Max-Age
// golem15.user.jwt.refresh_ttl), so the token never reaches JavaScript, and
// body is answered without it.
func writeSession(w http.ResponseWriter, app *backpack.App, cc *bouncer.CookieConfig, token string, body map[string]any) {
if cc == nil {
body["token"] = token
} else {
http.SetCookie(w, cc.TokenCookie(token, refreshTTL(app)))
}
writeJSON(w, http.StatusOK, body)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Cache-Control", "no-cache, private")
wire.WriteJSON(w, status, v)
@@ -1174,6 +1275,18 @@ func writeInvalidLogin(w http.ResponseWriter, r *http.Request, app *backpack.App
})
}
// refuseLogin is the one refusal /login gives after the throttle check: it
// records a failed attempt for user (a nil user, an unknown email, records
// nothing) and answers the 401 invalid-login body. The wrong-password and
// the not-activated branches share it, so their answers cannot be told apart.
func refuseLogin(ctx context.Context, w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, user *models.User, ip string) {
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, false); err != nil && !classes.IsLoginRejected(err) {
writeOpaque500(w)
return
}
writeInvalidLogin(w, r, app)
}
func writeUnauthorized(w http.ResponseWriter) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthorized"})
}

View File

@@ -241,8 +241,10 @@ func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) e
}
if changed {
// An admin password reset ends every session of the user, as the
// user's own change-password does.
now := time.Now()
// user's own change-password does. The cutoff is truncated to whole
// seconds, as in RevokeUserAccess, because JWT iat has whole-second
// precision, so the user's next login in that second is not refused.
now := time.Now().Truncate(time.Second)
user.TokensValidAfter = &now
}
return nil

View File

@@ -129,7 +129,7 @@ func TestEmailVerifiedAt(t *testing.T) {
}
})
t.Run("activate_by_code_stamps", func(t *testing.T) {
user := insertUser(t, gdb, email("activate-by-code"), "correct-horse")
user := insertInactiveUser(t, gdb, email("activate-by-code"), "correct-horse")
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
if err != nil {
t.Fatal(err)
@@ -146,7 +146,7 @@ func TestEmailVerifiedAt(t *testing.T) {
})
t.Run("activate_stamps", func(t *testing.T) {
addr := email("activate")
token := loginToken(t, app, gdb, addr)
token := inactiveSessionToken(t, app, gdb, addr)
user := &models.User{ID: userIDByEmail(t, gdb, addr)}
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
if err != nil {
@@ -163,7 +163,7 @@ func TestEmailVerifiedAt(t *testing.T) {
})
t.Run("activate_wrong_code_no_proof", func(t *testing.T) {
addr := email("activate-wrong")
token := loginToken(t, app, gdb, addr)
token := inactiveSessionToken(t, app, gdb, addr)
user := &models.User{ID: userIDByEmail(t, gdb, addr)}
if _, err := classes.IssueActivationCode(t.Context(), gdb, user); err != nil {
t.Fatal(err)

106
login_client_ip_test.go Normal file
View File

@@ -0,0 +1,106 @@
package user
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/compass"
)
// TestLoginThrottleClientIP pins the /login throttle key: the client address
// surf.ClientIP derives with http.trusted_proxies, as /register and the
// user-api rate limit use, and never an X-Forwarded-For header from an
// untrusted peer.
func TestLoginThrottleClientIP(t *testing.T) {
t.Run("behind a trusted proxy", func(t *testing.T) {
e := newAdminEnv(t, func(cfg *compass.Config) {
if err := cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"}); err != nil {
t.Fatal(err)
}
})
user := e.seedUser(t, "throttle-proxy", nil)
const proxy = "10.0.0.2:5000"
for i := range 5 {
rec := e.loginFrom(t, proxy, "203.0.113.10", user.Email, "wrong-password")
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
t.Fatalf("failed login %d = %d %s", i+1, rec.Code, rec.Body.String())
}
}
assertThrottleRow(t, e, user, "203.0.113.10")
if rec := e.loginFrom(t, proxy, "203.0.113.10", user.Email, "correct-horse"); rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
t.Fatalf("suspended client login = %d %s", rec.Code, rec.Body.String())
}
rec := e.loginFrom(t, proxy, "203.0.113.20", user.Email, "correct-horse")
var body struct {
Token string `json:"token"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); rec.Code != http.StatusOK || err != nil || body.Token == "" {
t.Fatalf("other client behind the proxy = %d %s", rec.Code, rec.Body.String())
}
})
t.Run("untrusted X-Forwarded-For is ignored", func(t *testing.T) {
e := newAdminEnv(t)
user := e.seedUser(t, "throttle-direct", nil)
const peer = "192.0.2.50:4000"
for _, forged := range []string{"203.0.113.1", "203.0.113.2", "203.0.113.3", "203.0.113.4", "203.0.113.5"} {
if rec := e.loginFrom(t, peer, forged, user.Email, "wrong-password"); rec.Code != http.StatusUnauthorized {
t.Fatalf("failed login with X-Forwarded-For %s = %d %s", forged, rec.Code, rec.Body.String())
}
}
assertThrottleRow(t, e, user, "192.0.2.50")
if rec := e.loginFrom(t, peer, "203.0.113.99", user.Email, "correct-horse"); rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
t.Fatalf("forged X-Forwarded-For escaped the suspension: %d %s", rec.Code, rec.Body.String())
}
if rec := e.loginFrom(t, "192.0.2.51:4000", "", user.Email, "correct-horse"); rec.Code != http.StatusOK {
t.Fatalf("login from another address = %d %s", rec.Code, rec.Body.String())
}
})
}
// assertThrottleRow: the account has exactly one user_throttle row, keyed on
// ip, with five attempts and suspended.
func assertThrottleRow(t *testing.T, e *adminEnv, user *models.User, ip string) {
t.Helper()
var rows []models.Throttle
if err := e.gdb.Where("user_id = ?", user.ID).Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("throttle rows = %d, want 1: %+v", len(rows), rows)
}
row := rows[0]
got := "<nil>"
if row.IPAddress != nil {
got = *row.IPAddress
}
if got != ip || row.Attempts != 5 || !row.IsSuspended {
t.Fatalf("throttle row ip=%s attempts=%d suspended=%v, want ip=%s attempts=5 suspended", got, row.Attempts, row.IsSuspended, ip)
}
}
// loginFrom posts a login from a fixed connection address, with an
// X-Forwarded-For header when forwardedFor is set. send cannot model a
// reverse proxy: it gives every request its own address.
func (e *adminEnv) loginFrom(t *testing.T, remoteAddr, forwardedFor, email, password string) *httptest.ResponseRecorder {
t.Helper()
raw, err := json.Marshal(map[string]string{"email": email, "password": password})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/login", bytes.NewReader(raw))
req.Host = "example.com"
req.RemoteAddr = remoteAddr
req.Header.Set("Accept-Language", "en")
req.Header.Set("Content-Type", "application/json")
if forwardedFor != "" {
req.Header.Set("X-Forwarded-For", forwardedFor)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}

View File

@@ -24,8 +24,9 @@ const (
// TestLogoutEndsTheSession: POST /logout ends the session however the token
// travels (the bearer header, or only the token or auth_token cookie the
// jwt guard also reads), so the next request with the same token or cookie
// is 401 on the jwt.auth routes, at /fetch and at /refresh. It clears the
// jwt guard also reads), so the next request with the same token, through
// the header or either cookie, is 401 on the jwt.auth routes, at /fetch and
// at /refresh. It clears the
// session cookies with the attributes the token cookie is written with, and
// the revocation lasts for the token's refresh window: a logged-out token is
// not refreshable after its access lifetime, and an expired but still
@@ -224,8 +225,9 @@ func (e *adminEnv) guarded(t *testing.T, token, cookie string) int {
return e.send(t, http.MethodGet, userTokensPath, "", nil, "application/json", map[string]string{"Cookie": cookie + "=" + token}).Code
}
// assertSessionEnded: token no longer authenticates, through any carrier,
// and cannot be refreshed.
// assertSessionEnded: token no longer authenticates, through any carrier
// (the bearer header, or only the token or auth_token cookie), at jwt.auth
// and /fetch, and cannot be refreshed through any of them.
func (e *adminEnv) assertSessionEnded(t *testing.T, token string) {
t.Helper()
for _, carrier := range []string{"", "token", "auth_token"} {
@@ -239,6 +241,15 @@ func (e *adminEnv) assertSessionEnded(t *testing.T, token string) {
if rec := e.refresh(t, token); rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
t.Fatalf("refresh after logout = %d %s", rec.Code, rec.Body.String())
}
for _, name := range []string{"token", "auth_token"} {
cookie := map[string]string{"Cookie": name + "=" + token}
if rec := e.send(t, http.MethodGet, "/_user/api/v1/fetch", "", nil, "application/json", cookie); rec.Code != http.StatusUnauthorized {
t.Fatalf("fetch after logout (cookie %q) = %d", name, rec.Code)
}
if rec := e.send(t, http.MethodPost, userRefreshPath, "", nil, "application/json", cookie); rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
t.Fatalf("refresh after logout (cookie %q) = %d %s", name, rec.Code, rec.Body.String())
}
}
}
func (e *adminEnv) cookieConfig(t *testing.T) bouncer.CookieConfig {

303
password_routes_test.go Normal file
View File

@@ -0,0 +1,303 @@
package user
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
)
const passwordPublicRoutesKey = "golem15.user.password.public_routes"
// passwordRouteKeys are the registrations golem15.user.password.public_routes
// false leaves out.
var passwordRouteKeys = map[string]bool{
http.MethodPost + " /_user/api/v1/login": true,
http.MethodPost + " /_user/api/v1/forgot-password": true,
http.MethodPost + " /_user/api/v1/reset-password": true,
}
// recordRoutesOn records the routes the plugin registers on app and returns
// them with the Routes error.
func recordRoutesOn(t *testing.T, app *backpack.App) ([]recordedRoute, error) {
t.Helper()
rec := newRecordingRouter()
err := (&Plugin{app: app}).Routes(rec)
return *rec.routes, err
}
// passwordRoutesApp is a bareApp with golem15.user.password.public_routes set to value.
func passwordRoutesApp(t *testing.T, value any) *backpack.App {
t.Helper()
app := bareApp(t)
if err := app.Config.Set(passwordPublicRoutesKey, value); err != nil {
t.Fatal(err)
}
return app
}
// withoutPasswordRoutes returns routes without POST /login, /forgot-password and /reset-password.
func withoutPasswordRoutes(routes []recordedRoute) []recordedRoute {
out := make([]recordedRoute, 0, len(routes))
for _, route := range routes {
if !passwordRouteKeys[route.method+" "+route.path] {
out = append(out, route)
}
}
return out
}
// assertSameRoutes requires got to equal want route by route (method, path, middleware, order).
func assertSameRoutes(t *testing.T, got, want []recordedRoute) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%d routes, want %d", len(got), len(want))
}
for i := range got {
g, w := got[i], want[i]
if g.method != w.method || g.path != w.path {
t.Fatalf("route %d is %s %s, want %s %s", i, g.method, g.path, w.method, w.path)
}
if strings.Join(g.middleware, ",") != strings.Join(w.middleware, ",") {
t.Errorf("%s %s middleware %v, want %v", g.method, g.path, g.middleware, w.middleware)
}
}
}
// assertLikeUnknown requires a 404 with the status, body and Content-Type of unknown and no cookie.
func assertLikeUnknown(t *testing.T, got, unknown *httptest.ResponseRecorder) {
t.Helper()
if unknown.Code != http.StatusNotFound {
t.Fatalf("unknown path = %d %s, want 404", unknown.Code, unknown.Body.String())
}
if got.Code != unknown.Code || got.Body.String() != unknown.Body.String() {
t.Fatalf("answer %d %q, want %d %q", got.Code, got.Body.String(), unknown.Code, unknown.Body.String())
}
if g, u := got.Header().Get("Content-Type"), unknown.Header().Get("Content-Type"); g != u {
t.Fatalf("Content-Type %q, want %q", g, u)
}
assertNoCookie(t, got)
}
// TestPasswordPublicRoutes pins golem15.user.password.public_routes on the
// route table: unset, true and the config.yaml default register every route;
// false drops exactly POST /login, /forgot-password and /reset-password and
// nothing else, the turnstile guard staying on /register; an invalid value
// fails Routes naming the key.
func TestPasswordPublicRoutes(t *testing.T) {
base, err := recordRoutesOn(t, bareApp(t))
if err != nil {
t.Fatalf("unset: Routes error %v", err)
}
if len(base) != 18 {
t.Fatalf("unset: %d routes, want 18", len(base))
}
seen := 0
for _, route := range base {
if passwordRouteKeys[route.method+" "+route.path] {
seen++
}
}
if seen != len(passwordRouteKeys) {
t.Fatalf("unset: found %d of the password routes, want %d", seen, len(passwordRouteKeys))
}
off := withoutPasswordRoutes(base)
if len(off) != 15 {
t.Fatalf("base without the password routes has %d routes, want 15", len(off))
}
for _, c := range []struct {
name string
value any
want []recordedRoute
}{
{name: "true", value: true, want: base},
{name: "true string", value: "true", want: base},
{name: "upper-case true string", value: "TRUE", want: base},
{name: "empty means default", value: "", want: base},
{name: "false", value: false, want: off},
{name: "false string", value: "false", want: off},
{name: "padded false string", value: " false ", want: off},
{name: "mixed-case false string", value: "False", want: off},
} {
t.Run(c.name, func(t *testing.T) {
got, err := recordRoutesOn(t, passwordRoutesApp(t, c.value))
if err != nil {
t.Fatalf("Routes error %v", err)
}
assertSameRoutes(t, got, c.want)
})
}
t.Run("config.yaml default", func(t *testing.T) {
app := bareApp(t)
if err := app.Config.MergePlugin("golem15.user", configFS); err != nil {
t.Fatal(err)
}
if v, ok := app.Config.Lookup(passwordPublicRoutesKey); !ok || v != true {
t.Fatalf("config.yaml default = %#v (set %v), want true", v, ok)
}
got, err := recordRoutesOn(t, app)
if err != nil {
t.Fatalf("Routes error %v", err)
}
assertSameRoutes(t, got, base)
})
t.Run("env false", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-bare\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{
"SUMMER_ENV=development",
"SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false",
}})
if err != nil {
t.Fatal(err)
}
got, err := recordRoutesOn(t, backpack.New(cfg))
if err != nil {
t.Fatalf("Routes error %v", err)
}
assertSameRoutes(t, got, off)
})
t.Run("false with turnstile closed", func(t *testing.T) {
app := passwordRoutesApp(t, false)
if err := app.Config.Set("golem15.user.turnstile", "closed"); err != nil {
t.Fatal(err)
}
got, err := recordRoutesOn(t, app)
if err != nil {
t.Fatalf("Routes error %v", err)
}
want := make([]recordedRoute, len(off))
for i, route := range off {
want[i] = route
if route.method == http.MethodPost && route.path == "/_user/api/v1/register" {
want[i].middleware = append(append([]string{}, route.middleware...), "turnstile:closed")
}
}
assertSameRoutes(t, got, want)
for _, route := range got {
if names := turnstileOf(route); len(names) > 0 && route.path != "/_user/api/v1/register" {
t.Errorf("%s %s carries %v", route.method, route.path, names)
}
}
})
t.Run("invalid", func(t *testing.T) {
for _, bad := range []any{"nope", "yes", "1", "0", "t", "f", 0} {
if _, err := recordRoutesOn(t, passwordRoutesApp(t, bad)); err == nil || !strings.Contains(err.Error(), passwordPublicRoutesKey) {
t.Errorf("%#v: error %v, want one naming %s", bad, err, passwordPublicRoutesKey)
}
}
})
}
// TestPasswordPublicRoutesAnswer404 pins that, with
// golem15.user.password.public_routes false, the dropped routes answer
// exactly like an unknown path through the assembled router, while the
// default answers are unchanged and /register, /fetch, /refresh and /logout
// keep working.
func TestPasswordPublicRoutesAnswer404(t *testing.T) {
const unknownPath = "/_user/api/v1/no-such-route"
t.Run("default answers", func(t *testing.T) {
e := newAdminEnv(t, tokenCookieSettings(t, nil))
if v, ok := e.app.Config.Lookup(passwordPublicRoutesKey); !ok || v != true {
t.Fatalf("merged default = %#v (set %v), want true", v, ok)
}
if rec := e.call(t, http.MethodPost, unknownPath, "", map[string]string{}); rec.Code != http.StatusNotFound {
t.Fatalf("unknown path = %d %s, want 404", rec.Code, rec.Body.String())
}
user := e.seedUser(t, "public-routes-default", nil)
if rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "wrong-horse"}); rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
t.Fatalf("login = %d %s, want 401 %s", rec.Code, rec.Body.String(), invalidLoginBody)
}
rec := e.call(t, http.MethodPost, "/_user/api/v1/forgot-password", "", map[string]string{"email": user.Email})
if rec.Code != http.StatusOK {
t.Fatalf("forgot-password = %d %s, want 200", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec, "message")
if rec := e.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{}); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("reset-password = %d %s, want 422", rec.Code, rec.Body.String())
}
if rec := e.call(t, http.MethodGet, userLoginPath, "", nil); rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET login = %d %s, want 405", rec.Code, rec.Body.String())
}
})
e := newAdminEnv(t, tokenCookieSettings(t, map[string]any{passwordPublicRoutesKey: false}))
t.Run("off answers like an unknown path", func(t *testing.T) {
unknown := e.call(t, http.MethodPost, unknownPath, "", map[string]string{})
unknownGet := e.call(t, http.MethodGet, unknownPath, "", nil)
user := e.seedUser(t, "public-routes-off", nil)
login := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"})
assertLikeUnknown(t, login, unknown)
forgot := e.call(t, http.MethodPost, "/_user/api/v1/forgot-password", "", map[string]string{"email": user.Email})
assertLikeUnknown(t, forgot, unknown)
code, err := classes.IssueResetCode(t.Context(), e.gdb, user)
if err != nil {
t.Fatal(err)
}
reset := e.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{
"code": userSub(user) + "!" + code,
"password": "another-horse",
"password_confirmation": "another-horse",
})
assertLikeUnknown(t, reset, unknown)
assertLikeUnknown(t, e.call(t, http.MethodGet, userLoginPath, "", nil), unknownGet)
if got := e.reload(t, user.ID); got.LastSeen != nil {
t.Fatalf("last_seen stamped %v", *got.LastSeen)
}
if got := e.reload(t, user.ID); got.Password != user.Password {
t.Fatal("the password changed")
}
if sent := e.mails(user.Email); len(sent) != 0 {
t.Fatalf("mails sent %#v", sent)
}
})
t.Run("off keeps the rest", func(t *testing.T) {
rec := e.call(t, http.MethodPost, userRegisterPath, "", registerBody("public-routes-register"))
if rec.Code != http.StatusOK {
t.Fatalf("register = %d %s", rec.Code, rec.Body.String())
}
body := assertBodyKeys(t, rec, "token", "user")
token, _ := body["token"].(string)
if token == "" {
t.Fatalf("register token %#v", body["token"])
}
if code := e.fetchCode(t, token); code != http.StatusOK {
t.Fatalf("fetch = %d", code)
}
refreshed := e.refresh(t, token)
if refreshed.Code != http.StatusOK {
t.Fatalf("refresh = %d %s", refreshed.Code, refreshed.Body.String())
}
rotated, _ := assertBodyKeys(t, refreshed, "token")["token"].(string)
if rotated == "" {
t.Fatal("refresh returned no token")
}
if rec := e.call(t, http.MethodPost, "/_user/api/v1/logout", rotated, nil); rec.Code != http.StatusOK {
t.Fatalf("logout = %d %s", rec.Code, rec.Body.String())
}
if rec := e.call(t, http.MethodGet, "/_user/api/v1/oauth-providers", "", nil); rec.Code != http.StatusOK {
t.Fatalf("oauth-providers = %d %s", rec.Code, rec.Body.String())
}
})
}

View File

@@ -70,10 +70,21 @@ func (p *Plugin) Boot(app *backpack.App) error {
if err != nil {
return err
}
if gdb, ok := app.Lookup[*gorm.DB](); ok {
// Serve publishes the database after Boot, so the GORM hooks, the
// blacklist store and its sweep are installed on lagoon.Publish, or at
// once when the database is already published.
if err := lagoon.OnDatabase(app, func(sqlDB *sql.DB, gdb *gorm.DB) error {
if err := classes.RegisterHooks(gdb); err != nil {
return err
}
store := bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist")
if err := app.Publish[bouncer.BlacklistStore](store); err != nil {
return err
}
p.startBlacklistSweep(store)
return nil
}); err != nil {
return err
}
reg, ok := app.Lookup[*bouncer.Registry]()
if !ok || reg == nil {
@@ -82,21 +93,7 @@ func (p *Plugin) Boot(app *backpack.App) error {
return err
}
}
var bl bouncer.BlacklistStore
if sqlDB, ok := app.Lookup[*sql.DB](); ok && sqlDB != nil {
bl = bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist")
} else if gdb, ok := app.Lookup[*gorm.DB](); ok && gdb != nil {
if sqlDB, err := gdb.DB(); err == nil && sqlDB != nil {
bl = bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist")
}
}
if bl != nil {
if err := app.Publish[bouncer.BlacklistStore](bl); err != nil {
return err
}
p.startBlacklistSweep(bl)
}
if err := reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, "token", "auth_token")); err != nil {
if err := reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, &appBlacklist{app: app}, classes.SessionCookieNames()...)); err != nil {
return err
}
prefix, err := classes.APITokenPrefix(app)

261
refresh_cookie_test.go Normal file
View File

@@ -0,0 +1,261 @@
package user
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
)
// opaque500Body is the plugin's opaque 500 answer (wire.WriteOpaque500).
const opaque500Body = `{"error":true,"message":"Internal server error"}`
// TestRefreshRewritesAuthCookie pins the cookie-carried POST /refresh: when
// the token came from the auth.cookie cookie with no bearer header, that
// cookie is rewritten with the rotated token and the body is empty, whoever
// wrote the cookie; bearer and other-cookie refreshes stay body-only;
// refusals touch no cookie; and a broken auth.cookie block fails a cookie
// refresh before it rotates anything.
func TestRefreshRewritesAuthCookie(t *testing.T) {
e := newAdminEnv(t)
t.Run("token cookie rotates", func(t *testing.T) {
old := e.sessionLogin(t, "refresh-cookie-rotates")
rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "token", old, nil)
if rec.Code != http.StatusOK {
t.Fatalf("refresh with the token cookie = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec)
next := assertSessionCookie(t, rec, e.cookieConfig(t), 20160*60)
if next == old {
t.Fatal("the rewritten cookie carries the old token")
}
sub := parseToken(t, old)["sub"]
if got := parseToken(t, next)["sub"]; got != sub {
t.Fatalf("rotated token names %v, old one %v", got, sub)
}
fetch := e.cookieSend(t, http.MethodGet, userFetchPath, "token", next, nil)
if fetch.Code != http.StatusOK {
t.Fatalf("fetch with the rewritten cookie = %d %s", fetch.Code, fetch.Body.String())
}
if got := fetchedID(t, fetch); got != sub {
t.Fatalf("fetch served user %q, token names %v", got, sub)
}
if code := e.guarded(t, next, "token"); code != http.StatusOK {
t.Fatalf("jwt.auth with the rewritten cookie = %d", code)
}
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, "token", old, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("fetch with the rotated-away token cookie = %d", rec.Code)
}
})
t.Run("auth_token cookie stays body-only", func(t *testing.T) {
old := e.sessionLogin(t, "refresh-cookie-auth-token")
rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "auth_token", old, nil)
if rec.Code != http.StatusOK {
t.Fatalf("refresh with the auth_token cookie = %d %s", rec.Code, rec.Body.String())
}
body := assertBodyKeys(t, rec, "token")
if next, _ := body["token"].(string); next == "" || next == old {
t.Fatalf("refresh answered %s", rec.Body.String())
}
assertNoCookie(t, rec)
})
t.Run("bearer wins over the cookie", func(t *testing.T) {
a := e.sessionLogin(t, "refresh-cookie-bearer-a")
b := e.sessionLogin(t, "refresh-cookie-bearer-b")
rec := e.send(t, http.MethodPost, userRefreshPath, a, nil, "application/json", map[string]string{"Cookie": "token=" + b})
if rec.Code != http.StatusOK {
t.Fatalf("refresh with bearer A and cookie B = %d %s", rec.Code, rec.Body.String())
}
body := assertBodyKeys(t, rec, "token")
next, _ := body["token"].(string)
if got, want := parseToken(t, next)["sub"], parseToken(t, a)["sub"]; got != want {
t.Fatalf("refresh rotated user %v, bearer names %v", got, want)
}
assertNoCookie(t, rec)
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, "token", b, nil); rec.Code != http.StatusOK {
t.Fatalf("fetch with cookie B after a bearer refresh = %d", rec.Code)
}
})
t.Run("bearer only", func(t *testing.T) {
token := e.sessionLogin(t, "refresh-cookie-bearer-only")
rec := e.refresh(t, token)
if rec.Code != http.StatusOK {
t.Fatalf("bearer refresh = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec, "token")
assertNoCookie(t, rec)
})
t.Run("configured auth.cookie name", func(t *testing.T) {
custom := newAdminEnv(t, func(c *compass.Config) {
for key, value := range map[string]any{
"auth.cookie.name": "auth_token",
"golem15.user.jwt.refresh_ttl": 60,
} {
if err := c.Set(key, value); err != nil {
t.Fatal(err)
}
}
})
old := custom.sessionLogin(t, "refresh-cookie-custom")
rec := custom.cookieSend(t, http.MethodPost, userRefreshPath, "auth_token", old, nil)
if rec.Code != http.StatusOK {
t.Fatalf("refresh with the auth_token cookie = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec)
next := assertSessionCookie(t, rec, custom.cookieConfig(t), 3600)
if next == old {
t.Fatal("the rewritten cookie carries the old token")
}
if rec := custom.cookieSend(t, http.MethodGet, userFetchPath, "auth_token", next, nil); rec.Code != http.StatusOK {
t.Fatalf("fetch with the rewritten auth_token cookie = %d", rec.Code)
}
other := custom.sessionLogin(t, "refresh-cookie-custom-token")
rec = custom.cookieSend(t, http.MethodPost, userRefreshPath, "token", other, nil)
if rec.Code != http.StatusOK {
t.Fatalf("refresh with the token cookie = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec, "token")
assertNoCookie(t, rec)
})
t.Run("refusals set no cookie", func(t *testing.T) {
user := e.seedUser(t, "refresh-cookie-banned", nil)
e.seedThrottle(t, user, "", true, nil)
banned := userAudienceToken(t, userSub(user), "refresh-cookie-banned-"+adminStamp(), time.Now(), time.Now().Add(time.Hour))
rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "token", banned, nil)
assertRefreshRefused(t, rec)
assertNoCookie(t, rec)
old := e.sessionLogin(t, "refresh-cookie-twice")
if rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "token", old, nil); rec.Code != http.StatusOK {
t.Fatalf("first refresh = %d %s", rec.Code, rec.Body.String())
}
rec = e.cookieSend(t, http.MethodPost, userRefreshPath, "token", old, nil)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
t.Fatalf("second refresh of the same token = %d %s", rec.Code, rec.Body.String())
}
assertNoCookie(t, rec)
})
t.Run("broken auth.cookie", func(t *testing.T) {
broken := newAdminEnv(t, func(c *compass.Config) {
if err := c.Set("auth.cookie.same_site", "sideways"); err != nil {
t.Fatal(err)
}
})
token := broken.sessionLogin(t, "refresh-cookie-broken")
rec := broken.cookieSend(t, http.MethodPost, userRefreshPath, "token", token, nil)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != opaque500Body {
t.Fatalf("cookie refresh with a broken auth.cookie = %d %s", rec.Code, rec.Body.String())
}
assertNoCookie(t, rec)
rec = broken.refresh(t, token)
if rec.Code != http.StatusOK {
t.Fatalf("bearer refresh after the 500 = %d %s", rec.Code, rec.Body.String())
}
if next, _ := assertBodyKeys(t, rec, "token")["token"].(string); next == "" {
t.Fatalf("bearer refresh answered %s", rec.Body.String())
}
})
}
// TestSessionTokenCookie is the carrier table of classes.SessionTokenCookie:
// it names the session cookie SessionToken read the token from, and "" when
// the bearer header supplied it or there is none.
func TestSessionTokenCookie(t *testing.T) {
cases := []struct {
name, auth, cookie, want string
}{
{"bearer plus a token cookie", "Bearer A", "token=B", ""},
{"empty bearer plus a token cookie", "Bearer ", "token=B", "token"},
{"basic auth plus a token cookie", "Basic xyz", "token=B", "token"},
{"only auth_token", "", "auth_token=C", "auth_token"},
{"token and auth_token", "", "token=B; auth_token=C", "token"},
{"empty token cookie", "", "token=; auth_token=X", "auth_token"},
{"no credentials", "", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, userRefreshPath, nil)
if tc.auth != "" {
r.Header.Set("Authorization", tc.auth)
}
if tc.cookie != "" {
r.Header.Set("Cookie", tc.cookie)
}
got := classes.SessionTokenCookie(r)
if got != tc.want {
t.Fatalf("SessionTokenCookie = %q, want %q", got, tc.want)
}
token := classes.SessionToken(r)
if got != "" {
c, err := r.Cookie(got)
if err != nil || strings.TrimSpace(c.Value) != token {
t.Fatalf("cookie %s does not carry SessionToken %q", got, token)
}
} else if token != "" && !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer "+token) {
t.Fatalf("SessionToken %q did not come from the bearer header", token)
}
})
}
if got := classes.SessionTokenCookie(nil); got != "" {
t.Fatalf("SessionTokenCookie(nil) = %q", got)
}
}
// assertSessionCookie requires exactly one cookie, the session cookie cfg
// writes with Max-Age maxAge, and returns its value.
func assertSessionCookie(t *testing.T, rec *httptest.ResponseRecorder, cfg bouncer.CookieConfig, maxAge int) string {
t.Helper()
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("want one session cookie, got %v", rec.Result().Header.Values("Set-Cookie"))
}
c, want := cookies[0], cfg.TokenCookie("x", time.Hour)
if c.Name != cfg.Name || c.Value == "" || c.Path != want.Path || c.Domain != want.Domain ||
c.SameSite != want.SameSite || c.Secure != want.Secure || c.HttpOnly != want.HttpOnly || c.MaxAge != maxAge {
t.Fatalf("session cookie %s does not match %s with Max-Age %d", c, want, maxAge)
}
return c.Value
}
// assertNoCookie requires a response that sets no cookie.
func assertNoCookie(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if cookies := rec.Result().Cookies(); len(cookies) != 0 || rec.Header().Get("Set-Cookie") != "" {
t.Fatalf("response sets cookies %v", rec.Result().Header.Values("Set-Cookie"))
}
}
// assertBodyKeys decodes the JSON object body and requires exactly keys.
func assertBodyKeys(t *testing.T, rec *httptest.ResponseRecorder, keys ...string) map[string]any {
t.Helper()
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body == nil {
t.Fatalf("body %s is not a JSON object: %v", rec.Body.String(), err)
}
got := make([]string, 0, len(body))
for key := range body {
got = append(got, key)
}
slices.Sort(got)
want := slices.Clone(keys)
slices.Sort(want)
if !slices.Equal(got, want) {
t.Fatalf("body keys %v, want %v: %s", got, want, rec.Body.String())
}
return body
}

View File

@@ -14,15 +14,23 @@ func (p *Plugin) Routes(r pact.Router) error {
if err != nil {
return err
}
public, err := p.publicPasswordRoutes()
if err != nil {
return err
}
r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) {
g.Post("/login", controllers.Login(p.app), guard...)
if public {
g.Post("/login", controllers.Login(p.app), guard...)
}
g.Post("/logout", controllers.Logout(p.app))
g.Get("/fetch", controllers.Fetch(p.app))
g.Post("/refresh", controllers.Refresh(p.app))
g.Post("/register", controllers.Register(p.app), guard...)
g.Get("/oauth-providers", controllers.OAuthProviders(p.app))
g.Post("/forgot-password", controllers.ForgotPassword(p.app))
g.Post("/reset-password", controllers.ResetPassword(p.app))
if public {
g.Post("/forgot-password", controllers.ForgotPassword(p.app))
g.Post("/reset-password", controllers.ResetPassword(p.app))
}
g.Post("/activate", controllers.Activate(p.app))
g.Post("/activate-by-code", controllers.ActivateByCode(p.app))
g.Post("/update", controllers.Update(p.app))
@@ -56,3 +64,36 @@ func (p *Plugin) turnstileGuard() ([]string, error) {
return nil, fmt.Errorf("golem15.user: golem15.user.turnstile must be empty, open or closed, got %q", mode)
}
}
// publicPasswordRoutes reports golem15.user.password.public_routes, which
// decides whether POST /login, /forgot-password and /reset-password are
// registered. The default is true. False leaves them unregistered, so they
// answer the router's own 404 like any unknown path. The key is read when the
// routes are built, so a change needs a restart. It takes a bool or the
// string true or false (any case, surrounding spaces trimmed; empty or unset
// means true); any other value is refused so a typo cannot silently remove
// (or keep) the sign-in routes.
func (p *Plugin) publicPasswordRoutes() (bool, error) {
if p.app == nil || p.app.Config == nil {
return true, nil
}
const key = "golem15.user.password.public_routes"
raw, ok := p.app.Config.Lookup(key)
if !ok || raw == nil {
return true, nil
}
switch v := raw.(type) {
case bool:
return v, nil
case string:
switch s := strings.TrimSpace(v); {
case s == "", strings.EqualFold(s, "true"):
return true, nil
case strings.EqualFold(s, "false"):
return false, nil
}
return false, fmt.Errorf("golem15.user: %s must be true or false, got %q", key, v)
default:
return false, fmt.Errorf("golem15.user: %s must be true or false, got %v", key, v)
}
}

114
same_second_cutoff_test.go Normal file
View File

@@ -0,0 +1,114 @@
package user
import (
"net/http"
"strconv"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
)
// TestPasswordCutoffSameSecond pins the whole-second tokens_valid_after
// cutoff of POST /reset-password and of the admin Users password set: JWT
// iat has one-second resolution, so a token minted in the second of the
// reset is accepted on every check site (the jwt guard, /fetch, /refresh)
// and a token from an earlier second is refused.
func TestPasswordCutoffSameSecond(t *testing.T) {
e := newAdminEnv(t)
t.Run("reset-password", func(t *testing.T) {
user := e.seedUser(t, "cutoff-reset", nil)
code, err := classes.IssueResetCode(t.Context(), e.gdb, user)
if err != nil {
t.Fatal(err)
}
const next = "reset-password-same-second"
waitForFreshSecond(t)
rec := e.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{
"code": strconv.FormatUint(uint64(user.ID), 10) + "!" + code,
"password": next,
"password_confirmation": next,
})
if rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Password has been reset"}` {
t.Fatalf("reset = %d %s", rec.Code, rec.Body.String())
}
got := e.reload(t, user.ID)
if got.EmailVerifiedAt == nil {
t.Fatal("reset did not record email_verified_at")
}
assertCutoffSameSecond(t, e, got, next)
})
t.Run("admin password set", func(t *testing.T) {
user := e.seedUser(t, "cutoff-admin", nil)
token := e.admin(t, permAccessUsers)
const next = "admin-password-same-second"
waitForFreshSecond(t)
e.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{
"name": "Cutoff admin", "email": user.Email, "password": next, "password_confirmation": next,
})
assertCutoffSameSecond(t, e, e.reload(t, user.ID), next)
})
}
// assertCutoffSameSecond: the stored cutoff is a whole second; a token whose
// iat is that second works at jwt.auth, /fetch and /refresh, one issued a
// second earlier is refused at all three, and a login with password works.
func assertCutoffSameSecond(t *testing.T, e *adminEnv, user *models.User, password string) {
t.Helper()
if user.TokensValidAfter == nil {
t.Fatal("tokens_valid_after is not set")
}
cutoff := *user.TokensValidAfter
if !cutoff.Equal(cutoff.Truncate(time.Second)) {
t.Fatalf("tokens_valid_after %s is not a whole second", cutoff.Format(time.RFC3339Nano))
}
sub := userSub(user)
same := userAudienceToken(t, sub, "same-"+adminStamp(), cutoff, cutoff.Add(time.Hour))
if code := e.guarded(t, same, ""); code != http.StatusOK {
t.Fatalf("jwt.auth with iat = cutoff: %d", code)
}
if code := e.fetchCode(t, same); code != http.StatusOK {
t.Fatalf("fetch with iat = cutoff: %d", code)
}
// Refresh last: it rotates and revokes the token.
if rec := e.refresh(t, same); rec.Code != http.StatusOK {
t.Fatalf("refresh with iat = cutoff: %d %s", rec.Code, rec.Body.String())
}
earlier := cutoff.Add(-time.Second)
old := userAudienceToken(t, sub, "earlier-"+adminStamp(), earlier, cutoff.Add(time.Hour))
if code := e.guarded(t, old, ""); code != http.StatusUnauthorized {
t.Fatalf("jwt.auth with iat a second before the cutoff: %d", code)
}
if code := e.fetchCode(t, old); code != http.StatusUnauthorized {
t.Fatalf("fetch with iat a second before the cutoff: %d", code)
}
assertRefreshRefused(t, e.refresh(t, old))
status, token := e.userLogin(t, user.Email, password)
if status != http.StatusOK || token == "" {
t.Fatalf("login after the password change = %d", status)
}
if code := e.guarded(t, token, ""); code != http.StatusOK {
t.Fatalf("jwt.auth with the fresh login token: %d", code)
}
if code := e.fetchCode(t, token); code != http.StatusOK {
t.Fatalf("fetch with the fresh login token: %d", code)
}
if iat, err := parseToken(t, token).GetIssuedAt(); err == nil && iat != nil {
t.Logf("login iat %d, cutoff %d, same second: %v", iat.Unix(), cutoff.Unix(), iat.Unix() == cutoff.Unix())
}
}
// waitForFreshSecond sleeps until 10ms after the next whole second, so the
// password change, the reload and the login usually fall within one second.
// The deterministic proof is the crafted-token pair, not this alignment.
func waitForFreshSecond(t *testing.T) {
t.Helper()
now := time.Now()
time.Sleep(now.Truncate(time.Second).Add(time.Second + 10*time.Millisecond).Sub(now))
}

81
serve_order_test.go Normal file
View File

@@ -0,0 +1,81 @@
package user
import (
"net/http"
"sync/atomic"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"gorm.io/gorm"
)
// TestBootBeforeDatabase pins logout revocation when Boot runs before the
// database is published, as under summer serve: nothing database-backed is
// installed before lagoon.Publish, the Postgres blacklist store and the GORM
// hooks are installed when it runs, and a token ended by POST /logout is
// refused on jwt.auth through every carrier, at /fetch and at /refresh, with
// its jwt_blacklist row.
func TestBootBeforeDatabase(t *testing.T) {
// The classes hook registrants are process-global and cannot be removed,
// so the test hook is gated and goes inert once this test ends.
var hooked atomic.Pointer[gorm.DB]
var active atomic.Bool
active.Store(true)
t.Cleanup(func() { active.Store(false) })
classes.RegisterHook(func(gdb *gorm.DB) error {
if active.Load() {
hooked.Store(gdb)
}
return nil
})
e := newServeOrderEnv(t, func(app *backpack.App) {
if _, ok := app.Lookup[*gorm.DB](); ok {
t.Fatal("a *gorm.DB is published before lagoon.Publish")
}
if _, ok := app.Lookup[bouncer.BlacklistStore](); ok {
t.Fatal("a bouncer.BlacklistStore is published before lagoon.Publish")
}
if hooked.Load() != nil {
t.Fatal("the GORM hooks ran before lagoon.Publish")
}
})
store, ok := e.app.Lookup[bouncer.BlacklistStore]()
if !ok {
t.Fatal("no bouncer.BlacklistStore published after lagoon.Publish")
}
if _, ok := store.(*bouncer.PostgresBlacklist); !ok {
t.Fatalf("published blacklist store is %T, want *bouncer.PostgresBlacklist", store)
}
if got := hooked.Load(); got != e.gdb {
t.Fatalf("the GORM hooks ran with %p, want the published handle %p", got, e.gdb)
}
token := e.sessionLogin(t, "serve-order")
if code := e.guarded(t, token, ""); code != http.StatusOK {
t.Fatalf("jwt.auth before logout (bearer) = %d", code)
}
for _, name := range classes.SessionCookieNames() {
if code := e.guarded(t, token, name); code != http.StatusOK {
t.Fatalf("jwt.auth before logout (cookie %q) = %d", name, code)
}
}
assertLoggedOut(t, e.call(t, http.MethodPost, userLogoutPath, token, nil))
e.assertSessionEnded(t, token)
jti, _ := parseToken(t, token)["jti"].(string)
if jti == "" {
t.Fatal("session token has no jti")
}
var rows int64
if err := e.gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, jti).Scan(&rows).Error; err != nil {
t.Fatal(err)
}
if rows != 1 {
t.Fatalf("jwt_blacklist rows for %s = %d, want 1", jti, rows)
}
}

263
session_cookie_test.go Normal file
View File

@@ -0,0 +1,263 @@
package user
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/models"
)
const (
userFetchPath = "/_user/api/v1/fetch"
userUpdatePath = "/_user/api/v1/update"
userChangePasswordPath = "/_user/api/v1/change-password"
userConsentPath = "/_user/api/v1/marketing-consent"
tokenNotFoundBody = `{"error":"Token not found"}`
)
// sessionCookieNames are the cookies the plugin's jwt guard reads after the
// Authorization header.
var sessionCookieNames = []string{"token", "auth_token"}
// TestSessionRoutesAcceptCookies: GET /fetch, POST /refresh, POST /update and
// POST /change-password (and the other signed-in routes behind the shared
// authenticate) read the session token as the jwt guard does: the bearer
// header first, then the token and auth_token cookies. A cookie-only session
// that jwt.auth accepts is served; no credentials stays 401 with the old
// bodies; a bearer header wins over a cookie exactly as on jwt.auth; a
// cookie-carried token is refused for the same reasons as a bearer one; a
// /refresh through the token cookie (the auth.cookie default) gets that
// cookie back with the rotated token and an empty body, and one through
// auth_token gets the rotated token in the body and no cookie.
func TestSessionRoutesAcceptCookies(t *testing.T) {
e := newAdminEnv(t)
for _, name := range sessionCookieNames {
t.Run("fetch via the "+name+" cookie", func(t *testing.T) {
token := e.sessionLogin(t, "cookie-fetch-"+name)
if code := e.guarded(t, token, name); code != http.StatusOK {
t.Fatalf("jwt.auth with the %s cookie = %d", name, code)
}
rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("fetch with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
}
if got, want := fetchedID(t, rec), parseToken(t, token)["sub"]; got != want {
t.Fatalf("fetch served user %q, token names %v", got, want)
}
})
}
t.Run("fetch without credentials", func(t *testing.T) {
rec := e.send(t, http.MethodGet, userFetchPath, "", nil, "application/json", nil)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
t.Fatalf("fetch without credentials = %d %s", rec.Code, rec.Body.String())
}
})
t.Run("bearer wins", func(t *testing.T) {
a := e.sessionLogin(t, "cookie-bearer-a")
b := e.sessionLogin(t, "cookie-bearer-b")
cookie := map[string]string{"Cookie": "token=" + b}
rec := e.send(t, http.MethodGet, userFetchPath, a, nil, "application/json", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("fetch with bearer A and cookie B = %d %s", rec.Code, rec.Body.String())
}
if got, want := fetchedID(t, rec), parseToken(t, a)["sub"]; got != want {
t.Fatalf("fetch served user %q, bearer names %v", got, want)
}
rec = e.send(t, http.MethodGet, userFetchPath, "not-a-token", nil, "application/json", cookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("fetch with an invalid bearer and a valid cookie = %d", rec.Code)
}
rec = e.send(t, http.MethodGet, userTokensPath, "not-a-token", nil, "application/json", cookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("jwt.auth with an invalid bearer and a valid cookie = %d", rec.Code)
}
})
t.Run("fetch refuses a cut-off cookie token", func(t *testing.T) {
user := e.seedUser(t, "cookie-cutoff", nil)
e.cutOff(t, user)
token := userAudienceToken(t, userSub(user), "cookie-cutoff-"+adminStamp(), time.Now().Add(-10*time.Second), time.Now().Add(time.Hour))
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, "auth_token", token, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("fetch with a cut-off auth_token cookie = %d", rec.Code)
}
})
for _, name := range sessionCookieNames {
t.Run("refresh via the "+name+" cookie", func(t *testing.T) {
token := e.sessionLogin(t, "cookie-refresh-"+name)
if code := e.guarded(t, token, name); code != http.StatusOK {
t.Fatalf("jwt.auth with the %s cookie = %d", name, code)
}
rec := e.cookieSend(t, http.MethodPost, userRefreshPath, name, token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("refresh with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
}
var next string
if name == "token" {
if rec.Body.String() != `{}` {
t.Fatalf("refresh with the token cookie answered %s", rec.Body.String())
}
next = assertSessionCookie(t, rec, e.cookieConfig(t), 20160*60)
} else {
var body struct {
Token string `json:"token"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
next = body.Token
if cookies := rec.Result().Cookies(); len(cookies) != 0 {
t.Fatalf("refresh set cookies %v", cookies)
}
}
if next == "" || next == token {
t.Fatalf("refresh answered %s", rec.Body.String())
}
if code := e.fetchCode(t, next); code != http.StatusOK {
t.Fatalf("fetch with the refreshed token = %d", code)
}
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("fetch with the refreshed-away token in the %s cookie = %d", name, rec.Code)
}
})
}
t.Run("refresh refuses a banned user through the cookie", func(t *testing.T) {
user := e.seedUser(t, "cookie-refresh-banned", nil)
e.seedThrottle(t, user, "", true, nil)
token := userAudienceToken(t, userSub(user), "cookie-banned-"+adminStamp(), time.Now(), time.Now().Add(time.Hour))
assertRefreshRefused(t, e.cookieSend(t, http.MethodPost, userRefreshPath, "token", token, nil))
})
t.Run("refresh refuses a cut-off token through the cookie", func(t *testing.T) {
user := e.seedUser(t, "cookie-refresh-cutoff", nil)
e.cutOff(t, user)
token := userAudienceToken(t, userSub(user), "cookie-refresh-cutoff-"+adminStamp(), time.Now().Add(-10*time.Second), time.Now().Add(time.Hour))
assertRefreshRefused(t, e.cookieSend(t, http.MethodPost, userRefreshPath, "token", token, nil))
})
t.Run("refresh without credentials", func(t *testing.T) {
rec := e.send(t, http.MethodPost, userRefreshPath, "", nil, "application/json", nil)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != tokenNotFoundBody {
t.Fatalf("refresh without credentials = %d %s", rec.Code, rec.Body.String())
}
})
for _, name := range sessionCookieNames {
t.Run("update via the "+name+" cookie", func(t *testing.T) {
user := e.seedUser(t, "cookie-update-"+name, nil)
token := e.login(t, user, "correct-horse")
if code := e.guarded(t, token, name); code != http.StatusOK {
t.Fatalf("jwt.auth with the %s cookie = %d", name, code)
}
rec := e.cookieSend(t, http.MethodPost, userUpdatePath, name, token, map[string]any{
"name": "Cookie", "surname": "Carrier", "email": user.Email,
})
if rec.Code != http.StatusOK {
t.Fatalf("update with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
}
if got := e.reload(t, user.ID).Name; got == nil || *got != "Cookie" {
t.Fatalf("name after update = %v", got)
}
})
}
for _, name := range sessionCookieNames {
t.Run("change-password via the "+name+" cookie", func(t *testing.T) {
user := e.seedUser(t, "cookie-password-"+name, func(u *models.User) { u.HasSelfSetPassword = true })
token := e.login(t, user, "correct-horse")
if code := e.guarded(t, token, name); code != http.StatusOK {
t.Fatalf("jwt.auth with the %s cookie = %d", name, code)
}
rec := e.cookieSend(t, http.MethodPost, userChangePasswordPath, name, token, map[string]any{
"current_password": "correct-horse",
"password": "cookie-next-pass",
"password_confirmation": "cookie-next-pass",
})
if rec.Code != http.StatusOK {
t.Fatalf("change-password with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
}
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil); rec.Code != http.StatusOK {
t.Fatalf("fetch with the calling token after change-password = %d", rec.Code)
}
if code, _ := e.userLogin(t, user.Email, "cookie-next-pass"); code != http.StatusOK {
t.Fatalf("login with the new password = %d", code)
}
})
}
t.Run("marketing-consent via the token cookie", func(t *testing.T) {
token := e.sessionLogin(t, "cookie-consent")
rec := e.cookieSend(t, http.MethodPost, userConsentPath, "token", token, map[string]any{"marketing_consent": true})
if rec.Code != http.StatusOK {
t.Fatalf("marketing-consent with the token cookie = %d %s", rec.Code, rec.Body.String())
}
})
for _, path := range []string{userUpdatePath, userChangePasswordPath} {
t.Run(path+" without credentials", func(t *testing.T) {
rec := e.send(t, http.MethodPost, path, "", []byte(`{}`), "application/json", nil)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
t.Fatalf("%s without credentials = %d %s", path, rec.Code, rec.Body.String())
}
})
}
}
// cookieSend sends one request with no bearer header and the session token
// only in the named cookie. body is JSON-encoded unless it is nil.
func (e *adminEnv) cookieSend(t *testing.T, method, path, cookie, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
t.Fatal(err)
}
}
return e.send(t, method, path, "", raw, "application/json", map[string]string{"Cookie": cookie + "=" + token})
}
// login signs user in through POST /login and returns the session token.
func (e *adminEnv) login(t *testing.T, user *models.User, password string) string {
t.Helper()
code, token := e.userLogin(t, user.Email, password)
if code != http.StatusOK || token == "" {
t.Fatalf("login %s = %d", user.Email, code)
}
return token
}
// cutOff sets the user's tokens_valid_after to now, truncated to the second.
func (e *adminEnv) cutOff(t *testing.T, user *models.User) {
t.Helper()
if err := e.gdb.Exec(`UPDATE users SET tokens_valid_after = ? WHERE id = ?`, time.Now().Truncate(time.Second), user.ID).Error; err != nil {
t.Fatal(err)
}
}
func userSub(user *models.User) string {
return strconv.FormatUint(uint64(user.ID), 10)
}
// fetchedID returns user.id of a /fetch answer as a decimal string.
func fetchedID(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var body struct {
User struct {
ID json.Number `json:"id"`
} `json:"user"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("fetch body %s: %v", rec.Body.String(), err)
}
return fmt.Sprint(body.User.ID)
}

View File

@@ -57,7 +57,7 @@ func TestLogin(t *testing.T) {
if err != nil {
t.Fatal(err)
}
user := &models.User{Email: "ada-login@example.test", Password: hash}
user := &models.User{Email: "ada-login@example.test", Password: hash, IsActivated: true}
if err := gdb.Create(user).Error; err != nil {
t.Fatal(err)
}
@@ -116,7 +116,7 @@ func TestLogin(t *testing.T) {
now := time.Now()
ip := "192.0.2.1"
throttled := &models.User{Email: "throttled@example.test", Password: hash}
throttled := &models.User{Email: "throttled@example.test", Password: hash, IsActivated: true}
if err := gdb.Create(throttled).Error; err != nil {
t.Fatal(err)
}
@@ -130,7 +130,7 @@ func TestLogin(t *testing.T) {
}
loc := "en"
soft := &models.User{Email: "soft@example.test", Password: hash, PreferredLocale: &loc}
soft := &models.User{Email: "soft@example.test", Password: hash, IsActivated: true, PreferredLocale: &loc}
if err := gdb.Create(soft).Error; err != nil {
t.Fatal(err)
}
@@ -163,7 +163,7 @@ func TestLoginSixthAttempt(t *testing.T) {
if err != nil {
t.Fatal(err)
}
user := &models.User{Email: "sixth@example.test", Password: hash}
user := &models.User{Email: "sixth@example.test", Password: hash, IsActivated: true}
if err := gdb.Create(user).Error; err != nil {
t.Fatal(err)
}
@@ -192,6 +192,201 @@ func TestLoginSixthAttempt(t *testing.T) {
}
}
// TestLoginRequiresActivation pins the activation gate of POST /login:
// with golem15.user.activation.require_activation on (the default) the
// correct password of a not-activated account gets the same 401
// invalid-login answer as a wrong password and counts as a failed attempt,
// and an account that is also deactivated stays deactivated. With the key
// off, and for activated accounts, login and the deactivation restore work
// as before.
func TestLoginRequiresActivation(t *testing.T) {
hash, err := bouncer.HashPassword(10, "correct-horse")
if err != nil {
t.Fatal(err)
}
newApp := func(t *testing.T, require *bool) (*backpack.App, *gorm.DB) {
t.Helper()
app, gdb := sessionApp(t, "0s")
if require != nil {
if err := app.Config.Set("golem15.user.activation.require_activation", *require); err != nil {
t.Fatal(err)
}
}
return app, gdb
}
on, off := true, false
create := func(t *testing.T, gdb *gorm.DB, email string, activated, deleted bool) *models.User {
t.Helper()
loc := "en"
user := &models.User{Email: email, Password: hash, IsActivated: activated, PreferredLocale: &loc}
if err := gdb.Create(user).Error; err != nil {
t.Fatal(err)
}
if deleted {
if err := gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil {
t.Fatal(err)
}
}
return user
}
login := func(app *backpack.App, email, password string) *httptest.ResponseRecorder {
return postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"`+email+`","password":"`+password+`"}`)
}
reload := func(t *testing.T, gdb *gorm.DB, id uint) models.User {
t.Helper()
var u models.User
if err := gdb.Unscoped().First(&u, id).Error; err != nil {
t.Fatal(err)
}
return u
}
attempts := func(t *testing.T, gdb *gorm.DB, id uint) models.Throttle {
t.Helper()
var row models.Throttle
if err := gdb.Where("user_id = ?", id).Take(&row).Error; err != nil {
t.Fatal(err)
}
return row
}
refused := func(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
t.Fatalf("want the invalid-login 401, got %d %s", rec.Code, rec.Body.String())
}
}
signedIn := func(t *testing.T, rec *httptest.ResponseRecorder, user *models.User) {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
token, _ := body["token"].(string)
if token == "" {
t.Fatalf("no token in %s", rec.Body.String())
}
if sub := parseToken(t, token)["sub"]; sub != strconv.FormatUint(uint64(user.ID), 10) {
t.Fatalf("sub = %v", sub)
}
}
mailer := func(t *testing.T, app *backpack.App) *captureMail {
t.Helper()
mail := &captureMail{}
if err := app.Publish[postcard.Mailer](mail); err != nil {
t.Fatal(err)
}
return mail
}
t.Run("default_refuses", func(t *testing.T) {
app, gdb := newApp(t, nil)
user := create(t, gdb, "act-default@example.test", false, false)
rec := login(app, user.Email, "correct-horse")
refused(t, rec)
if strings.Contains(rec.Body.String(), "token") {
t.Fatalf("refusal carries a token: %s", rec.Body.String())
}
if got := reload(t, gdb, user.ID); got.LastSeen != nil {
t.Fatalf("refusal stamped last_seen %v", got.LastSeen)
}
if row := attempts(t, gdb, user.ID); row.Attempts != 1 {
t.Fatalf("throttle after refusal: %+v", row)
}
})
t.Run("same_as_wrong_password", func(t *testing.T) {
app, gdb := newApp(t, nil)
user := create(t, gdb, "act-same@example.test", false, false)
right := login(app, user.Email, "correct-horse")
if row := attempts(t, gdb, user.ID); row.Attempts != 1 {
t.Fatalf("throttle after the correct password: %+v", row)
}
wrong := login(app, user.Email, "wrong-horse")
if row := attempts(t, gdb, user.ID); row.Attempts != 2 {
t.Fatalf("throttle after the wrong password: %+v", row)
}
refused(t, right)
if right.Code != wrong.Code || right.Body.String() != wrong.Body.String() {
t.Fatalf("correct %d %s, wrong %d %s", right.Code, right.Body.String(), wrong.Code, wrong.Body.String())
}
for _, h := range []string{"Content-Type", "Cache-Control"} {
if a, b := right.Header().Get(h), wrong.Header().Get(h); a != b || a == "" {
t.Fatalf("%s: correct %q, wrong %q", h, a, b)
}
}
})
t.Run("explicit_true_refuses", func(t *testing.T) {
app, gdb := newApp(t, &on)
user := create(t, gdb, "act-true@example.test", false, false)
refused(t, login(app, user.Email, "correct-horse"))
})
t.Run("refusals_suspend", func(t *testing.T) {
app, gdb := newApp(t, nil)
user := create(t, gdb, "act-suspend@example.test", false, false)
for i := 1; i <= 5; i++ {
refused(t, login(app, user.Email, "correct-horse"))
}
if row := attempts(t, gdb, user.ID); row.Attempts != 5 || !row.IsSuspended {
t.Fatalf("after 5 refusals: %+v", row)
}
})
t.Run("off_allows", func(t *testing.T) {
app, gdb := newApp(t, &off)
user := create(t, gdb, "act-off@example.test", false, false)
signedIn(t, login(app, user.Email, "correct-horse"), user)
})
t.Run("activated_allows", func(t *testing.T) {
app, gdb := newApp(t, nil)
user := create(t, gdb, "act-yes@example.test", true, false)
signedIn(t, login(app, user.Email, "correct-horse"), user)
})
t.Run("deactivated_activated_restores", func(t *testing.T) {
app, gdb := newApp(t, nil)
user := create(t, gdb, "act-restore@example.test", true, true)
mail := mailer(t, app)
signedIn(t, login(app, user.Email, "correct-horse"), user)
if got := reload(t, gdb, user.ID); got.DeletedAt.Valid {
t.Fatal("deactivated activated account was not restored")
}
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
t.Fatalf("reactivate template = %#v", mail.got)
}
})
t.Run("deactivated_inactive_refused", func(t *testing.T) {
app, gdb := newApp(t, nil)
user := create(t, gdb, "act-both@example.test", false, true)
mail := mailer(t, app)
refused(t, login(app, user.Email, "correct-horse"))
if got := reload(t, gdb, user.ID); !got.DeletedAt.Valid {
t.Fatal("refused account was restored")
}
if len(mail.got) != 0 {
t.Fatalf("refusal sent mail %#v", mail.got)
}
})
t.Run("deactivated_inactive_off_restores", func(t *testing.T) {
app, gdb := newApp(t, &off)
user := create(t, gdb, "act-both-off@example.test", false, true)
mail := mailer(t, app)
signedIn(t, login(app, user.Email, "correct-horse"), user)
if got := reload(t, gdb, user.ID); got.DeletedAt.Valid {
t.Fatal("account was not restored with require_activation off")
}
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
t.Fatalf("reactivate template = %#v", mail.got)
}
})
}
func TestLogout(t *testing.T) {
app, gdb := sessionApp(t, "0s")
token := loginToken(t, app, gdb, "logout@example.test")
@@ -361,6 +556,8 @@ func (c *captureMail) Send(_ context.Context, msg postcard.Message) error {
return nil
}
// loginToken inserts an activated account with the password "correct-horse"
// (insertUser) and signs it in through POST /login, returning the token.
func loginToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) string {
t.Helper()
insertUser(t, gdb, email, "correct-horse")
@@ -379,19 +576,49 @@ func loginToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) str
return token
}
// insertUser creates an activated account with password, an ordinary
// account that can sign in through POST /login.
func insertUser(t *testing.T, gdb *gorm.DB, email, password string) *models.User {
t.Helper()
return createUser(t, gdb, email, password, true)
}
// insertInactiveUser creates a not-activated account with password. While
// golem15.user.activation.require_activation is on (the default) POST /login
// refuses it.
func insertInactiveUser(t *testing.T, gdb *gorm.DB, email, password string) *models.User {
t.Helper()
return createUser(t, gdb, email, password, false)
}
func createUser(t *testing.T, gdb *gorm.DB, email, password string, activated bool) *models.User {
t.Helper()
hash, err := bouncer.HashPassword(10, password)
if err != nil {
t.Fatal(err)
}
user := &models.User{Email: email, Password: hash}
user := &models.User{Email: email, Password: hash, IsActivated: activated}
if err := gdb.Create(user).Error; err != nil {
t.Fatal(err)
}
return user
}
// inactiveSessionToken inserts a not-activated account (insertInactiveUser)
// and mints its session with controllers.IssueToken, the session such an
// account gets from /register when require_activation is off.
func inactiveSessionToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) string {
t.Helper()
user := insertInactiveUser(t, gdb, email, "correct-horse")
req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/login", nil)
req.Host = "example.com"
token, err := controllers.IssueToken(app, user, req)
if err != nil {
t.Fatal(err)
}
return token
}
func postJSON(t *testing.T, h http.HandlerFunc, path, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))

237
token_cookie_test.go Normal file
View File

@@ -0,0 +1,237 @@
package user
import (
"net/http"
"net/http/httptest"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/compass"
)
const (
userLoginPath = "/_user/api/v1/login"
userRegisterPath = "/_user/api/v1/register"
userActivateByCodePath = "/_user/api/v1/activate-by-code"
)
// TestTokenCookie pins golem15.user.jwt.token_cookie: off keeps the body
// token and sets no cookie; on puts the session token of /login, /register
// and /activate-by-code into the HttpOnly auth.cookie cookie and leaves it
// out of the body; a broken auth.cookie block answers the opaque 500 before
// anything is signed in, created or activated.
func TestTokenCookie(t *testing.T) {
t.Run("off by default", func(t *testing.T) {
e := newAdminEnv(t, tokenCookieSettings(t, nil))
user := e.seedUser(t, "token-cookie-off", nil)
rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"})
if rec.Code != http.StatusOK {
t.Fatalf("login = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec, "token", "user")
assertNoCookie(t, rec)
rec = e.call(t, http.MethodPost, userRegisterPath, "", registerBody("token-cookie-off-register"))
if rec.Code != http.StatusOK {
t.Fatalf("register = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec, "token", "user")
assertNoCookie(t, rec)
rec = e.call(t, http.MethodPost, userActivateByCodePath, "", map[string]string{"code": e.activationCode(t, e.inactiveUser(t, "token-cookie-off-activate"))})
if rec.Code != http.StatusOK {
t.Fatalf("activate-by-code = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec, "message", "token", "user")
assertNoCookie(t, rec)
})
t.Run("on", func(t *testing.T) {
e := newAdminEnv(t, tokenCookieSettings(t, map[string]any{"golem15.user.jwt.token_cookie": true}))
cfg := e.cookieConfig(t)
user := e.seedUser(t, "token-cookie-on", nil)
rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"})
if rec.Code != http.StatusOK {
t.Fatalf("login = %d %s", rec.Code, rec.Body.String())
}
body := assertBodyKeys(t, rec, "user")
if got, _ := body["user"].(map[string]any)["email"].(string); got != user.Email {
t.Fatalf("login answered user %q, want %q", got, user.Email)
}
session := assertSessionCookie(t, rec, cfg, 20160*60)
e.assertCookieServes(t, cfg.Name, session, userSub(user))
rec = e.cookieSend(t, http.MethodPost, userRefreshPath, cfg.Name, session, nil)
if rec.Code != http.StatusOK {
t.Fatalf("cookie refresh = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec)
rotated := assertSessionCookie(t, rec, cfg, 20160*60)
if rotated == session {
t.Fatal("the refreshed cookie carries the old token")
}
e.assertCookieServes(t, cfg.Name, rotated, userSub(user))
rec = e.cookieSend(t, http.MethodPost, userTokensPath, cfg.Name, rotated, map[string]string{"name": "cli"})
if rec.Code != http.StatusCreated {
t.Fatalf("POST /tokens = %d %s", rec.Code, rec.Body.String())
}
if secret, _ := assertBodyKeys(t, rec, "token", "meta")["token"].(string); secret == "" {
t.Fatalf("POST /tokens answered %s", rec.Body.String())
}
assertNoCookie(t, rec)
rec = e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "wrong-horse"})
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
t.Fatalf("login with a wrong password = %d %s", rec.Code, rec.Body.String())
}
assertNoCookie(t, rec)
rec = e.call(t, http.MethodPost, userRegisterPath, "", registerBody("token-cookie-on-register"))
if rec.Code != http.StatusOK {
t.Fatalf("register = %d %s", rec.Code, rec.Body.String())
}
body = assertBodyKeys(t, rec, "user")
registered := assertSessionCookie(t, rec, cfg, 20160*60)
id, _ := body["user"].(map[string]any)["id"].(float64)
e.assertCookieServes(t, cfg.Name, registered, userSub(&models.User{ID: uint(id)}))
inactive := e.inactiveUser(t, "token-cookie-on-activate")
rec = e.call(t, http.MethodPost, userActivateByCodePath, "", map[string]string{"code": e.activationCode(t, inactive)})
if rec.Code != http.StatusOK {
t.Fatalf("activate-by-code = %d %s", rec.Code, rec.Body.String())
}
body = assertBodyKeys(t, rec, "message", "user")
if body["message"] != "Account activated" || body["user"].(map[string]any)["is_activated"] != true {
t.Fatalf("activate-by-code answered %s", rec.Body.String())
}
activated := assertSessionCookie(t, rec, cfg, 20160*60)
e.assertCookieServes(t, cfg.Name, activated, userSub(inactive))
})
t.Run("on with configured auth.cookie", func(t *testing.T) {
e := newAdminEnv(t, tokenCookieSettings(t, map[string]any{
"golem15.user.jwt.token_cookie": true,
"auth.cookie.name": "auth_token",
"golem15.user.jwt.refresh_ttl": 60,
}))
cfg := e.cookieConfig(t)
if cfg.Name != "auth_token" {
t.Fatalf("config not applied: %+v", cfg)
}
user := e.seedUser(t, "token-cookie-custom", nil)
rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"})
if rec.Code != http.StatusOK {
t.Fatalf("login = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec, "user")
session := assertSessionCookie(t, rec, cfg, 3600)
e.assertCookieServes(t, "auth_token", session, userSub(user))
})
t.Run("on with a broken auth.cookie", func(t *testing.T) {
e := newAdminEnv(t, tokenCookieSettings(t, map[string]any{
"golem15.user.jwt.token_cookie": true,
"auth.cookie.same_site": "sideways",
}))
user := e.seedUser(t, "token-cookie-broken", nil)
rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"})
assertOpaque500(t, rec)
if got := e.reload(t, user.ID); got.LastSeen != nil {
t.Fatalf("last_seen after a refused login = %v", got.LastSeen)
}
if got := e.hangers(t, user).Throttle; got != 0 {
t.Fatalf("user_throttle rows after a refused login = %d", got)
}
register := registerBody("token-cookie-broken-register")
rec = e.call(t, http.MethodPost, userRegisterPath, "", register)
assertOpaque500(t, rec)
var count int64
if err := e.gdb.Unscoped().Model(&models.User{}).Where("email = ?", register["email"]).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("register with a broken auth.cookie stored %d users", count)
}
inactive := e.inactiveUser(t, "token-cookie-broken-activate")
code := e.activationCode(t, inactive)
rec = e.call(t, http.MethodPost, userActivateByCodePath, "", map[string]string{"code": code})
assertOpaque500(t, rec)
if e.reload(t, inactive.ID).IsActivated {
t.Fatal("activate-by-code with a broken auth.cookie activated the user")
}
if err := e.app.Config.Set("auth.cookie.same_site", "lax"); err != nil {
t.Fatal(err)
}
rec = e.call(t, http.MethodPost, userActivateByCodePath, "", map[string]string{"code": code})
if rec.Code != http.StatusOK {
t.Fatalf("activate-by-code after fixing auth.cookie = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec, "message", "user")
assertSessionCookie(t, rec, e.cookieConfig(t), 20160*60)
})
}
// tokenCookieSettings turns the registration throttle off and sets keys.
func tokenCookieSettings(t *testing.T, keys map[string]any) func(*compass.Config) {
return func(c *compass.Config) {
if err := c.Set("golem15.user.registration.use_register_throttle", false); err != nil {
t.Fatal(err)
}
for key, value := range keys {
if err := c.Set(key, value); err != nil {
t.Fatal(err)
}
}
}
}
// registerBody is a /register body with a unique email.
func registerBody(label string) map[string]string {
return map[string]string{
"email": label + "-" + adminStamp() + "@example.test",
"password": "correct-horse",
"name": label,
}
}
// inactiveUser seeds a not-activated frontend user.
func (e *adminEnv) inactiveUser(t *testing.T, label string) *models.User {
t.Helper()
return e.seedUser(t, label, func(u *models.User) { u.IsActivated = false })
}
// activationCode issues an activation code for user, packed as id!code.
func (e *adminEnv) activationCode(t *testing.T, user *models.User) string {
t.Helper()
code, err := classes.IssueActivationCode(t.Context(), e.gdb, user)
if err != nil {
t.Fatal(err)
}
return userSub(user) + "!" + code
}
// assertCookieServes: GET /fetch with only the named cookie set to token
// serves the user sub.
func (e *adminEnv) assertCookieServes(t *testing.T, name, token, sub string) {
t.Helper()
rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("fetch with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
}
if got := fetchedID(t, rec); got != sub {
t.Fatalf("fetch with the %s cookie served user %q, want %q", name, got, sub)
}
}
// assertOpaque500 requires the plugin's opaque 500 and no cookie.
func assertOpaque500(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusInternalServerError || rec.Body.String() != opaque500Body {
t.Fatalf("want the opaque 500, got %d %s", rec.Code, rec.Body.String())
}
assertNoCookie(t, rec)
}