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_oauth_identities, golem15_user_organisations, user_groups, users_groups, golem15_user_frontend_permissions, golem15_user_backend_links and golem15_user_backend_link_requests, 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. A/registerbody sets only theUser.RegisterFillablecolumns:name,surname,email,password(stored as its hash),preferred_localeandmarketing_consent. It cannot setorganisation_idororganisation_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 reachRegisterEvent.Payload).created_ip_addressandlast_ip_addressare server-set from the client address only. Withrequire_activationon (the default),POST /loginrefuses 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-codeanswers with a token), by the invitation link or by an administrator.POST /activateserves the sessions of not-activated accounts, which exist whenrequire_activationis off.Loginchecks 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 thejwtguard with the framework'sbouncer.Registryand exposes it as thejwt.authmiddleware for other plugins. The guard reads the session cookiesclasses.SessionCookieNames()lists and resolves the publishedbouncer.BlacklistStoreon every request. Boot installs the plugin's GORM hooks (classes.RegisterHooks), publishes the Postgresjwt_blackliststore (bouncer.NewPostgresBlacklist) and starts its background sweep, which prunes the blacklist, throughlagoon.OnDatabase. Undersummer serve, which publishes the database after Boot, they take effect whenlagoon.Publishruns, 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 /logoutends the session however the token travels: the bearer header, or only thetokenorauth_tokencookie the guard also reads. Every session token the request carries goes to the Postgresjwt_blacklistuntil its refresh window closes (the later ofexpandiatplusrefresh_ttl, plus a minute), so the guard refuses it on the next request andPOST /refreshcannot 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 theauth.cookie.*token cookie (the onegolem15.sociallogin'stoken_cookiesets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure.POST /refreshloads 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'stokens_valid_after; a suspension alone does not stop a refresh.GET /fetch,POST /refresh,POST /updateandPOST /change-password, like the plugin's other signed-in routes, read the session token as thejwtguard does: the bearer header first, then thetokenandauth_tokencookies, so a cookie-only session works on them and a bearer header wins when both are sent. APOST /refreshwhose token came from theauth.cookie.*cookie (auth.cookie.name, defaulttoken) with no bearer header gets that cookie back with the rotated token: the cookie has thebouncer.LoadCookieConfigattributes, HttpOnly and Max-Agerefresh_ttl, and the answer is{}, so the token never reaches JavaScript. This holds whoever wrote the cookie (this plugin'sgolem15.user.jwt.token_cookie,golem15.sociallogin'stoken_cookieor the application); a frontend that writes thetokencookie 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 aftergolem15.user.jwt.blacklist_grace, so that caller stores the new token itself. A refused refresh writes and forgets no cookie. Whenbouncer.LoadCookieConfigrefuses theauth.cookieblock, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block. Keepauth.cookie.nameattokenorauth_token, the cookies the guard reads. Withgolem15.user.jwt.token_cookieon,POST /login,POST /register(when it signs the new account in) andPOST /activate-by-codeset that HttpOnly session cookie (bouncer.LoadCookieConfigattributes, Max-Agerefresh_ttl) and leavetokenout of the body;user, andmessageon activate-by-code, stay. With it off (the default) the token is in the body and no cookie is set. SameSite stays theauth.cookiesetting (laxby default) and there is no CSRF token, so do not setsame_site: nonefor this cookie. With the key on, a brokenauth.cookieblock 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 (theauth.cookiedefault islax), as on thejwt.authroutes. - 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. Withgolem15.user.password.public_routesfalse,POST /login,POST /forgot-passwordandPOST /reset-passwordare not registered, so they answer the router's own 404 like any unknown path (any method, so no 405 shows they exist), while thecontrollershandlers stay exported. - 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 client address in
user_throttle(CheckAndRecordLogin,RejectIfThrottled) and suspend the account for a configured time. The client address issurf.ClientIPwith the framework'shttp.trusted_proxies, as for the/registerIP limit and theuser-apirate limit: behind a listed reverse proxy it is theX-Forwarded-Forclient, otherwise the connection address, and anX-Forwarded-Forheader from any other peer is ignored. A deployment behind a proxy lists the proxy's address inhttp.trusted_proxies; otherwise every visitor shares the proxy's counter. - 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). - Deactivation and bans. Deactivation is self-service and reversible: a password login or a
golem15.socialloginsign-in restores a deactivated account and sends the reactivate mail, and an administrator can restore it too. It ends every earlier session JWT and personal API token of the user (RevokeUserAccessstampstokens_valid_afterand revokes theuser_api_tokensrows), so the user signs in again and gets a fresh token, while the old API tokens stay revoked. A ban is the lock: password login, social sign-in andPOST /refreshrefuse a banned user, and banning revokes the user's sessions and API tokens. Neither unban nor restore revives a token. Activation gates login, deactivation does not: whilerequire_activationis on, an account that is both deactivated and not activated is refused and stays deactivated, with no reactivate mail. Upgrade note: accounts deactivated or banned before this version keep their API tokens until they are deactivated or banned again (both actions are idempotent) orRevokeUserAccessruns for them. - 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.organisation_rolebelongs to that membership: the admin clears it whenever a user's organisation changes (see the Organisations screen), and an application that setsorganisation_iditself sets or clears the role with 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, and deactivate and ban both end the user's earlier sessions and personal API tokens (see Deactivation and bans); the delete button and the bulk delete remove a user for good, together with the throttle rows, group memberships and attachments. A user created in the admin starts not activated. There is no impersonation and no guest account. - 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; both also clear the user'sorganisation_role, so a role never moves to another organisation or outlives the membership. 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, where a save that changes the organisation, to another one or to none, clearsorganisation_roleunless the same save writes it). 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 /loginand a successfulPOST /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. - Address proof.
users.email_verified_atrecords when the account's owner proved control of the address (User.EmailVerifiedAt). Exactly two flows write it: the mailed activation code (POST /activate,POST /activate-by-codeand the invitation link, throughVerifyActivationCode) and a completed reset-code password reset (POST /reset-password).POST /registerinautooradminmode,RegisterUser, an administrator's create and the admin Activate action,POST /change-passwordand social sign-ins record none. The proof follows the address: an email change throughPOST /updateor the admin Users form clears it, while a change of letter case only keeps it. The column is never mass-assigned and is in no user API payload, admin form or list. No migration or code path backfills it: accounts from before v0.1.5 have no proof. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not. A social-login plugin links a verified provider's identity onto an existing account only when it is set. - Handle.
users.handleis a unique nullableVARCHAR(255)generated on create (classes.HandleGenerator, charset[a-z0-9._-], length 2–255). It is omitted from the user JSON payload (json:"-").user:backfill-handlesfills existing rows that still have none. - User lighthouse authorizer. Boot calls
classes.RegisterUserAuthorizer:lighthouse.FromthenRegistry.Register("user", …)if the namespace is free.classes.AuthorizeUserallows onlyuser:{id}for that user (lighthouse.ChannelIDon the two-segment name). - 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), so it is untrusted client input.CreatedEvent(classes.CreatedEventNameuser.Created) fires from API register and admin create./registerlogs a failingRegisterEventorCreatedEventlistener at Error level and continues: a committed registration is never undone or failed by a listener.DeletingEvent(classes.DeletingEventNameuser.Deleting) fires fromFormAfterDeletebeforeForceDeleteCleanup/ unscoped delete, inside the same transaction.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 the social sign-in providers a social-login plugin has enabled, read on every request from its publishedclasses.OAuthProviderSource. Without one it answers the empty list, as before. - Frontend account link for administrators. A backend administrator's account can be linked to one frontend user, and a frontend user to one administrator (
golem15_user_backend_links, unique on both sides). The administrator starts it on My account (sectionfrontend_account, actionlink): the plugin stores a one-shot request (32 random bytes as hex, kept only as its SHA-256, validgolem15.user.backend_links.request_ttl) and answers the site URLgolem15.user.frontend_url(elseapp.url) +/link-admin?code=<code>, which the admin opens in a new tab. The site page readsGET /backend-link/{code}(the administrator's login and display name and the expiry, nothing else), has the person sign in on the site as the frontend user, and posts the code toPOST /backend-link/confirmbehindjwt.auth. The link is written only there, in one transaction with the request row locked, and only when the session token was issued at or after the request was created (compared in whole seconds), so a browser that was already signed in must sign in again; password, social login and any login gate apply as usual. A code works once; a used, expired or unknown code answers 404. No link is ever made by matching emails. The administrator removes it on My account (unlink), and an administrator holdinggolem15.users.manage_backend_linkslinks or unlinks another administrator on the Administrators screen (see Admin). Deleting or deactivating the frontend user removes the link in the same transaction; deleting an administrator removes it through the foreign key, and a deactivated or soft-deleted administrator is not answered by the resolver. Boot publishes*classes.BackendLinksand the same value aspact.MCPLinkedBackendResolver(LinkedBackendUser, one query per call), which the framework's MCP endpoint uses to give a frontend user the linked administrator's backend tools.BackendLinkedEventandBackendUnlinkedEventfire inside the write transaction. - Frontend permission codes from plugins. Another plugin declares the frontend permission codes its routes check (
user.permission:<code>) withpact.HasFrontendPermissions. The Users and User Groups permission editors offer the rows ofgolem15_user_frontend_permissionsfirst, unchanged, then the declared codes of the activated plugins in activation order (classes.FrontendPermissionCatalog); a seeded row wins over a declared code with the same code, and the first declaring plugin wins over a later one. Nothing is written to the table; the stored permissions keep their shape. - MCP guard and the
user_metool. Boot registersclasses.MCPUserGuardas the MCP guard provideruserin the application's*pact.MCPGuardsand publishes it aspact.MCPPersonalTokenResolver, so the framework's MCP endpoint (built with-tags summercms_mcp) can bind tools to routes behinduser.api_token,user.scope:<scope>anduser.permission:<code>and accept the plugin's personal API tokens (thegolem15.user.api_tokens.prefix). The guard decides with the routes' own checks: every scope of the route must be granted to the MCP session, and every permission code must be granted by one of the user's groups, asuser.permissionchecks it (a user's own permission override does not grant). A tool call runs the route with the user and an in-memory token credential carrying the session's scopes (ID 0, never saved). The plugin's one tool,me(exposed asuser_me), runsGET /token/meand answers the signed-in user's payload. Revoking a personal token, saving a user on the Users screen, saving or deleting a group and the link changes firepact.MCPSubjectChangedafter they commit, so open MCP sessions re-list their tools or end. - Sign-in helpers for social login.
RegisterOptions.PasswordLess,FinishLogin,RegistrationSettings,RegistrationsFromIP,RefreshTTLandRemoteIPlet a social-login plugin create accounts and finish logins with the user plugin's own rules instead of copies.
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) |
GET /backend-link/{code} |
controllers.BackendLinkShow |
POST /backend-link/confirm |
controllers.BackendLinkConfirm (jwt.auth) |
GET /token/me |
controllers.TokenMe (user.api_token, user.scope:read) |
/fetch, /refresh, /update, /change-password, /activate, /marketing-consent, /avatar, /avatar/remove and the jwt.auth routes read the session token from the bearer header, then the token and auth_token cookies.
With golem15.user.password.public_routes false the /login, /forgot-password and /reset-password rows are not registered.
GET /backend-link/{code} (code is 64 lower-case hex characters) answers {"data":{"admin":{"login":…,"name":…},"expires_at":"<RFC 3339>"}} for a usable request and 404 {"error":"not_found"} for an unknown, used or expired one or when the administrator was deactivated since. POST /backend-link/confirm takes {"code":…} from the signed-in frontend user and answers 200 {"data":{"backend_user":{"login":…,"name":…}}}; 409 {"error":"stale_session"} when the session token was issued before the request (sign in again), 409 {"error":"already_linked"} when the user or the administrator is already linked, and 404 {"error":"not_found"} as the show route. GET /token/me answers the {"user": …} body of /fetch to a personal API token with the read scope (401 {"error":true,"message":"Invalid token."} from the guard, 403 Missing required scope: read from the scope check); it is the target of the MCP tool user_me.
Host-mounted OAuth identities
The host application mounts these constructors on a path it chooses. They are not registered under /_user/api/v1. OAuthIdentitiesOptions.WriteNotFound is the host's 404 writer so missing, foreign and unknown providers share one body. OAuthIdentitiesOptions.Providers is the unlink allow-list; when empty it is read on every request from a published classes.OAuthProviderSource (its KnownOAuthProviders), else it is google, facebook, github and microsoft. Unlinking follows the PHP rule: it succeeds when the user has a self-set password (has_self_set_password) or another identity remains, and otherwise answers 409 with the phrase golem15.user::lang.oauth.last_method_blocked (body unchanged). The guarded delete runs in a transaction that first locks the user's users row, so two concurrent unlinks never remove the last sign-in method.
| Constructor | Role |
|---|---|
controllers.OAuthIdentitiesIndex |
Lists the caller's linked providers as {provider, linked_at} only. |
controllers.OAuthIdentitiesDestroy |
Unlinks one provider for the caller (204), or answers 409 when it is the last sign-in method of a user without a self-set password. |
controllers.OAuthIdentitiesOptions |
Host injects Providers and WriteNotFound. |
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; deactivate and ban also revoke the selected users' sessions and personal API tokens |
| Record actions (Users) | activate, unban, unsuspend |
Record actions (Administrators, summercms.cabana.users) |
link_frontend_account (asks for the frontend user's email) and unlink_frontend_account (danger), both behind golem15.users.manage_backend_links, from AdminActionExtensions |
| My account section | frontend_account with the actions link and unlink, from AccountContributions |
On the Administrators screen, link_frontend_account links the activated, not deactivated frontend user whose email equals the typed value (letter case and surrounding spaces ignored; no search and no list) to the administrator shown, and records the caller in linked_by. An unknown email or an account that is already linked answers 422 on the input. It is offered only while the administrator has no link, and unlink_frontend_account only while it has one. The admin's own rule that an administrator cannot act on one whose grants exceed theirs runs first. These two actions skip the frontend user's sign-in proof, which is why their permission has no default role; the My account path always requires it.
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. |
golem15.users.manage_backend_links |
none | Linking a frontend account to another administrator, and unlinking one, on the Administrators screen. 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) |
Migrations added for linked OAuth identities, all additive:
| ID | Adds |
|---|---|
202610050001_create_oauth_identities |
the table golem15_user_oauth_identities (user_id cascade FK to users, unique (user_id, provider) and (provider, provider_id), jsonb profile_data) |
Migrations added for @mention handles, all additive:
| ID | Adds |
|---|---|
202610160002_add_handle_to_users |
users.handle (VARCHAR(255) NULL, unique index users_handle_unique, ADD COLUMN IF NOT EXISTS; rollback is a no-op) |
Migrations added for address proof, all additive:
| ID | Adds |
|---|---|
202610210001_add_users_email_verified_at |
users.email_verified_at (TIMESTAMPTZ NULL, ADD COLUMN IF NOT EXISTS, no backfill; rollback is a no-op) |
Migrations added for the frontend account link, all additive:
| ID | Adds |
|---|---|
202610220001_create_golem15_user_backend_links |
the tables golem15_user_backend_links (user_id unique, cascade FK to users; backend_user_id unique, cascade FK to backend_users; linked_by FK to backend_users, set null on delete; created_at) and golem15_user_backend_link_requests (code_hash CHAR(64) unique, backend_user_id cascade FK, expires_at, used_at, created_at). The backend_users keys are added when that table exists, which it does whenever the application migrates through lagoon.Migrate. |
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 fills only theUser.RegisterFillablecolumns and ignores every other key offields: an organisation membership and role are set with an explicit update after creation, as an application's onboarding does. It returns the validation errors instead of writing a response and never modifiesfields.RegisterOptions.Activatecreates the account activated;RegisterOptions.IPrecords the client IP and is the only source ofcreated_ip_addressandlast_ip_address(both stay NULL without it, whateverfieldssays);RegisterOptions.PasswordLessstoreshas_self_set_password=falsefor an account whose random password the user never knew (a social sign-in), while the zero value keepstrue.FireRegisterEvent(ctx, app, user, fields)firesRegisterEventwithPayloadset to a copy offieldswithoutpasswordandpassword_confirmation, and returns the listeners' error (/registerlogs it and continues).Payloadcarries every extra key the client sent, so treat it as untrusted. Fire it after the user row is committed.FireCreatedEvent(ctx, app, user)firesCreatedEvent(user.Created) and returns the listeners' error;/registerlogs it and continues.FireDeletingEvent(ctx, app, user, tx)firesDeletingEvent(user.Deleting) inside the delete transaction.FireDeactivatingEvent(ctx, app, user, tx)firesDeactivatingEvent(user.Deactivating) inside the deactivation transaction. The admin deactivate bulk action fires it afterDeactivateUsers(so the user's sessions and API tokens are already revoked) for every selected user that was not deactivated yet; a listener error rolls the action back.IssueToken(app, user, r)mints the session JWT as/registerand/logindo.BackendLinkShow,BackendLinkConfirmandTokenMe, the handlers of the account link and/token/meroutes.APIArray(ctx, app, user)builds the user payload, extended byGetApiArrayEventlisteners.FinishLogin(ctx, app, db, user, ip)runs the stepsPOST /logintakes 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, recordsipaslast_ip_addressand toucheslast_seen(a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks.RegistrationSettings(app)returnsallow_registration,use_register_throttle,activate_modeandrequire_activationwith their defaults (true,true,auto,true), andRegistrationsFromIP(ctx, db, ip)counts the accounts created from an IP in the last 60 minutes (/registerrefuses at 3 with the throttle on).RefreshTTL(app)is the refresh window,golem15.user.jwt.refresh_ttlminutes (default 20160), andRemoteIP(r)the host part ofr.RemoteAddr, with no proxy handling (/loginand/registerusesurf.ClientIPwithhttp.trusted_proxies).
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.RevokeUserAccess(ctx, db, userIDs)ends every session JWT issued before the current second (it stampsusers.tokens_valid_after, deactivated rows included) and revokes every personal API token of the users.DeactivateUsersandBanUserscall it; an application may call it for its own offboarding and after writing a ban directly touser_throttle.TouchLastSeen(ctx, db, userID).OAuthProvider(Name,Label) andOAuthProviderSource(EnabledOAuthProviders(ctx),KnownOAuthProviders()), the seam a social-login plugin publishes with an explicit type argument,app.Publish[classes.OAuthProviderSource](src), so the lookup finds it.GET /oauth-providerslists its enabled providers in order; the unlink routes accept its known names.EndSession(ctx, app, w, r)is whatPOST /logoutruns: it blacklists every session JWT the request carries (bearer header,tokenandauth_tokencookies) that verifies, an expired one too while its refresh window is open, until that window closes, writes the forget cookies described under JWT sessions, and returns the subject of the token the guard would read (""when there is none or it does not verify); only a failing blacklist write is an error.SessionToken(r), the session JWT thejwtguard reads from a request (bearer header, then thetokenandauth_tokencookies;""when there is none);SessionTokenCookie(r), the name of the session cookie that supplied that token (""when the bearer header did or there is none), whichPOST /refreshuses to rewrite theauth.cookiecookie; andSessionCookieNames(), a copy of that cookie list to pass tobouncer.NewJWTGuard, so a handler's own guard reads the same token.RequestUser(ctx, app, r)andEndRequestSession(ctx, app, w, r), for handlers that serve signed-in and anonymous browsers on one route (an OAuth provider's authorize page).RequestUserreads and checks the session JWT exactly as thejwtguard does (bearer header, then thetokenandauth_tokencookies, the blacklist,tokens_valid_after) and returns the user, ornilfor an anonymous, invalid, expired or blacklisted request and for a soft-deleted user; only an infrastructure failure is an error.EndRequestSessionis the best-effortEndSession: it revokes and forgets the same way (the forget cookie ofbouncer.LoadCookieConfig), but a blacklist failure is logged, never returned.AvatarURL(ctx, app, user, size), the display picture rule of the user payload at any size: the avatar thumbnail (size by size, mode auto) when the user has an avatar and a file bucket is published, the file path when the thumbnail fails, otherwise the gravatar URL. The URL may be protocol-relative or relative. The payload'savatar_urlcomes from it at 128.BackendLinks(CreateLinkRequest,LinkRequestByCode,ConfirmLinkRequest,SetLink,RemoveLink,LinkFor,LinkOf,LinkedBackendUser,RegisterBackendLinkListeners), its errorsErrLinkRequestNotFound,ErrLinkRequestUsed,ErrLinkRequestExpired,ErrStaleSession,ErrAlreadyLinked,ErrBackendUserUnavailableandErrFrontendUserUnavailable, the eventsBackendLinkedEvent/BackendLinkedEventNameandBackendUnlinkedEvent/BackendUnlinkedEventName,BackendLinkRequestTTL(app),DefaultBackendLinkRequestTTL,FrontendURL(app)andBackendDisplayName(admin).PermissionManageBackendLinks,FrontendPermissionCatalog(ctx, app, db),MCPUserGuard,FireSubjectChanged(ctx, app, guard, userID, kind)andFireSubjectChangedAfterCommit(ctx, app, tx, guard, userID, kind)with the guard namesSubjectGuardUserandSubjectGuardBackend.HandleGenerator,AssignHandle(ctx, db, user),CreatedEvent/CreatedEventName,DeletingEvent/DeletingEventName,DeactivatingEvent/DeactivatingEventName,AuthorizeUserandRegisterUserAuthorizer.
middleware:APITokenGuard,Scope,PermissionandBearerToken.models:User,Throttle,Organisation,UserGroup,UsersGroup(theusers_groupspivot),APIToken,OAuthIdentity,FrontendPermission,BackendLink,BackendLinkRequestandPermissionSet.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).User.RegisterFillableis the registration allow-list;User.Fillableis the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code.updates: the gormigrate migrations, returned byupdates.All().console:RequirePasswordChange,BackfillHandlesandAPITokenCommands.
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.jwt.token_cookie |
false |
When on, POST /login, POST /register (when it signs the new account in) and POST /activate-by-code set the HttpOnly session cookie with the auth.cookie.* attributes (bouncer.LoadCookieConfig; Max-Age refresh_ttl) and leave token out of the body (user, and message on activate-by-code, stay). When off, the token is in the body and no cookie is set. The twin of golem15.sociallogin.token_cookie. |
golem15.user.activation.require_activation |
true |
Whether an account must be activated before login. When true, POST /login refuses a not-activated account (the opaque invalid-login answer, counted as a failed attempt) and /register in admin mode returns no token; when false, both sign such an account in. |
golem15.user.activation.activate_mode |
auto |
auto, user (mailed code) or admin. |
golem15.user.activation.reset_ttl_minutes |
60 |
Password reset code lifetime. |
golem15.user.activation.activation_ttl_hours |
72 |
Activation code lifetime. |
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.password.public_routes |
true |
When false, POST /login, POST /forgot-password and POST /reset-password are not registered and answer 404 like any unknown path; POST /register (still gated by allow_registration and guarded by golem15.user.turnstile) and every other route stay. It is read when the routes are built, so a change needs a restart. It takes true or false, also as a string (SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false); an empty value means true; any other value fails boot. |
golem15.user.api_tokens.prefix |
usr_ |
Prefix for newly minted and accepted shared API-token secrets. Override an existing application's legacy prefix without creating a plugin-specific token table. |
golem15.user.privileged_groups |
[admin] |
Codes of the privileged user groups. Membership of such a group, the group's code and its delete, and the email, password and permanent delete of its members need golem15.users.manage_privileged_groups. Read on every request. An empty list protects no group. |
golem15.user.frontend_url |
"" |
The site the plugin's own links point to: the account link page is this URL + /link-admin?code=<code>. Empty falls back to app.url; a trailing slash is dropped. |
golem15.user.backend_links.request_ttl |
10m |
How long a link request an administrator starts on My account stays usable (a Go duration). A value that does not parse or is not positive fails boot. |
golem15.user.turnstile |
"" |
Cloudflare Turnstile on POST /login and POST /register (only /register while golem15.user.password.public_routes is false). Empty adds nothing; open or closed appends the framework's turnstile:open or turnstile:closed middleware to those two routes only (summercms v0.3.0 and later; secret in turnstile.secret, token in the turnstile_token field). open lets requests pass when Cloudflare is unreachable, closed refuses them. Any other value fails boot. |
CLI commands
| Command | Purpose | Example |
|---|---|---|
user:require-password-change <email> |
Flag an account so its next session must set a new password. Ends with Password change required for <email>. |
bin/<app> user:require-password-change jane@example.com |
user:backfill-handles |
Assign a unique handle to every user row that still has none, with a progress bar. Ends with Assigned handles to N user(s). |
bin/<app> user:backfill-handles |
user:token:create <email> [--name api] [--scopes read,write] [--expires RFC3339] [--json] |
Create an application-wide API token and print its secret once (also under -q); --json prints the token and its details as one object. |
bin/<app> user:token:create jane@example.com --scopes read --json |
user:token:list |
List token metadata without secrets or hashes; --json prints a JSON array with the expiry, revocation and last-use times. |
bin/<app> user:token:list --json |
user:token:revoke <id> |
Revoke a shared API token immediately. | bin/<app> user:token:revoke 12 |
Each command uses the database the serve command published or, run from the console, opens one from database.dsn (and loads the encryption keys from app.key) for its duration. bin/<app> help <command> shows each command's long help and examples. The plugin logs through the component user (log.components.user in config/log.yaml).
Dependencies
- The SummerCMS framework v0.4.3,
git.golem15.com/golem15/summercms(modulesbackpack,bonfire,bouncer,diary,pact,party,surfand others). - The organisation role cleanup of the admin (members manager and user form) uses cabana's
ClearOnChange, added in SummerCMS v0.4.3. - 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.
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.
TestOffboarding runs the admin deactivate and ban bulk actions and checks that the user's older session JWT and personal API token stop working, that a password login restores a deactivated account with a working token while the old credentials stay dead, and that unban revives nothing; TestRefreshSubjectChecks covers the /refresh subject checks; TestRevokeUserAccess (in classes) covers the revocation writes.
TestLogoutEndsTheSession signs in through POST /login and logs out with the bearer header, with only the token or auth_token cookie, and with both a bearer and a different cookie token, then checks that the token is refused on a jwt.auth route, at /fetch and at /refresh, each through every carrier (bearer header, token cookie, auth_token cookie), that the forget cookies match the token cookie's attributes (also with a configured domain and SameSite), that the blacklist row lasts for the refresh window so /refresh stays refused after the sweep that follows the access token's expiry, that an expired but refreshable token is revoked, and which tokens a logout refuses with 401.
TestBootBeforeDatabase boots in the serve order (party.Activate with no database, then lagoon.Publish, through newServeOrderEnv in admin_harness_test.go). It checks that the blacklist store and the GORM hooks are installed on Publish and not before, and that a token ended by POST /logout is refused on a jwt.auth route through every carrier, at /fetch and at /refresh, with its jwt_blacklist row. TestAppBlacklist (in -short) covers the guard's per-request store lookup: no store, delegation and a failing store.
TestSessionRoutesAcceptCookies checks that a session jwt.auth accepts through only the token or auth_token cookie is served by GET /fetch, POST /refresh (through token, the auth.cookie default, the rotated token comes back in a rewritten cookie with a {} body; through auth_token it comes in the body with no cookie; the old token is refused afterwards), POST /update, POST /change-password (the calling token stays valid) and POST /marketing-consent; that a request without credentials still gets the old 401 bodies; that the bearer header wins over a cookie exactly as on jwt.auth, an invalid bearer included; and that a cut-off token and a banned user are refused through the cookie.
TestLoginRequiresActivation pins the activation gate of POST /login: under the default and an explicit require_activation: true a not-activated account's correct password gets the byte-identical 401 (status, body, Content-Type and Cache-Control) of a wrong password, stamps no last_seen, adds one throttle attempt and suspends the account after attempt_limit refusals; require_activation: false and activated accounts sign in; a deactivated activated account is restored with the reactivate mail, and a deactivated not-activated account is refused and stays deactivated unless the key is off.
TestLoginThrottleClientIP sends failed logins through one trusted proxy (http.trusted_proxies). It checks that the throttle row carries the X-Forwarded-For client, and that this client is suspended while another client behind the same proxy still signs in. It also checks that without trusted proxies a changing X-Forwarded-For neither moves the counter nor escapes the suspension.
TestRefreshRewritesAuthCookie covers the cookie-carried POST /refresh: the token cookie rewritten with the rotated token and the LoadCookieConfig attributes (the old token refused, the new one served by /fetch and jwt.auth), auth_token and bearer refreshes left body-only (the bearer wins over a cookie), a configured cookie name and refresh window, refusals that set no cookie, and a broken auth.cookie block that answers the opaque 500 and rotates nothing. TestSessionTokenCookie (in -short) is the carrier table of classes.SessionTokenCookie.
TestTokenCookie covers golem15.user.jwt.token_cookie: off by default (body tokens, no cookie); on for POST /login, POST /register and POST /activate-by-code, with the session cookie serving /fetch and rotating through a cookie refresh and no token in the body; POST /tokens unaffected; a configured cookie name and refresh window; and a broken auth.cookie block that answers the opaque 500 and signs in, creates and activates nothing.
TestPasswordPublicRoutes (in -short) and TestPasswordPublicRoutesAnswer404 cover golem15.user.password.public_routes: unset, true and the config.yaml default register every route; false (also from a SUMMER_ variable) drops exactly the three password routes and keeps every other route and its middleware, the turnstile on /register included; an invalid value fails Routes. Through the assembled router the dropped routes (and a GET /login) answer exactly like an unknown path and sign in, mail and reset nothing, while the default answers are unchanged and /register, /fetch, /refresh and /logout still work.
TestAdminOrganisationMembershipClearsRole checks that the members manager's unlink and link and a Users form save that changes the organisation clear organisation_role, that a save keeping the organisation keeps it, and that deleting an organisation clears both columns.
TestBackendLinkSmoke runs the account link end to end: My account offers link, the action answers the site URL with a 64-hex code, the show route answers the administrator's login and expiry, a session older than the request gets stale_session, a fresh one links (the published resolver answers the administrator), the code is one-shot, a second frontend user gets already_linked, an expired code is 404, unlink and deleting the frontend user remove the link, and the subject events fire. TestBackendLinkAdminSmoke covers the Administrators screen actions: offered only as they apply and only with golem15.users.manage_backend_links, the case-insensitive email match, linked_by, BackendLinkedEvent, the 422 refusals and the unlink. TestUserMCPGuardSmoke covers the user guard provider (requirements, the scope and group-permission checks, the dispatch credential, personal token resolution), GET /token/me and the revoke, group and user subject events. TestFrontendPermissionCatalogSmoke boots a test plugin that declares frontend permission codes and checks both editors' options. All four need Docker.
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.