golem15.user
The shared front-end user plugin for SummerCMS applications: registration, login with JWT sessions, activation and password reset, profile and avatar endpoints.
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, User Groups and Organisations screens of the admin. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs, permission codes and config keys are a contract: change them only in a backwards-compatible way.
Features
- Registration and activation.
POST /register,POST /activate,POST /activate-by-code. Registration can be switched off and throttled;activate_modeisauto,user(a mailed code) oradmin, with codes fromIssueActivationCodeandVerifyActivationCode. - JWT sessions.
POST /login,GET /fetch,POST /refresh,POST /logout. Boot registers thejwtguard with the framework'sbouncer.Registryand exposes it as thejwt.authmiddleware for other plugins. Logged-out tokens go to the Postgresjwt_blacklist, which a background sweep prunes. - Password reset and change.
POST /forgot-passwordanswers the same body for known and unknown addresses;POST /reset-passwordconsumes a code fromIssueResetCode.POST /change-passwordrequires the current password. - Must-change-password lock. The
user:require-password-changeconsole command flags an account. The flag rides on the authenticated principal (bouncer.Principal.MustChangePassword) for guards and other plugins to act on, and a successfulPOST /change-passwordclears 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-apibucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls. - Profile.
POST /update,POST /marketing-consent,POST /avatarandPOST /avatar/remove(the avatar is stored through the framework's attachment storage). - Organisations. The
Organisationmodel and theorganisation_idandorganisation_roleuser columns, as a foreign-key target for other plugins. An organisation has a name, a slug (filled from the name bySlugifywhen it is left empty), a description, an avatar and its members: the users whoseorganisation_idpoints at it. - User groups. The
user_groupstable (seeded with theguestandregisteredgroups, as in PHP) and theusers_groupsmembership pivot, theUserGroupmodel and theUser.Groupsrelation, which is never serialized.UserGroupCodesandHasGroupCodelet other plugins check membership server-side, for example a site-admin check on the group codeadmin. No user API route reads or writes groups, and the user payload still answers"groups":[]. Membership is written in one place only: thegroupsfield of the admin user form. Apart from that, rows leaveusers_groupsonly when a user or a group is deleted for good. The admin Users list can filter by group. - Admin screens. The plugin registers the Users screen with the framework's admin (
pact.HasAdminControllers, controller IDgolem15.user.users), the port of the WinterCMSUserscontroller: a list with search, the group, registration date and activated filters and row states (deactivated, banned, not activated); a read-only preview with a status hint; and a form with name, email, a password with confirmation, an optional invitation mail, the user's groups, the organisation, the avatar and the user's frontend permissions. Bulk actions activate, deactivate, restore, ban and unban users; the preview offers activate, unban and unsuspend while they apply. Deactivating is the soft delete; the delete button and the bulk delete remove a user for good, together with the throttle rows, group memberships and attachments. A user created in the admin starts not activated. There is no impersonation and no guest account. - User Groups screen. Controller ID
golem15.user.usergroups, the port of the WinterCMSUserGroupscontroller, behindgolem15.users.access_groups: a list with the name, code, creation date and number of members, and a form with the name, the code (letters, digits, underscore and hyphen; unique), a description and the group's frontend permissions as checkboxes, stored inuser_groups.permissionsas a JSON object of code to1. A group can be deleted from its form; its memberships go with it and its members stay. The screen never changes who is in a group. - Organisations screen. Controller ID
golem15.user.organisations, the port of the WinterCMSOrganisationscontroller, behindgolem15.users.access_users: a list and a form with the name, slug, description and avatar, and on an existing organisation the Members tab. Adding a member sets that user'sorganisation_idand removing one clears it; a user is never deleted. Only users without an organisation can be added, so a user moves by being removed from one organisation and added to the other (or through the organisation field of the user form). Deleting an organisation clearsorganisation_idandorganisation_roleof its members first. - Privileged groups. The groups whose code is listed in
golem15.user.privileged_groups(defaultadmin) are privileged: an application may treat their members as site administrators. Three things therefore need the backend permissiongolem15.users.manage_privileged_groupson top of the screen's own permission, and answer 403 without it, saving nothing. First, membership: adding a privileged group to a user or removing it, on create and on update of the user form. An administrator without the permission sees those groups locked in the field and can still change the user's other groups. Second, the code: creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group. Its name, description and frontend permissions need onlygolem15.users.access_groups. Codes compare case-sensitively, and a group without a code is never privileged. Third, the member's account, which is protected against takeover: changing that user's email or password, and deleting them for good, needs the backend permissiongolem15.users.manage_privileged_groupson top ofgolem15.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 onlygolem15.users.access_users.PrivilegedGroupCodes,IsPrivilegedCode,PrivilegedGroupIDsandIsPrivilegedMemberexpose the rule to other code. - Frontend permissions.
golem15_user_frontend_permissionsis 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 inusers.permissionsand a group's inuser_groups.permissions, as a JSON object of code to1(allow) or-1(deny).MergedPermissionscombines them as PHPgetMergedPermissionsdoes: among a user's groups the most positive value wins, then the user's own value replaces the groups'.HasPermissionandUserHasPermissionanswer whether a code is held: only at exactly1, with Winter's*wildcards. Neither value appears in the user payload, whosepermissionskey staysnull. - Last seen.
users.last_seenis stamped byPOST /loginandPOST /refreshthroughTouchLastSeen, 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_tokensstores one hashed personal/OAuth credential per user for the whole application. Theuser.api_tokenguard authenticates it for any plugin;user.scope:<scope>limits delegated access anduser.permission:<code>checks the user's current group permissions.POST/GET/DELETE /tokensmanage personal tokens, anduser:token:create,user:token:listanduser:token:revokeprovide 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.*, layoutgolem15.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.
RegisterEventfires after a user row is created; itsPayloadis a copy of the register input (extra keys a client sent included,passwordandpassword_confirmationnever).GetApiArrayEventlets other plugins merge keys into the user payload. - Registration for other plugins.
RegisterUser,FireRegisterEvent,IssueTokenandAPIArrayare the stepsPOST /registerruns, 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-providerslists 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:
git submodule add git@git.golem15.com:golem15/sm-user-plugin.git plugins/golem15/user
Add it to the application's go.work:
use (
.
./plugins/golem15/user
)
Require it in the application's go.mod with a local replace:
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:
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 three screens are served by the framework's generic admin API under <admin prefix>/api/v1/golem15/user/users, .../usergroups and .../organisations; the plugin adds no admin route of its own.
| Item | Value |
|---|---|
| Admin controllers | golem15.user.users, golem15.user.usergroups and golem15.user.organisations, from controllers.AdminControllers |
| Navigation | main item user (order 555) with the side items users, usergroups and organisations, each shown to an administrator who holds its permission |
| Relation manager | members on golem15.user.organisations (link and unlink) |
| Bulk actions (Users) | activate, deactivate, restore, ban, unban, and the built-in delete |
| Record actions (Users) | 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, including a user's membership of ordinary groups, and the Organisations screen with its members. |
golem15.users.access_groups |
developer |
The User Groups screen. |
golem15.users.access_settings |
developer |
Registered for roles that already name it; no screen uses it yet. |
golem15.users.impersonate_user |
developer |
Registered for roles that already name it; impersonation is not available. |
golem15.users.manage_privileged_groups |
none | Adding a privileged group to a user or removing it; creating, re-coding or deleting a privileged group; changing the email or password of a privileged-group member, and deleting one for good. Only a superuser or a role that was granted it holds it. |
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),PermissionAccessUsersandPermissionAccessGroups, plus the registration stepsPOST /registerruns, for other plugins:RegisterUser(ctx, app, db, fields, RegisterOptions)validatesfieldsagainst the user model's rules, hashes the password withgolem15.user.password.bcrypt_costand creates the user ondb(which may be a transaction). It returns the validation errors instead of writing a response and never modifiesfields.RegisterOptions.Activatecreates the account activated;RegisterOptions.IPrecords the client IP.FireRegisterEvent(ctx, app, user, fields)firesRegisterEventwithPayloadset to a copy offieldswithoutpasswordandpassword_confirmation, and returns the listeners' error (/registerignores it). Fire it after the user row is committed.IssueToken(app, user, r)mints the session JWT as/registerand/logindo.APIArray(ctx, app, user)builds the user payload, extended byGetApiArrayEventlisteners.
classes:JWTSecret,GormUsers(thebouncer.UserProvider),LookupByEmail,UserPermissionGrants,UserGroupCodes,HasGroupCode,APITokenPrefix,MintAPIToken,HashAPITokenandRevokeAPIToken, plus the activation/reset, throttle, mail locale and event helpers.RegisterEventcarriesUserandPayload 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)andUserHasPermission(ctx, db, userID, code), the frontend permission resolver, andFrontendPermissionOptions(ctx, db), the catalogue by tab and code.PermissionManagePrivilegedGroups,PrivilegedGroupCodes(cfg),IsPrivilegedCode(codes, code),PrivilegedGroupIDs(ctx, db, codes)andIsPrivilegedMember(ctx, db, codes, userID), the privileged-group rule.ActivateUsers,DeactivateUsers,RestoreUsers,BanUsers,UnbanUsers,UnsuspendUser,ThrottleStatesandForceDeleteCleanup, the user actions the admin runs. Each takes the handle of the caller's transaction.TouchLastSeen(ctx, db, userID).
middleware:APITokenGuard,Scope,PermissionandBearerToken.models:User,Throttle,Organisation,UserGroup,UsersGroup(theusers_groupspivot),APIToken,FrontendPermissionandPermissionSet.PermissionSetreads a stored permission column tolerantly (NULL, an empty string,[], numeric strings) and writes a JSON object of integers;ParsePermissionSetandPermissionSet.Textdo the same for a column that is a plain string on its model.UserGroup.UsersCountis filled only by a query that selectsusers_count(the admin list does).Organisation.Membersis the relation behind the members manager,Organisation.AttachRelationsdeclares its avatar (OrganisationAvatarField), andSlugifyis the slug rule.User.FilterScopeanswers the admin list scopeFilterByGroup, andUser.AttachRelationsdeclares the avatar (AvatarField).updates: the gormigrate migrations, returned byupdates.All().console:RequirePasswordChangeandAPITokenCommands.
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 privileged user groups. Membership of such a group, the group's code and its delete, and the email, password and permanent delete of its members need golem15.users.manage_privileged_groups. Read on every request. An empty list protects no group. |
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(modulesbackpack,bonfire,bouncer,pact,party,surfand others). - gormigrate, GORM, golang-jwt and gocloud.dev, as pinned in
go.mod. - testcontainers-go for the Postgres-backed tests.
Testing
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.