3 Commits

Author SHA1 Message Date
Jakub Zych
b179e6b2e5 fix: activate-by-code asks the login gates before it signs in
After the mailed code verifies, ActivateByCode consults the login gates
with source password, as Login does. A gate answer (for example a
second-factor challenge) replaces the token response and sets no session
cookie; a gate error answers the opaque 500. The account is activated
either way. Without a gate, or when every gate allows, the answer is
unchanged. With require_activation off an account can enable a second
factor before it activates, so the mailed code no longer stands in for it.
2026-11-05 17:54:21 +01:00
Jakub Zych
97f83cc2cf feat: plugin.yaml manifest for the SummerCMS marketplace
- plugin.yaml generated by summer plugin:manifest (name override: User)
- TestPluginManifest guards it with kiosk.CheckT
- require SummerCMS v0.6.0, the framework minimum in plugin.yaml
2026-10-29 19:27:56 +01:00
Jakub Zych
6bf219f50a fix(quick-261029-d9j): pick the mail language from Accept-Language, add mail.fallback_locale, Polish subjects
- preferred_locale and the request locale are normalised (comma lists,
  q-weights, region subtags, case) to pl or en by NormalizeMailLocale, so a
  raw Accept-Language such as en-GB,en;q=0.9 selects the -en templates
- new golem15.user.mail.fallback_locale (default pl) is used when neither
  names a supported language; ResolveMailLocaleFor applies it, and Boot
  rejects any value other than pl or en
- activate, reactivate and restore Polish templates get Polish subjects
- no template, route or payload changes; MailTemplate and
  ResolveMailLocale keep their signatures
2026-10-29 09:47:42 +01:00
19 changed files with 692 additions and 41 deletions

View File

@@ -16,9 +16,9 @@ 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. With `require_activation` on (the default), `POST /login` refuses an account that is not activated with the same 401 invalid-login answer as a wrong password and counts the attempt for the login throttle. Such an account is activated by the mailed code (`POST /activate-by-code` answers with a token), by the invitation link or by an administrator. `POST /activate` serves the sessions of not-activated accounts, which exist when `require_activation` is off. `Login` checks in this order: a ban or an active suspension (`RejectIfThrottled`), the password, the activation, then the deactivation restore.
- **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. A `/register` body sets only the `User.RegisterFillable` columns: `name`, `surname`, `email`, `password` (stored as its hash), `preferred_locale` and `marketing_consent`. It cannot set `organisation_id` or `organisation_role` (membership is the administrator's or the application's), `is_onboarded`, `must_change_password`, or the activation, reset, address proof, handle and permission columns; other keys are ignored (they still reach `RegisterEvent.Payload`). `created_ip_address` and `last_ip_address` are server-set from the client address only. With `require_activation` on (the default), `POST /login` refuses an account that is not activated with the same 401 invalid-login answer as a wrong password and counts the attempt for the login throttle. Such an account is activated by the mailed code (`POST /activate-by-code` answers with a token unless a login gate holds the sign-in back, for example a second-factor challenge the user completes through the gate's plugin; the account is activated either way), by the invitation link or by an administrator. `POST /activate` serves the sessions of not-activated accounts, which exist when `require_activation` is off. `Login` checks in this order: a ban or an active suspension (`RejectIfThrottled`), the password, the activation, then the deactivation restore.
- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts its background sweep, which prunes the blacklist, through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh. `GET /fetch`, `POST /refresh`, `POST /update` and `POST /change-password`, like the plugin's other signed-in routes, read the session token as the `jwt` guard does: the bearer header first, then the `token` and `auth_token` cookies, so a cookie-only session works on them and a bearer header wins when both are sent. A `POST /refresh` whose token came from the `auth.cookie.*` cookie (`auth.cookie.name`, default `token`) with no bearer header gets that cookie back with the rotated token: the cookie has the `bouncer.LoadCookieConfig` attributes, HttpOnly and Max-Age `refresh_ttl`, and the answer is `{}`, so the token never reaches JavaScript. This holds whoever wrote the cookie (this plugin's `golem15.user.jwt.token_cookie`, `golem15.sociallogin`'s `token_cookie` or the application); a frontend that writes the `token` cookie itself and refreshes through it gets it back HttpOnly, without a body token, so such a frontend sends the bearer header to keep rotating in the body. A refresh carried by the bearer header (a cookie sent with it included) or by the other session cookie answers `{"token": …}` and writes no cookie; the old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so that caller stores the new token itself. A refused refresh writes and forgets no cookie. When `bouncer.LoadCookieConfig` refuses the `auth.cookie` block, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block. Keep `auth.cookie.name` at `token` or `auth_token`, the cookies the guard reads. With `golem15.user.jwt.token_cookie` on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set that HttpOnly session cookie (`bouncer.LoadCookieConfig` attributes, Max-Age `refresh_ttl`) and leave `token` out of the body; `user`, and `message` on activate-by-code, stay. With it off (the default) the token is in the body and no cookie is set. SameSite stays the `auth.cookie` setting (`lax` by default) and there is no CSRF token, so do not set `same_site: none` for this cookie. With the key on, a broken `auth.cookie` block answers those three routes with the opaque 500 before they sign in, create or activate anything. Personal API tokens (`POST /tokens`) are not sessions and are unaffected. A cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the `auth.cookie` default is `lax`), as on the `jwt.auth` routes.
- **Login gates.** Register publishes a `*classes.LoginGates` registry. Another plugin (a two-factor plugin, for example) looks it up with `app.Lookup[*classes.LoginGates]()` and adds a `classes.LoginGate` in its Boot. `POST /login` consults the gates through `controllers.CheckLoginGates` after the password, activation and throttle checks (and the bcrypt rehash) and before it restores a deactivated account, sends the reactivate mail, stamps `last_seen` and mints the token. A gate sees a `classes.LoginAttempt` (`User`, `Source`, the already-read login body as `Fields`, `Request`). A nil result allows the sign-in; a `classes.LoginGateResult` replaces the token response with its `Status` (0 means 200) and `Body`, for example `{"two_factor_required":true,"challenge_token":…,"available_methods":[…],"expires_at":…}`, and no token is issued, no account is restored and no login event fires. A gate error fails closed with the opaque 500. Without a gate, `/login` answers exactly as before. `POST /refresh` never consults the gates.
- **Login gates.** Register publishes a `*classes.LoginGates` registry. Another plugin (a two-factor plugin, for example) looks it up with `app.Lookup[*classes.LoginGates]()` and adds a `classes.LoginGate` in its Boot. `POST /login` consults the gates through `controllers.CheckLoginGates` after the password, activation and throttle checks (and the bcrypt rehash) and before it restores a deactivated account, sends the reactivate mail, stamps `last_seen` and mints the token. `POST /activate-by-code` consults them after the mailed code verified and before it mints the token, with source `password`: with `require_activation` off an account can enable a second factor before it activates, so the mailed code must not stand in for that factor. A gate sees a `classes.LoginAttempt` (`User`, `Source`, the already-read login or activation body as `Fields`, `Request`). A nil result allows the sign-in; a `classes.LoginGateResult` replaces the token response with its `Status` (0 means 200) and `Body`, for example `{"two_factor_required":true,"challenge_token":…,"available_methods":[…],"expires_at":…}`, and no token is issued, no session cookie is set, no account is restored and no login event fires. A gate error fails closed with the opaque 500. An activation a gate held back or failed stays committed. Without a gate, `/login` and `/activate-by-code` answer exactly as before. `POST /refresh` never consults the gates.
- **Login event.** Every sign-in that issued a session token fires `classes.LoginEvent` (`classes.LoginEventName`, `golem15.user.login`, PHP's name) with `User` and `Source` (`LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor`, `LoginSourceDevice`), after the token was minted. `POST /login` fires it with `password`; another plugin that completes a sign-in fires it through `controllers.FireLoginEvent`. A listener error is logged and never fails the sign-in. Refresh and `/register` fire none.
- **Password-change hook.** A password reset, the user's own `POST /change-password` and an administrator setting a password on the Users screen fire `classes.AccessRevokedEvent` inside the write's transaction (see Access revocation); `(*AccessRevokedEvent).PasswordChanged()` reports these three reasons. A two-factor plugin listens to it to revoke trusted devices, and this plugin revokes the users' pending and confirmed device sessions (see Device authorization); a listener error rolls the password write back. The bcrypt rehash on login and a new account fire nothing.
- **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password. With `golem15.user.password.public_routes` false, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered, so they answer the router's own 404 like any unknown path (any method, so no 405 shows they exist), while the `controllers` handlers stay exported.
@@ -53,7 +53,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
```
Boot compiles the catalog from the activated plugins (`classes.CompileAPITokenScopes`, published as `*classes.APITokenScopeCatalog` and as `pact.MemberTokenScopeCatalog`) and fails on a name that does not pass `pact.ValidTokenScopeName`, a built-in redeclared, a reserved name `backend` or `offline_access`, a name two plugins declare (the error names both), an empty label or description, an `Implies` other than empty, `read` or `write` (the error names the plugin and that value), or a label or description phrase key (containing `::`) that the translator does not have. `Implies` is only those three values: empty means the declared scope is not implied, and the built-ins `read`, `write` and `ai` keep it empty. Implied names are not stored on a token row or a fingerprint. `POST /tokens` and `classes.MintAPITokenFor(app, …)` accept the scopes of the application's own catalog (`classes.APITokenScopesFor(app)`) and answer an undeclared one with the usual 422 `The selected scope is invalid.`; the console's `user:token:create` uses `MintAPITokenFor` too. Applications mint with `MintAPITokenFor`. The older `classes.MintAPIToken` is deprecated; it keeps its signature and accepts the built-ins plus every scope a catalog compiled in the same process declared, a fallback for existing callers; production runs one application per process, so that is the application's own catalog, while a test binary that boots several applications shares it (`classes.ResetDeclaredAPITokenScopes` clears it, for tests only). `user.scope:<name>`, `middleware.Scope` and `APIToken.HasScope` check a declared scope like a built-in, and `GET /tokens` lists it in `scopes`. `GET /tokens/scopes` (`jwt.auth`) answers `{"data":[{"name","label","description","built_in"}]}`, the built-ins first, then the declared scopes in activation order, with phrase-key labels and descriptions translated for the request locale. The admin `g15_` token catalog never shows these scopes (it reads `pact.HasTokenScopes` only). `classes.AllowedAPITokenScopes` stays the built-in list.
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the Polish templates have Polish subjects. The language follows the user's `preferred_locale`, then the request locale (the translate plugin's, else the raw `Accept-Language` header), each normalised to `pl` or `en` by `NormalizeMailLocale` (comma lists, q-weights, region subtags such as `en-GB`, case), then `golem15.user.mail.fallback_locale` (`ResolveMailLocaleFor`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never), so it is untrusted client input. `CreatedEvent` (`classes.CreatedEventName` `user.Created`) fires from API register and admin create. `/register` logs a failing `RegisterEvent` or `CreatedEvent` listener at Error level and continues: a committed registration is never undone or failed by a listener. `DeletingEvent` (`classes.DeletingEventName` `user.Deleting`) fires from `FormAfterDelete` before `ForceDeleteCleanup` / unscoped delete, inside the same transaction. `GetApiArrayEvent` lets other plugins merge keys into the user payload.
- **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload.
- **OAuth providers.** `GET /oauth-providers` lists the social sign-in providers a social-login plugin has enabled, read on every request from its published `classes.OAuthProviderSource`. Without one it answers the empty list, as before.
@@ -233,7 +233,7 @@ Packages:
- `BackendLinkShow`, `BackendLinkShowPost`, `BackendLinkConfirm` and `TokenMe`, the handlers of the account link and `/token/me` routes.
- `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners.
- `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks, consults the login gates before it and fires the login event after it minted the token.
- `CheckLoginGates(ctx, app, attempt)` consults the published `*classes.LoginGates` (none published allows the sign-in) and returns its answer: nil allows, a `*classes.LoginGateResult` replaces the token response, an error must fail the sign-in closed. A social-login plugin calls it before it issues a token.
- `CheckLoginGates(ctx, app, attempt)` consults the published `*classes.LoginGates` (none published allows the sign-in) and returns its answer: nil allows, a `*classes.LoginGateResult` replaces the token response, an error must fail the sign-in closed. `Login` and `ActivateByCode` call it before they mint; a social-login plugin calls it before it issues a token.
- `FireLoginEvent(ctx, app, user, source)` fires `classes.LoginEvent` and returns the listeners' error; call it after the token was minted and log the error.
- `DeviceInitiate`, `DeviceStatus`, `DeviceAuthorize`, `DeviceIndex` and `DeviceDestroy`, the device authorization handlers (see Device authorization).
- `RegistrationSettings(app)` returns `allow_registration`, `use_register_throttle`, `activate_mode` and `require_activation` with their defaults (`true`, `true`, `auto`, `true`), and `RegistrationsFromIP(ctx, db, ip)` counts the accounts created from an IP in the last 60 minutes (`/register` refuses at 3 with the throttle on).
@@ -257,6 +257,7 @@ Packages:
- `LoginEvent` / `LoginEventName` (see Login event).
- `DeviceShortCodeAlphabet`, `NewDeviceToken()`, `NewDeviceShortCode()`, `NormalizeDeviceShortCode(s)`, `DeviceAuthTTL(app)`, `DeviceAuthURLFor(app, token, shortCode)`, `DeviceAuthURL(app, token)` (the same with the token in place of the short code), `DeviceDisplayName(stored, userAgent)`, `RevokeDeviceSessions(ctx, db, userIDs...)` (revokes the users' pending and confirmed sessions; the plugin calls it for every `AccessRevokedEvent`), `FollowDeviceJTI(ctx, db, oldJTI, newJTI)` (moves a used session to a refreshed token's jti and reports a session revoked meanwhile; `POST /refresh` calls it and refuses the refresh then) and `HandOverDeviceJTI(ctx, db, oldJTI, newJTI)` (the same move without the report), the device authorization helpers; device tokens are hashed with `HashAPIToken`.
- `TwoFactorAdmin` (`HasTwoFactor(ctx, tx, userID)`, `Reset(ctx, tx, user)`) and `PermissionResetTwoFactor`, the Reset 2FA slot (see Admin: Reset 2FA), and `(*AccessRevokedEvent).PasswordChanged()` (see Password-change hook).
- `MailTemplate(base, locale)`, `ResolveMailLocale(ctx, user)`, `ResolveMailLocaleFor(ctx, app, user)`, `NormalizeMailLocale(raw)` and `MailFallbackLocale(app)`, the mail language helpers. `ResolveMailLocale` returns `pl`, `en` or `""` (neither the user nor the request names a supported language); a plugin that mails in the user's language calls `ResolveMailLocaleFor`, which adds the application's fallback and always returns `pl` or `en`, and passes its result to `MailTemplate`.
- `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `BackendLink`, `BackendLinkRequest`, `DeviceAuthSession` (with `DeviceStatusPending`, `DeviceStatusConfirmed`, `DeviceStatusUsed`, `DeviceStatusExpired` and `DeviceStatusRevoked`) and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`) and its logo (`OrganisationLogoField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code.
@@ -280,6 +281,7 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
| `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. |
| `golem15.user.mail.fallback_locale` | `pl` | The mail language when the user's `preferred_locale` and the request locale name neither `pl` nor `en`. `pl` keeps the unsuffixed templates; `en` (`SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en`) suits English-only sites. Region forms such as `en-GB` are accepted; any other value fails boot. Read on every send. The user and the request still come first, so a browser that asks for Polish first gets Polish mail on an application whose fallback is `en`. |
| `golem15.user.registration.allow_registration` | `true` | Whether `POST /register` accepts new accounts. |
| `golem15.user.registration.use_register_throttle` | `true` | Whether registration is limited to three accounts per IP an hour. |
| `golem15.user.throttle.attempt_limit` | `5` | Failed logins before suspension. |
@@ -325,6 +327,8 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
`TestRegisterCORSPath` reads the host application's `config/http.yaml`, so run the tests inside an application checkout where the plugin is mounted at `plugins/golem15/user`.
The mail language is pinned by `TestNormalizeMailLocale`, `TestResolveMailLocale`, `TestResolveMailLocaleFor` and `TestMailFallbackLocale` (in `classes`, `-short`), `TestMailFallbackLocaleConfig`, `TestMailFallbackLocaleBoot` and `TestPolishMailSubjects` (root, `-short`; the last renders each Polish template next to its `-en` twin and requires a different subject) and `TestMailLocaleFromRequest` (Postgres, skipped under `-short`: `POST /forgot-password` with a raw `Accept-Language` request locale, the default and an `en` fallback, and a saved `en-GB`).
`TestCommandsDocumented` (in `-short`) logs, without failing, each command whose help lacks a description, long help, examples or an option description (`bonfire.CheckDocs`).
`TestRegisterIgnoresProtectedFields` posts organisation, IP and account-flag keys to `/register` and `RegisterUser` and checks that only the allow-listed columns and the server-side IP are stored; `TestRegisterListenerErrors` checks that failing register and created listeners do not fail the registration.
@@ -353,7 +357,7 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
`TestMemberTokenScopesBoot` (no Docker) checks that the scope Boot step fails for a name two plugins declare (naming both), for a redeclared `read`, `write` or `ai`, for a bad name, an empty label and a missing phrase key. With Docker: `TestMemberTokenScopes` walks the member token scope acceptance list item by item (mint and `middleware.Scope`, the 403 message, the 422 for an undeclared scope, the list, discovery in en and pl, the per-application catalog and the legacy fallback); `TestBackendLinkAuthTime` checks the link proof against login, request, refresh and re-login orders, a token without `auth_time` and another user's session token; `TestBackendLinkShowPost` checks that `POST /backend-link/show` answers like the GET route with `Cache-Control: no-store, private`, the fragment URL and `backend_links.page_path`; `TestAccessRevocation` checks `AccessRevokedEvent` from the ban action, the password reset, the password change and an administrator setting a password (ids, `Before`, reason, the transaction, the subject event after commit, the rollback on a failing listener) and that a banned user loses the backend link until unbanned; `TestUserMCPGuardClientIP` checks `last_used_ip` on the MCP personal-token path; `TestAPITokenRevokeEvent` checks the token-specific revoked event of `DELETE /tokens/{id}`; `TestOrganisationLogo` checks the logo field, the upload, `OrganisationLogoFields` with and without a logo, the URL fallback when the thumbnail fails, and that deleting an organisation removes its avatar and logo attachments, the rows and, once the delete commits, the blobs and thumbnails.
The login-gate seam, the password-change hook and the Reset 2FA slot have their own tests. `TestLoginGates` (in `-short`, clean under `-race`) pins the registry: a nil or empty registry allows, gates run in the order they were added, the first result or error wins and stops the chain, nil gates are ignored, and `controllers.CheckLoginGates` allows when no registry is published. `TestLoginGateOrder` pins where `POST /login` asks the gates: refused first factors (wrong password, unknown email, not activated, banned) never reach them; the gate sees the attempt with the body already read; the bcrypt rehash happens before a challenge and fires no password-change event; a challenged deactivated account stays deactivated with no mail and no login event, while an allowed one is restored with one reactivate mail and one login event; a gate's status and body pass through; `/refresh` never asks a gate. `TestPasswordChangedSources` checks that the reset, the user's own change and an administrator setting a password each fire one `AccessRevokedEvent` with `PasswordChanged()` true inside the write's transaction (a listener's write through `ev.Tx` commits with it, a listener error rolls both back), and that the rehash and a registration fire none. `TestResetTwoFactorAction` covers the `reset_two_factor` record action: not offered without a published `classes.TwoFactorAdmin`, offered with the danger confirmation when one reports a second factor, hidden when it reports none, refused without `golem15.users.reset_two_factor`, `Run` refused outside the action's transaction, `Reset` committed with the action and rolled back by its error, and a privileged-group member refused with 403 before `Reset` without `golem15.users.manage_privileged_groups`. The plan 01 smoke tests (`TestLoginGatesSmoke`, `TestLoginGateSmoke`, `TestPasswordChangedEventSmoke`, `TestResetTwoFactorSlotSmoke`) stay. The Postgres tests need Docker.
The login-gate seam, the password-change hook and the Reset 2FA slot have their own tests. `TestLoginGates` (in `-short`, clean under `-race`) pins the registry: a nil or empty registry allows, gates run in the order they were added, the first result or error wins and stops the chain, nil gates are ignored, and `controllers.CheckLoginGates` allows when no registry is published. `TestLoginGateOrder` pins where `POST /login` asks the gates: refused first factors (wrong password, unknown email, not activated, banned) never reach them; the gate sees the attempt with the body already read; the bcrypt rehash happens before a challenge and fires no password-change event; a challenged deactivated account stays deactivated with no mail and no login event, while an allowed one is restored with one reactivate mail and one login event; a gate's status and body pass through; `/refresh` never asks a gate. `TestActivateByCodeGates` pins `POST /activate-by-code`: a challenge or a refusal replaces the token with the gate's status and body and sets no cookie, the gate sees source `password` and the code in `Fields`, an allowing gate gets the usual token answer, a gate error answers the opaque 500, an invalid code never reaches a gate, and the account is activated whatever the gate answers. `TestPasswordChangedSources` checks that the reset, the user's own change and an administrator setting a password each fire one `AccessRevokedEvent` with `PasswordChanged()` true inside the write's transaction (a listener's write through `ev.Tx` commits with it, a listener error rolls both back), and that the rehash and a registration fire none. `TestResetTwoFactorAction` covers the `reset_two_factor` record action: not offered without a published `classes.TwoFactorAdmin`, offered with the danger confirmation when one reports a second factor, hidden when it reports none, refused without `golem15.users.reset_two_factor`, `Run` refused outside the action's transaction, `Reset` committed with the action and rolled back by its error, and a privileged-group member refused with 403 before `Reset` without `golem15.users.manage_privileged_groups`. The plan 01 smoke tests (`TestLoginGatesSmoke`, `TestLoginGateSmoke`, `TestPasswordChangedEventSmoke`, `TestResetTwoFactorSlotSmoke`) stay. The Postgres tests need Docker.
The device authorization flow is pinned by `TestDeviceCodes` (in `classes`, `-short`: 10,000 tokens and short codes, the alphabet in every position, no modulo bias, the normalization table), `TestDeviceAuthURL` and `TestDeviceDisplayName` (both `-short`: the `auth_url` template with `{short_code}` as the default, the TTL setting and PHP's user-agent order), and on Postgres through the assembled routes: `TestDeviceInitiate` (PHP's answer with an `auth_url` that carries the short code and not the token, the stored pending row, the TTL, the 255-character `device_name` bound, the cleanup of at most 500 stale pending or expired sessions per call, the retry of an insert whose short code collides with a stored one); `TestDeviceStatus` (unknown, pending, PHP's flat expired body for a pending or confirmed session past `expires_at`, used and revoked sessions that never expire); `TestDeviceAuthorize` (short-code forms, the 422 and 400 refusals, the token path, the short code in the token field, body-only fields, sessions bound to another user, a repeated authorize, expired, used and revoked sessions, concurrent authorizes by two users binding one, and the 401 for a missing, invalid, foreign-signed or ended session); `TestDeviceHandOverConcurrent` (50 concurrent polls hand one token over with one login event of source `device`; a poll with another `User-Agent` than the initiating device's takes nothing and the device still collects its token; a deactivated or banned user's session becomes revoked); `TestDeviceAccessRevocation` (a password change revokes the user's confirmed sessions and keeps used ones and other users' sessions, and a session authorized before `tokens_valid_after` hands nothing over without the event); `TestDeviceListAndRevoke` (only confirmed and used sessions, newest activity first, `is_current`, the display name, an empty list as `[]`, 404 for foreign and unknown ids, a repeated revoke, a revoked device signed out); `TestDeviceRevokeAfterRefresh` (the row follows one and two refreshes, a revoke ends the whole token lineage and the blacklist entry outlives the refresh window, a failed refresh leaves the row); `TestDeviceRevokeRacesRefresh` (a refresh of a session revoked after the blacklist check is refused without a token, and a device refreshing in a tight loop while it is revoked ends with its last token refused); `TestDeviceTokensAtRest` (the token column is the SHA-256 hex, no column holds the token or the session JWT). `TestDeviceAuthSessionsMigration` (in `updates`) checks the table's PHP column names plus `jwt_jti`, the types, the unique `token` and `short_code`, the status CHECK, the indexes, the cascade on user delete, the rollback and a second up. The plan 01 smoke tests `TestDeviceCodesSmoke`, `TestDeviceFlowSmoke` and `TestDeviceRevokeSmoke` stay.

View File

@@ -12,7 +12,9 @@ import (
// Login sources name the first factor a LoginAttempt or a LoginEvent comes
// from.
const (
// LoginSourcePassword is POST /_user/api/v1/login (email and password).
// LoginSourcePassword is POST /_user/api/v1/login (email and password)
// and POST /_user/api/v1/activate-by-code (the mailed activation code,
// the first factor of that sign-in).
LoginSourcePassword = "password"
// LoginSourceSocial is a social sign-in completed by a social-login
// plugin.
@@ -42,7 +44,7 @@ type LoginAttempt struct {
// request body again, it is consumed). It carries keys a gate needs,
// such as trusted_device_token. It is nil on a social sign-in. Treat it
// as read-only, untrusted input; it holds the password on a password
// login.
// login and the activation code on an activation.
Fields map[string]any
// Request is the sign-in request, for the client address and the user
// agent.
@@ -78,8 +80,9 @@ type LoginGate interface {
// looks it up with app.Lookup[*classes.LoginGates]() and adds its gate in
// its Boot: every Register runs before every Boot. Password Login consults
// it after the password, activation and throttle checks and before it
// restores a deactivated account and mints the token; JWT refresh never
// consults it. It is safe for concurrent use.
// restores a deactivated account and mints the token; activate-by-code
// consults it after the code verified and before it mints the token; JWT
// refresh never consults it. It is safe for concurrent use.
type LoginGates struct {
mu sync.RWMutex
gates []LoginGate

View File

@@ -2,27 +2,128 @@ package classes
import (
"context"
"fmt"
"strconv"
"strings"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/towel"
)
// MailTemplate appends -en for English and leaves every other locale,
// including empty and pl, on the unsuffixed Polish template name.
const (
// mailFallbackLocaleKey names the mail language used when neither the
// user nor the request names one the plugin has mail for.
mailFallbackLocaleKey = "golem15.user.mail.fallback_locale"
// mailLocalePL is the unsuffixed (Polish) template set and the default
// fallback.
mailLocalePL = "pl"
// mailLocaleEN is the -en template set.
mailLocaleEN = "en"
)
// supportedMailLocale reports whether the plugin has mail in lang.
func supportedMailLocale(lang string) bool {
return lang == mailLocalePL || lang == mailLocaleEN
}
// NormalizeMailLocale reduces a locale value to the language the plugin has
// mail for: pl, en or "". It accepts a plain code (en, PL), a region form
// (en-GB, pl_PL) and an Accept-Language list with q-weights
// (en-GB,en;q=0.9). Entries whose q does not parse, lies outside 0..1 or is
// 0 are dropped, as are languages other than pl and en (the wildcard
// included); of the rest the one with the highest q wins, ties going to the
// earliest. The result never holds anything but pl, en or "".
func NormalizeMailLocale(raw string) string {
best, bestQ := "", -1.0
for _, entry := range strings.Split(raw, ",") {
parts := strings.Split(entry, ";")
tag := strings.ToLower(strings.TrimSpace(parts[0]))
if i := strings.IndexAny(tag, "-_"); i >= 0 {
tag = tag[:i]
}
if !supportedMailLocale(tag) {
continue
}
q, ok := 1.0, true
for _, param := range parts[1:] {
param = strings.TrimSpace(param)
if len(param) < 2 || !strings.EqualFold(param[:2], "q=") {
continue
}
v, err := strconv.ParseFloat(strings.TrimSpace(param[2:]), 64)
if err != nil || v < 0 || v > 1 {
ok = false
break
}
q = v
}
if !ok || q == 0 {
continue
}
if q > bestQ {
best, bestQ = tag, q
}
}
return best
}
// MailTemplate appends -en when locale normalises to English
// (NormalizeMailLocale) and leaves every other value, empty included, on the
// unsuffixed Polish template name. Callers that want the application's
// configured fallback resolve the locale with ResolveMailLocaleFor first.
func MailTemplate(base, locale string) string {
if locale == "en" {
if NormalizeMailLocale(locale) == mailLocaleEN {
return base + "-en"
}
return base
}
// ResolveMailLocale prefers the user's saved locale, then the request locale.
// ResolveMailLocale prefers the user's saved locale, then the request locale,
// each normalised by NormalizeMailLocale; a value naming neither pl nor en is
// skipped. It returns pl, en or "" (neither names a supported language).
func ResolveMailLocale(ctx context.Context, user *models.User) string {
if user != nil && user.PreferredLocale != nil && *user.PreferredLocale != "" {
return *user.PreferredLocale
if user != nil && user.PreferredLocale != nil {
if loc := NormalizeMailLocale(*user.PreferredLocale); loc != "" {
return loc
}
}
if loc, ok := towel.Locale(ctx); ok {
return loc
if ctx != nil {
if raw, ok := towel.Locale(ctx); ok {
return NormalizeMailLocale(raw)
}
}
return ""
}
// MailFallbackLocale returns golem15.user.mail.fallback_locale normalised to
// pl or en: the mail language when neither the user nor the request names
// one the plugin has mail for. The default is pl (the unsuffixed
// templates); an English-only site sets en, for example through
// SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en. A missing app, config or
// blank value gives pl; any other value gives pl and an error naming the key.
func MailFallbackLocale(app *backpack.App) (string, error) {
if app == nil || app.Config == nil {
return mailLocalePL, nil
}
raw := strings.TrimSpace(app.Config.String(mailFallbackLocaleKey))
if raw == "" {
return mailLocalePL, nil
}
if loc := NormalizeMailLocale(raw); loc != "" {
return loc, nil
}
return mailLocalePL, fmt.Errorf("golem15.user: %s must be pl or en, got %q", mailFallbackLocaleKey, raw)
}
// ResolveMailLocaleFor returns the language to mail user in, always pl or
// en: ResolveMailLocale, then the application's MailFallbackLocale. It is the
// helper other plugins use to mail in the user's language with the
// application's fallback; an invalid fallback (rejected at boot) counts as pl.
func ResolveMailLocaleFor(ctx context.Context, app *backpack.App, user *models.User) string {
if loc := ResolveMailLocale(ctx, user); loc != "" {
return loc
}
loc, _ := MailFallbackLocale(app)
return loc
}

View File

@@ -1,16 +1,187 @@
package classes
import "testing"
import (
"context"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/towel"
)
func TestMailTemplate(t *testing.T) {
base := "golem15.user::mail.reactivate"
if got := MailTemplate(base, "pl"); got != base {
t.Fatalf("pl = %s", got)
}
if got := MailTemplate(base, ""); got != base {
t.Fatalf("empty = %s", got)
}
if got := MailTemplate(base, "en"); got != base+"-en" {
t.Fatalf("en = %s", got)
for _, tc := range []struct {
locale, want string
}{
{"pl", base},
{"", base},
{"en", base + "-en"},
{"en-GB", base + "-en"},
{"en-GB,en;q=0.9", base + "-en"},
{"de", base},
{"pl-PL,en;q=0.5", base},
} {
if got := MailTemplate(base, tc.locale); got != tc.want {
t.Errorf("MailTemplate(%q) = %s, want %s", tc.locale, got, tc.want)
}
}
}
func TestNormalizeMailLocale(t *testing.T) {
for _, tc := range []struct {
raw, want string
}{
{"en", "en"},
{"pl", "pl"},
{"EN", "en"},
{"en-GB", "en"},
{"en_US", "en"},
{"pl-PL", "pl"},
{" en-gb ; q=0.9 ", "en"},
{"en-GB,en;q=0.9", "en"},
{"pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7", "pl"},
{"de-DE,de;q=0.9,en;q=0.8", "en"},
{"de-DE,en;q=0.5,pl;q=0.7", "pl"},
{"en;q=0.5,pl;q=0.5", "en"},
{"en;q=0,pl", "pl"},
{"en;q=0", ""},
{"en;q=abc,pl;q=0.1", "pl"},
{"en;q=1.5", ""},
{"english", ""},
{"de", ""},
{"*", ""},
{"", ""},
{" ", ""},
} {
if got := NormalizeMailLocale(tc.raw); got != tc.want {
t.Errorf("NormalizeMailLocale(%q) = %q, want %q", tc.raw, got, tc.want)
}
}
}
func TestResolveMailLocale(t *testing.T) {
str := func(s string) *string { return &s }
header := "en-GB,en;q=0.9"
for _, tc := range []struct {
name string
saved *string
nilUser bool
request string
want string
}{
{"saved region form wins", str("en-GB"), false, "pl", "en"},
{"saved upper case", str("PL"), false, "", "pl"},
{"unsupported saved falls through", str("de"), false, header, "en"},
{"nil user, raw header", nil, true, header, "en"},
{"nil user, no request locale", nil, true, "", ""},
{"empty saved, unsupported request", str(""), false, "fr", ""},
} {
ctx := context.Background()
if tc.request != "" {
ctx = towel.WithLocale(ctx, tc.request)
}
var user *models.User
if !tc.nilUser {
user = &models.User{PreferredLocale: tc.saved}
}
if got := ResolveMailLocale(ctx, user); got != tc.want {
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
}
}
}
func mailTestApp(t *testing.T, environ ...string) *backpack.App {
t.Helper()
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "development", Environ: append([]string{"SUMMER_ENV=development"}, environ...)})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
func TestMailFallbackLocale(t *testing.T) {
if got, err := MailFallbackLocale(nil); got != "pl" || err != nil {
t.Errorf("nil app: %q %v", got, err)
}
if got, err := MailFallbackLocale(&backpack.App{}); got != "pl" || err != nil {
t.Errorf("nil config: %q %v", got, err)
}
if got, err := MailFallbackLocale(mailTestApp(t)); got != "pl" || err != nil {
t.Errorf("no key: %q %v", got, err)
}
for _, tc := range []struct {
value, want string
bad bool
}{
{"", "pl", false},
{" ", "pl", false},
{"en", "en", false},
{"EN-gb", "en", false},
{"pl", "pl", false},
{"de", "pl", true},
} {
app := mailTestApp(t)
if err := app.Config.Set("golem15.user.mail.fallback_locale", tc.value); err != nil {
t.Fatal(err)
}
got, err := MailFallbackLocale(app)
if got != tc.want {
t.Errorf("%q: got %q, want %q", tc.value, got, tc.want)
}
if tc.bad {
if err == nil || !strings.Contains(err.Error(), "golem15.user.mail.fallback_locale") || !strings.Contains(err.Error(), `"de"`) {
t.Errorf("%q: error %v", tc.value, err)
}
} else if err != nil {
t.Errorf("%q: unexpected error %v", tc.value, err)
}
}
app := mailTestApp(t, "SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en")
if got, err := MailFallbackLocale(app); got != "en" || err != nil {
t.Errorf("env override: %q %v", got, err)
}
}
func TestResolveMailLocaleFor(t *testing.T) {
const restore = "golem15.user::mail.restore"
user := &models.User{}
headerCtx := towel.WithLocale(context.Background(), "en-GB,en;q=0.9")
// The reported bug: raw Accept-Language, no preferred_locale, default config.
if got := MailTemplate(restore, ResolveMailLocaleFor(headerCtx, mailTestApp(t), user)); got != restore+"-en" {
t.Errorf("raw header: %s", got)
}
if got := MailTemplate(restore, ResolveMailLocaleFor(context.Background(), mailTestApp(t), user)); got != restore {
t.Errorf("default fallback: %s", got)
}
if got := ResolveMailLocaleFor(context.Background(), nil, nil); got != "pl" {
t.Errorf("nil app and user: %q", got)
}
set := mailTestApp(t)
if err := set.Config.Set("golem15.user.mail.fallback_locale", "en"); err != nil {
t.Fatal(err)
}
if got := MailTemplate(restore, ResolveMailLocaleFor(context.Background(), set, user)); got != restore+"-en" {
t.Errorf("fallback en via Set: %s", got)
}
env := mailTestApp(t, "SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en")
if got := MailTemplate(restore, ResolveMailLocaleFor(context.Background(), env, user)); got != restore+"-en" {
t.Errorf("fallback en via env: %s", got)
}
// The user and the request still come first.
if got := ResolveMailLocaleFor(towel.WithLocale(context.Background(), "pl-PL,en;q=0.5"), set, user); got != "pl" {
t.Errorf("Polish-first browser on an en fallback: %q", got)
}
bad := mailTestApp(t)
if err := bad.Config.Set("golem15.user.mail.fallback_locale", "de"); err != nil {
t.Fatal(err)
}
if got := ResolveMailLocaleFor(context.Background(), bad, user); got != "pl" {
t.Errorf("invalid fallback: %q", got)
}
}

View File

@@ -15,6 +15,14 @@ activation:
activate_mode: auto
reset_ttl_minutes: 60
activation_ttl_hours: 72
mail:
# The language of this plugin's mails when neither the user's
# preferred_locale nor the request locale (the translate plugin's locale,
# else the Accept-Language header) names one the plugin has mail for (pl or
# en). pl keeps the unsuffixed Polish templates; an English-only site sets
# en (SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en). Any other value
# fails boot.
fallback_locale: pl
registration:
allow_registration: true
use_register_throttle: true

View File

@@ -160,7 +160,7 @@ func Login(app *backpack.App) http.HandlerFunc {
writeOpaque500(w)
return
}
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
sendTemplateVars(ctx, app, mailName(ctx, app, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
}
// A failed last_seen write is logged and never fails the login.
if err := classes.TouchLastSeen(ctx, gdb, user.ID); err != nil {
@@ -476,7 +476,7 @@ func ForgotPassword(app *backpack.App) http.HandlerFunc {
pluginLog.Error("golem15.user: issue reset code", "err", err)
} else {
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.restore"), user.Email, mailVars(displayName(user), resetLink(app, user.ID, code), packed))
sendTemplateVars(ctx, app, mailName(ctx, app, user, "golem15.user::mail.restore"), user.Email, mailVars(displayName(user), resetLink(app, user.ID, code), packed))
}
}
writeJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody})
@@ -588,8 +588,15 @@ 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
// ActivateByCode activates from a public "{id}!{code}" value, consults the
// login gates and mints a token. The gates run as on password Login, with
// source password, because the mailed code is the first factor here: with
// golem15.user.activation.require_activation off an account can enable a
// second factor before it activates, and the code must not stand in for it.
// A gate answer replaces the token response with its status and body and a
// gate error answers the opaque 500; neither mints a token or sets a
// session cookie, and the account stays activated either way. 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) {
@@ -638,6 +645,28 @@ func ActivateByCode(app *backpack.App) http.HandlerFunc {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "This activation link is invalid or has expired"})
return
}
// The activation above is committed whatever the gates answer. A
// gate answer replaces the token response; a gate error fails
// closed.
gate, err := CheckLoginGates(r.Context(), app, classes.LoginAttempt{
User: user,
Source: classes.LoginSourcePassword,
Fields: fields,
Request: r,
})
if err != nil {
pluginLog.Error("golem15.user: login gate", "user_id", user.ID, "err", err)
writeOpaque500(w)
return
}
if gate != nil {
status := gate.Status
if status == 0 {
status = http.StatusOK
}
writeJSON(w, status, gate.Body)
return
}
token, err := mintFor(app, secret, user, requestURL(r))
if err != nil {
writeOpaque500(w)
@@ -937,7 +966,7 @@ func Register(app *backpack.App) http.HandlerFunc {
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
vars = mailVars(displayName(user), activationLink(app, user.ID, code), packed)
}
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.activate"), user.Email, vars)
sendTemplateVars(ctx, app, mailName(ctx, app, user, "golem15.user::mail.activate"), user.Email, vars)
writeJSON(w, http.StatusOK, map[string]any{"message": "Activation email sent"})
return
}
@@ -1157,8 +1186,8 @@ func blacklistGrace(app *backpack.App) time.Duration {
return time.Duration(secs) * time.Second
}
func mailName(ctx context.Context, user *models.User, base string) string {
return classes.MailTemplate(base, classes.ResolveMailLocale(ctx, user))
func mailName(ctx context.Context, app *backpack.App, user *models.User, base string) string {
return classes.MailTemplate(base, classes.ResolveMailLocaleFor(ctx, app, user))
}
func sendTemplate(ctx context.Context, app *backpack.App, template, to string) {

View File

@@ -12,8 +12,9 @@ import (
// nil allows the sign-in, a result replaces the token response, and an error
// must fail the sign-in closed (answer the opaque 500, issue no token). It
// looks the registry up on every call and allows the sign-in when none is
// published. Password Login calls it; a social-login plugin calls it before
// it issues a token.
// published. Password Login calls it, activate-by-code calls it after the
// mailed code verified (source password), and a social-login plugin calls
// it before it issues a token.
func CheckLoginGates(ctx context.Context, app *backpack.App, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
if app == nil {
return nil, nil

View File

@@ -118,7 +118,7 @@ func FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *mode
return err
}
user.DeletedAt = gorm.DeletedAt{}
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
sendTemplateVars(ctx, app, mailName(ctx, app, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
}
if ip != "" {
if err := db.WithContext(ctx).Unscoped().Model(user).Update("last_ip_address", ip).Error; err != nil {

View File

@@ -378,7 +378,7 @@ func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) er
vars["login"], vars["Login"] = email, email
hours := c.configInt("golem15.user.activation.activation_ttl_hours", 72)
vars["hours"], vars["Hours"] = hours, hours
template := mailName(ctx, user, "golem15.user::mail.invite")
template := mailName(ctx, app, user, "golem15.user::mail.invite")
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
sendTemplateVars(ctx, app, template, email, vars)
})

2
go.mod
View File

@@ -3,7 +3,7 @@ module git.golem15.com/golem15/sm-user-plugin
go 1.27.0
require (
git.golem15.com/golem15/summercms v0.5.2
git.golem15.com/golem15/summercms v0.6.0
github.com/go-gormigrate/gormigrate/v2 v2.1.7
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.10.0

View File

@@ -6,6 +6,9 @@ import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -399,3 +402,136 @@ func TestLoginGateOrder(t *testing.T) {
}
})
}
// TestActivateByCodeGates pins where POST /activate-by-code consults the
// login gates: after the mailed code verifies, with source password (the
// code is the first factor here), and before the mint. A gate answer
// replaces the token response with its status and body and sets no session
// cookie, a gate error answers the opaque 500, and the account is activated
// whatever the gate answers. An invalid code never reaches a gate.
func TestActivateByCodeGates(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
challenge := &classes.LoginGateResult{Body: map[string]any{
"two_factor_required": true,
"challenge_token": "c",
"available_methods": []string{"totp"},
"expires_at": "2026-10-26T12:00:00+00:00",
}}
inactive := func(t *testing.T, env *adminEnv, label string) (*models.User, string) {
t.Helper()
user := env.seedUser(t, label, func(u *models.User) { u.IsActivated = false })
code, err := classes.IssueActivationCode(t.Context(), env.gdb, user)
if err != nil {
t.Fatal(err)
}
return user, strconv.FormatUint(uint64(user.ID), 10) + "!" + code
}
activate := func(t *testing.T, env *adminEnv, packed string) *httptest.ResponseRecorder {
t.Helper()
return env.call(t, http.MethodPost, "/_user/api/v1/activate-by-code", "", map[string]string{"code": packed})
}
noSession := func(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if cookies := rec.Header().Values("Set-Cookie"); len(cookies) != 0 {
t.Fatalf("a held-back activation set cookies %v", cookies)
}
if strings.Contains(rec.Body.String(), `"token"`) {
t.Fatalf("a held-back activation answered a token: %s", rec.Body.String())
}
}
t.Run("challenge_replaces_the_token", func(t *testing.T) {
env, gate := gatedEnv(t, challenge)
user, packed := inactive(t, env, "abc-challenge")
rec := activate(t, env, packed)
want := `{"available_methods":["totp"],"challenge_token":"c","expires_at":"2026-10-26T12:00:00+00:00","two_factor_required":true}`
if rec.Code != http.StatusOK || rec.Body.String() != want {
t.Fatalf("challenged activation = %d %s, want 200 %s", rec.Code, rec.Body.String(), want)
}
noSession(t, rec)
if gate.count() != 1 {
t.Fatalf("gate calls = %d, want 1", gate.count())
}
seen := gate.seen[0]
if seen.Source != classes.LoginSourcePassword || seen.User == nil || seen.User.ID != user.ID || seen.Request == nil {
t.Fatalf("attempt = %+v", seen)
}
if seen.Fields["code"] != packed {
t.Fatalf("attempt fields = %#v", seen.Fields)
}
if !env.reload(t, user.ID).IsActivated {
t.Fatal("a challenged activation left the account not activated")
}
})
t.Run("refusal_passes_through", func(t *testing.T) {
env, _ := gatedEnv(t, &classes.LoginGateResult{Status: http.StatusUnauthorized, Body: map[string]any{"error": "Invalid or expired challenge."}})
user, packed := inactive(t, env, "abc-refusal")
rec := activate(t, env, packed)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Invalid or expired challenge."}` {
t.Fatalf("refused activation = %d %s", rec.Code, rec.Body.String())
}
noSession(t, rec)
if !env.reload(t, user.ID).IsActivated {
t.Fatal("a refused activation left the account not activated")
}
})
t.Run("allowing_gate_mints_as_before", func(t *testing.T) {
env, gate := gatedEnv(t, nil)
user, packed := inactive(t, env, "abc-allow")
rec := activate(t, env, packed)
body := decodeBody(t, rec.Body.Bytes())
userBody, _ := body["user"].(map[string]any)
token, _ := body["token"].(string)
if rec.Code != http.StatusOK || token == "" || body["message"] != "Account activated" || userBody["is_activated"] != true {
t.Fatalf("allowed activation = %d %s", rec.Code, rec.Body.String())
}
if gate.count() != 1 {
t.Fatalf("gate calls = %d, want 1", gate.count())
}
if !env.reload(t, user.ID).IsActivated {
t.Fatal("an allowed activation left the account not activated")
}
})
t.Run("gate_error_fails_closed", func(t *testing.T) {
env := newAdminEnv(t)
gates, ok := env.app.Lookup[*classes.LoginGates]()
if !ok || gates == nil {
t.Fatal("the plugin's Register published no *classes.LoginGates")
}
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
return nil, errors.New("challenge store down")
}))
user, packed := inactive(t, env, "abc-error")
rec := activate(t, env, packed)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("erroring gate = %d %s", rec.Code, rec.Body.String())
}
noSession(t, rec)
if !env.reload(t, user.ID).IsActivated {
t.Fatal("an erroring gate left the account not activated")
}
// The code was spent: the account is activated, so a second try
// answers the Winter error page as before.
assertWinterErrorPage(t, activate(t, env, packed))
})
t.Run("bad_code_never_reaches_the_gate", func(t *testing.T) {
env, gate := gatedEnv(t, challenge)
user, _ := inactive(t, env, "abc-bad")
rec := activate(t, env, strconv.FormatUint(uint64(user.ID), 10)+"!nope")
if rec.Code != http.StatusUnprocessableEntity || rec.Body.String() != `{"error":"This activation link is invalid or has expired"}` {
t.Fatalf("bad code = %d %s", rec.Code, rec.Body.String())
}
if gate.count() != 0 {
t.Fatalf("the gate ran %d times for a bad code", gate.count())
}
if env.reload(t, user.ID).IsActivated {
t.Fatal("a bad code activated the account")
}
})
}

114
mail_locale_test.go Normal file
View File

@@ -0,0 +1,114 @@
package user
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/towel"
)
const mailFallbackKey = "golem15.user.mail.fallback_locale"
// TestMailFallbackLocaleConfig reads golem15.user.mail.fallback_locale from
// the plugin's config.yaml default and from overrides.
func TestMailFallbackLocaleConfig(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(mailFallbackKey); !ok || v != "pl" {
t.Fatalf("config.yaml default = %#v (set %v), want pl", v, ok)
}
if got, err := classes.MailFallbackLocale(app); got != "pl" || err != nil {
t.Fatalf("default: %q %v", got, err)
}
for _, tc := range []struct{ value, want string }{{"en", "en"}, {"EN-gb", "en"}} {
if err := app.Config.Set(mailFallbackKey, tc.value); err != nil {
t.Fatal(err)
}
if got, err := classes.MailFallbackLocale(app); got != tc.want || err != nil {
t.Errorf("%q: %q %v", tc.value, got, err)
}
}
if err := app.Config.Set(mailFallbackKey, "de"); err != nil {
t.Fatal(err)
}
if _, err := classes.MailFallbackLocale(app); err == nil || !strings.Contains(err.Error(), mailFallbackKey) {
t.Errorf("de: %v", err)
}
}
// TestMailFallbackLocaleBoot fails Boot on an unsupported fallback before
// anything is started, and leaves an unset key alone.
func TestMailFallbackLocaleBoot(t *testing.T) {
withSecret := func(t *testing.T) *backpack.App {
app := bareApp(t)
if err := app.Config.Set("golem15.user.jwt.secret", "mail-locale-boot-secret"); err != nil {
t.Fatal(err)
}
return app
}
app := withSecret(t)
if err := app.Config.Set(mailFallbackKey, "de"); err != nil {
t.Fatal(err)
}
if err := (&Plugin{}).Boot(app); err == nil || !strings.Contains(err.Error(), mailFallbackKey) {
t.Fatalf("Boot with fallback de: %v", err)
}
if err := (&Plugin{}).Boot(withSecret(t)); err != nil && strings.Contains(err.Error(), mailFallbackKey) {
t.Fatalf("Boot with the key unset: %v", err)
}
}
// TestMailLocaleFromRequest sends the reset mail through POST
// /forgot-password with the request locale the router stores when no
// translate plugin is installed: the raw Accept-Language header.
func TestMailLocaleFromRequest(t *testing.T) {
if testing.Short() {
t.Skip("Postgres (testcontainers)")
}
app, gdb := sessionApp(t, "0s")
rec, _ := publishMemoryMail(t, app)
forgot := func(t *testing.T, email, locale string) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/forgot-password", strings.NewReader(`{"email":"`+email+`"}`))
req.Host = "example.com"
req.Header.Set("Content-Type", "application/json")
if locale != "" {
req = req.WithContext(towel.WithLocale(req.Context(), locale))
}
w := httptest.NewRecorder()
controllers.ForgotPassword(app)(w, req)
if w.Code >= 300 {
t.Fatalf("forgot-password %s: %d %s", email, w.Code, w.Body.String())
}
}
insertUser(t, gdb, "locale-header@example.test", "correct-horse")
forgot(t, "locale-header@example.test", "en-GB,en;q=0.9")
assertTemplate(t, rec, "golem15.user::mail.restore-en", "locale-header@example.test")
insertUser(t, gdb, "locale-none@example.test", "correct-horse")
forgot(t, "locale-none@example.test", "")
assertTemplate(t, rec, "golem15.user::mail.restore", "locale-none@example.test")
if err := app.Config.Set(mailFallbackKey, "en"); err != nil {
t.Fatal(err)
}
insertUser(t, gdb, "locale-fallback@example.test", "correct-horse")
forgot(t, "locale-fallback@example.test", "")
assertTemplate(t, rec, "golem15.user::mail.restore-en", "locale-fallback@example.test")
saved := insertUser(t, gdb, "locale-saved@example.test", "correct-horse")
if err := gdb.Model(saved).Update("preferred_locale", "en-GB").Error; err != nil {
t.Fatal(err)
}
forgot(t, "locale-saved@example.test", "pl")
assertTemplate(t, rec, "golem15.user::mail.restore-en", "locale-saved@example.test")
}

58
mail_subjects_test.go Normal file
View File

@@ -0,0 +1,58 @@
package user
import (
"context"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/postcard"
)
// TestPolishMailSubjects renders every Polish template next to its -en twin
// and requires a subject of its own, so a Polish template with a copied
// English subject does not slip through.
func TestPolishMailSubjects(t *testing.T) {
p := &Plugin{}
cat := postcard.NewCatalog()
if err := cat.Register(p.ID(), p.MailTemplatesFS(), p.MailTemplates(), p.MailLayouts()); err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, name := range p.MailTemplates() {
names[name] = true
}
vars := map[string]any{
"name": "Ada", "Name": "Ada",
"link": "https://example.com/link", "Link": "https://example.com/link",
"code": "1!abc", "Code": "1!abc",
"hours": 72, "Hours": 72,
"login": "ada@example.test", "Login": "ada@example.test",
}
subject := func(t *testing.T, template string) string {
t.Helper()
mem := postcard.NewMemoryDriver()
mailer := postcard.NewMailer(cat, mem, postcard.Options{From: "noreply@example.test"})
if err := mailer.Send(context.Background(), postcard.Message{Template: template, To: []string{"ada@example.test"}, Vars: vars}); err != nil {
t.Fatalf("%s: %v", template, err)
}
msgs := mem.Messages()
if len(msgs) != 1 {
t.Fatalf("%s: %d messages", template, len(msgs))
}
return msgs[0].Subject
}
pairs := 0
for _, name := range p.MailTemplates() {
if strings.HasSuffix(name, "-en") || !names[name+"-en"] {
continue
}
pairs++
pl, en := subject(t, name), subject(t, name+"-en")
if pl == "" || pl == en {
t.Errorf("%s: Polish subject %q, English subject %q", name, pl, en)
}
}
if pairs == 0 {
t.Fatal("no Polish/English template pairs")
}
}

13
manifest_test.go Normal file
View File

@@ -0,0 +1,13 @@
package user
import (
"testing"
"git.golem15.com/golem15/summercms/modules/kiosk"
)
// TestPluginManifest fails when plugin.yaml is missing or no longer matches
// what `summer plugin:manifest` derives from this repository.
func TestPluginManifest(t *testing.T) {
kiosk.CheckT(t, ".")
}

View File

@@ -76,6 +76,9 @@ func (p *Plugin) Boot(app *backpack.App) error {
if err != nil {
return err
}
if _, err := classes.MailFallbackLocale(app); err != nil {
return err
}
// 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.

10
plugin.yaml Normal file
View File

@@ -0,0 +1,10 @@
# Generated by summer plugin:manifest. Do not edit; rerun the command.
schema: 1
id: golem15.user
module: git.golem15.com/golem15/sm-user-plugin
package: git.golem15.com/golem15/sm-user-plugin
name: User
description: "The shared front-end user plugin for SummerCMS applications: registration, login with JWT sessions, activation and password reset, profile and avatar endpoints."
framework: v0.6.0
overrides:
name: User

View File

@@ -1,4 +1,4 @@
subject = "Activate your account"
subject = "Aktywuj konto"
description = "Aktywacja konta"
layout = "user"
==

View File

@@ -1,4 +1,4 @@
subject = "Welcome back"
subject = "Witaj ponownie"
description = "Konto zostało ponownie aktywowane"
layout = "user"
==

View File

@@ -1,4 +1,4 @@
subject = "Reset your password"
subject = "Ustaw nowe hasło"
description = "Reset hasła"
layout = "user"
==