201 lines
18 KiB
Markdown
201 lines
18 KiB
Markdown
# golem15.user
|
|
|
|
The shared front-end user plugin for SummerCMS applications: registration, login with JWT sessions, activation and password reset, profile and avatar endpoints.
|
|
|
|
```go
|
|
import _ "git.golem15.com/golem15/sm-user-plugin"
|
|
```
|
|
|
|
Module `git.golem15.com/golem15/sm-user-plugin`, package `user`, plugin ID `golem15.user`.
|
|
|
|
## Overview
|
|
|
|
The plugin is a compiled SummerCMS plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`.
|
|
|
|
It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_api_tokens`, `golem15_user_organisations`, `user_groups`, `users_groups` and `golem15_user_frontend_permissions`, the JSON API under `/_user/api/v1`, and the Users screen of the admin. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs, permission codes and config keys are a contract: change them only in a backwards-compatible way.
|
|
|
|
## Features
|
|
|
|
- **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`.
|
|
- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. Logged-out tokens go to the Postgres `jwt_blacklist`, which a background sweep prunes.
|
|
- **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.
|
|
- **Must-change-password lock.** The `user:require-password-change` console command flags an account. The flag rides on the authenticated principal (`bouncer.Principal.MustChangePassword`) for guards and other plugins to act on, and a successful `POST /change-password` clears it.
|
|
- **Login throttling.** Failed logins are counted per user and IP in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time.
|
|
- **Rate limiting.** Every route runs behind the `user-api` bucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls.
|
|
- **Profile.** `POST /update`, `POST /marketing-consent`, `POST /avatar` and `POST /avatar/remove` (the avatar is stored through the framework's attachment storage).
|
|
- **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins.
|
|
- **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No user API route reads or writes groups, and the user payload still answers `"groups":[]`; the admin Users list can filter by group.
|
|
- **Admin screens.** The plugin registers the Users screen with the framework's admin (`pact.HasAdminControllers`, controller ID `golem15.user.users`), the port of the WinterCMS `Users` controller: a list with search, the group, registration date and activated filters and row states (deactivated, banned, not activated); a read-only preview with a status hint; and a form with name, email, a password with confirmation, an optional invitation mail, the organisation, the avatar and the user's frontend permissions. Bulk actions activate, deactivate, restore, ban and unban users; the preview offers activate, unban and unsuspend while they apply. Deactivating is the soft delete; the delete button and the bulk delete remove a user for good, together with the throttle rows, group memberships and attachments. A user created in the admin starts not activated. The User Groups and Organisations screens follow in the same release. There is no impersonation and no guest account.
|
|
- **Privileged group members.** A user who belongs to a group whose code is listed in `golem15.user.privileged_groups` (default `admin`) is protected against takeover: changing that user's email or password, and deleting them for good, needs the backend permission `golem15.users.manage_privileged_groups` on top of `golem15.users.access_users`. Without it the request answers 403 and nothing is saved; a bulk delete that contains such a user deletes none of the selected users. Everything else on such a user (name, organisation, avatar, frontend permissions, activate, deactivate, restore, ban, unban, unsuspend) needs only `golem15.users.access_users`. `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs` and `IsPrivilegedMember` expose the rule to other code.
|
|
- **Frontend permissions.** `golem15_user_frontend_permissions` is the catalogue of permissions a site defines for its users (rows come from the application's migrations or seeds). A user's own choices are stored in `users.permissions` and a group's in `user_groups.permissions`, as a JSON object of code to `1` (allow) or `-1` (deny). `MergedPermissions` combines them as PHP `getMergedPermissions` does: among a user's groups the most positive value wins, then the user's own value replaces the groups'. `HasPermission` and `UserHasPermission` answer whether a code is held: only at exactly `1`, with Winter's `*` wildcards. Neither value appears in the user payload, whose `permissions` key stays `null`.
|
|
- **Last seen.** `users.last_seen` is stamped by `POST /login` and `POST /refresh` through `TouchLastSeen`, at most once per five minutes per user. A failed write is logged and never fails authentication. It is shown in the admin list and is not part of any user payload.
|
|
- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:<scope>` limits delegated access and `user.permission:<code>` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created.
|
|
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
|
|
- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never). `GetApiArrayEvent` lets other plugins merge keys into the user payload.
|
|
- **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload.
|
|
- **OAuth providers.** `GET /oauth-providers` lists configured social login providers (none yet: it returns an empty list).
|
|
|
|
## Usage
|
|
|
|
Mount the plugin in an application as a git submodule at `plugins/golem15/user`:
|
|
|
|
```sh
|
|
git submodule add git@git.golem15.com:golem15/sm-user-plugin.git plugins/golem15/user
|
|
```
|
|
|
|
Add it to the application's `go.work`:
|
|
|
|
```text
|
|
use (
|
|
.
|
|
./plugins/golem15/user
|
|
)
|
|
```
|
|
|
|
Require it in the application's `go.mod` with a local replace:
|
|
|
|
```text
|
|
require git.golem15.com/golem15/sm-user-plugin v0.0.0
|
|
|
|
replace git.golem15.com/golem15/sm-user-plugin => ./plugins/golem15/user
|
|
```
|
|
|
|
List it in `summer.yaml` and run `summer build`:
|
|
|
|
```yaml
|
|
module: git.golem15.com/golem15/acme
|
|
binary: acme
|
|
plugins:
|
|
- id: golem15.user
|
|
module: git.golem15.com/golem15/sm-user-plugin
|
|
```
|
|
|
|
The plugin's own `go.mod` replaces the framework with `../../../../summercms.go`, so it expects the framework checkout next to the application checkout (`<dir>/summercms.go` and `<dir>/acme/plugins/golem15/user`).
|
|
|
|
Because the plugin registers an admin screen, an application that loads it mounts the framework's admin API and must set `admin.jwt.secret` (`SUMMER_ADMIN__JWT__SECRET`); start-up fails while it is empty. The admin screens need framework release v0.1.3 or later.
|
|
|
|
Another plugin that needs the signed-in user puts its routes behind `jwt.auth`. A machine API uses `user.api_token`, optionally followed by `user.scope:read`, `user.scope:write`, or `user.permission:vendor.plugin.code`. Both guards resolve the same user and current group permissions. Import `git.golem15.com/golem15/sm-user-plugin/models`, `.../classes`, or `.../middleware` for the shared types and helpers.
|
|
|
|
## API reference
|
|
|
|
All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middleware.
|
|
|
|
| Method and path | Handler |
|
|
|-----------------|---------|
|
|
| `POST /login` | `controllers.Login` |
|
|
| `POST /logout` | `controllers.Logout` |
|
|
| `GET /fetch` | `controllers.Fetch` |
|
|
| `POST /refresh` | `controllers.Refresh` |
|
|
| `POST /register` | `controllers.Register` |
|
|
| `GET /oauth-providers` | `controllers.OAuthProviders` |
|
|
| `POST /forgot-password` | `controllers.ForgotPassword` |
|
|
| `POST /reset-password` | `controllers.ResetPassword` |
|
|
| `POST /activate` | `controllers.Activate` |
|
|
| `POST /activate-by-code` | `controllers.ActivateByCode` |
|
|
| `POST /update` | `controllers.Update` |
|
|
| `POST /change-password` | `controllers.ChangePassword` |
|
|
| `POST /marketing-consent` | `controllers.MarketingConsent` |
|
|
| `POST /avatar` | `controllers.UploadAvatar` |
|
|
| `POST /avatar/remove` | `controllers.RemoveAvatar` |
|
|
| `POST /tokens` | `controllers.APITokenStore` (`jwt.auth`) |
|
|
| `GET /tokens` | `controllers.APITokenIndex` (`jwt.auth`) |
|
|
| `DELETE /tokens/{id}` | `controllers.APITokenDestroy` (`jwt.auth`) |
|
|
|
|
### Admin
|
|
|
|
The Users screen is served by the framework's generic admin API under `<admin prefix>/api/v1/golem15/user/users`; the plugin adds no admin route of its own.
|
|
|
|
| Item | Value |
|
|
|------|-------|
|
|
| Admin controller | `golem15.user.users`, from `controllers.AdminControllers` |
|
|
| Navigation | main item `user` (order 555) with the side item `users` |
|
|
| Bulk actions | `activate`, `deactivate`, `restore`, `ban`, `unban`, and the built-in delete |
|
|
| Record actions | `activate`, `unban`, `unsuspend` |
|
|
|
|
Backend permissions, all on the tab `golem15.user::lang.plugin.tab`:
|
|
|
|
| Code | Default role | Grants |
|
|
|------|--------------|--------|
|
|
| `golem15.users.access_users` | `developer` | The Users screen and every action on it. |
|
|
| `golem15.users.access_groups` | `developer` | The User Groups screen, which follows in the same release. |
|
|
| `golem15.users.access_settings` | `developer` | Registered for roles that already name it; no screen uses it yet. |
|
|
| `golem15.users.impersonate_user` | `developer` | Registered for roles that already name it; impersonation is not available. |
|
|
| `golem15.users.manage_privileged_groups` | none | Changing the email or password of a privileged-group member, and deleting one for good. Only a superuser or a role that was granted it holds it. |
|
|
|
|
Migrations added for the admin, all additive:
|
|
|
|
| ID | Adds |
|
|
|----|------|
|
|
| `202610040001_add_users_permissions` | `users.permissions` (text, nullable) |
|
|
| `202610040002_add_users_last_seen` | `users.last_seen` (timestamp, nullable) |
|
|
| `202610040003_create_frontend_permissions` | the table `golem15_user_frontend_permissions` (`code` unique, `label`, `tab`, `comment`) |
|
|
|
|
Packages:
|
|
|
|
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list) and `PermissionAccessUsers`, plus the registration steps `POST /register` runs, for other plugins:
|
|
- `RegisterUser(ctx, app, db, fields, RegisterOptions)` validates `fields` against the user model's rules, hashes the password with `golem15.user.password.bcrypt_cost` and creates the user on `db` (which may be a transaction). It returns the validation errors instead of writing a response and never modifies `fields`. `RegisterOptions.Activate` creates the account activated; `RegisterOptions.IP` records the client IP.
|
|
- `FireRegisterEvent(ctx, app, user, fields)` fires `RegisterEvent` with `Payload` set to a copy of `fields` without `password` and `password_confirmation`, and returns the listeners' error (`/register` ignores it). Fire it after the user row is committed.
|
|
- `IssueToken(app, user, r)` mints the session JWT as `/register` and `/login` do.
|
|
- `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners.
|
|
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `HashAPIToken` and `RevokeAPIToken`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
|
|
- `MergedPermissions(ctx, db, userID)`, `HasPermission(merged, code)` and `UserHasPermission(ctx, db, userID, code)`, the frontend permission resolver, and `FrontendPermissionOptions(ctx, db)`, the catalogue by tab and code.
|
|
- `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule.
|
|
- `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction.
|
|
- `TouchLastSeen(ctx, db, userID)`.
|
|
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
|
|
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `APIToken`, `FrontendPermission` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`).
|
|
- `updates`: the gormigrate migrations, returned by `updates.All()`.
|
|
- `console`: `RequirePasswordChange` and `APITokenCommands`.
|
|
|
|
## Configuration
|
|
|
|
Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any key can be overridden by an environment variable in the framework's `SUMMER_` form.
|
|
|
|
| Key | Default | Meaning |
|
|
|-----|---------|---------|
|
|
| `golem15.user.jwt.secret` | `""` | HS256 signing secret. Required: boot fails while it is empty. Set it with `SUMMER_GOLEM15__USER__JWT__SECRET`; there is no production default. |
|
|
| `golem15.user.jwt.ttl` | `60` | Access token lifetime in minutes. |
|
|
| `golem15.user.jwt.refresh_ttl` | `20160` | Refresh window in minutes. |
|
|
| `golem15.user.jwt.blacklist_grace` | `0` | Grace period in seconds for a blacklisted token. |
|
|
| `golem15.user.jwt.leeway` | `0` | Clock leeway in seconds (declared for parity, not read yet). |
|
|
| `golem15.user.jwt.blacklist_sweep_interval` | `10m` | How often expired blacklist rows are deleted (a Go duration). |
|
|
| `golem15.user.activation.require_activation` | `true` | Whether a new account must be activated before login. |
|
|
| `golem15.user.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.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. |
|
|
| `golem15.user.throttle.suspension_minutes` | `15` | Suspension length. |
|
|
| `golem15.user.throttle.use_throttle` | `true` | Whether login throttling is on. |
|
|
| `golem15.user.password.bcrypt_cost` | `10` | bcrypt cost for new password hashes. |
|
|
| `golem15.user.password.min_length` | `8` | Minimum password length. |
|
|
| `golem15.user.api_tokens.prefix` | `usr_` | Prefix for newly minted and accepted shared API-token secrets. Override an existing application's legacy prefix without creating a plugin-specific token table. |
|
|
| `golem15.user.privileged_groups` | `[admin]` | Codes of the user groups whose members are protected: their email, password and permanent delete need `golem15.users.manage_privileged_groups`. Read on every request. An empty list protects no group. |
|
|
|
|
## CLI commands
|
|
|
|
| Command | Purpose |
|
|
|---------|---------|
|
|
| `user:require-password-change <email>` | Flag an account so its next session must set a new password. |
|
|
| `user:token:create <email> [--name api] [--scopes read,write] [--expires RFC3339]` | Create an application-wide API token and print its secret once. |
|
|
| `user:token:list` | List token metadata without secrets or hashes. |
|
|
| `user:token:revoke <id>` | Revoke a shared API token immediately. |
|
|
|
|
## Dependencies
|
|
|
|
- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `pact`, `party`, `surf` and others).
|
|
- gormigrate, GORM, golang-jwt and gocloud.dev, as pinned in `go.mod`.
|
|
- testcontainers-go for the Postgres-backed tests.
|
|
|
|
## Testing
|
|
|
|
```sh
|
|
go test -short ./... # fast run, no Docker
|
|
go test ./... # full run, starts Postgres with testcontainers (needs Docker)
|
|
```
|
|
|
|
`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 admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`.
|