Files
sm-user-plugin/README.md

21 KiB

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_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. An organisation has a name, a slug (filled from the name by Slugify when it is left empty), a description, an avatar and its members: the users whose organisation_id points at it.
  • 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":[]. Membership is written in one place only: the groups field of the admin user form. Apart from that, rows leave users_groups only 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 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 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 WinterCMS UserGroups controller, behind golem15.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 in user_groups.permissions as a JSON object of code to 1. 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 WinterCMS Organisations controller, behind golem15.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's organisation_id and 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 clears organisation_id and organisation_role of its members first.
  • Privileged groups. The groups whose code is listed in golem15.user.privileged_groups (default admin) are privileged: an application may treat their members as site administrators. Three things therefore need the backend permission golem15.users.manage_privileged_groups on 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 only golem15.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 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:

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), PermissionAccessUsers and PermissionAccessGroups, 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, UsersGroup (the users_groups pivot), APIToken, FrontendPermission and PermissionSet. PermissionSet reads a stored permission column tolerantly (NULL, an empty string, [], numeric strings) and writes a JSON object of integers; ParsePermissionSet and PermissionSet.Text do the same for a column that is a plain string on its model. UserGroup.UsersCount is filled only by a query that selects users_count (the admin list does). Organisation.Members is the relation behind the members manager, Organisation.AttachRelations declares its avatar (OrganisationAvatarField), and Slugify is the slug rule. 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 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 (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

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.