Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b179e6b2e5 | ||
|
|
97f83cc2cf | ||
|
|
6bf219f50a |
14
README.md
14
README.md
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
117
classes/mail.go
117
classes/mail.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
2
go.mod
@@ -3,7 +3,7 @@ module git.golem15.com/golem15/sm-user-plugin
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
git.golem15.com/golem15/summercms v0.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
|
||||
|
||||
@@ -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
114
mail_locale_test.go
Normal 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
58
mail_subjects_test.go
Normal 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
13
manifest_test.go
Normal 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, ".")
|
||||
}
|
||||
@@ -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
10
plugin.yaml
Normal 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
|
||||
@@ -1,4 +1,4 @@
|
||||
subject = "Activate your account"
|
||||
subject = "Aktywuj konto"
|
||||
description = "Aktywacja konta"
|
||||
layout = "user"
|
||||
==
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
subject = "Welcome back"
|
||||
subject = "Witaj ponownie"
|
||||
description = "Konto zostało ponownie aktywowane"
|
||||
layout = "user"
|
||||
==
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
subject = "Reset your password"
|
||||
subject = "Ustaw nowe hasło"
|
||||
description = "Reset hasła"
|
||||
layout = "user"
|
||||
==
|
||||
|
||||
Reference in New Issue
Block a user