feat: password-change hook and the reset-2FA admin slot
The D-07 password-change hook is the existing AccessRevokedEvent, fired inside the write's transaction by a reset, the user's own change and an administrator's password; (*AccessRevokedEvent).PasswordChanged reports those three reasons, and the login rehash fires nothing. The Users screen declares the record action reset_two_factor (danger) behind the new permission golem15.users.reset_two_factor, which no role has by default. It is offered only when a two-factor plugin publishes a classes.TwoFactorAdmin and the user has a second factor; Run calls the service's Reset in the action's transaction.
This commit is contained in:
@@ -20,6 +20,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
||||
- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts its background sweep, which prunes the blacklist, through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh. `GET /fetch`, `POST /refresh`, `POST /update` and `POST /change-password`, like the plugin's other signed-in routes, read the session token as the `jwt` guard does: the bearer header first, then the `token` and `auth_token` cookies, so a cookie-only session works on them and a bearer header wins when both are sent. A `POST /refresh` whose token came from the `auth.cookie.*` cookie (`auth.cookie.name`, default `token`) with no bearer header gets that cookie back with the rotated token: the cookie has the `bouncer.LoadCookieConfig` attributes, HttpOnly and Max-Age `refresh_ttl`, and the answer is `{}`, so the token never reaches JavaScript. This holds whoever wrote the cookie (this plugin's `golem15.user.jwt.token_cookie`, `golem15.sociallogin`'s `token_cookie` or the application); a frontend that writes the `token` cookie itself and refreshes through it gets it back HttpOnly, without a body token, so such a frontend sends the bearer header to keep rotating in the body. A refresh carried by the bearer header (a cookie sent with it included) or by the other session cookie answers `{"token": …}` and writes no cookie; the old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so that caller stores the new token itself. A refused refresh writes and forgets no cookie. When `bouncer.LoadCookieConfig` refuses the `auth.cookie` block, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block. Keep `auth.cookie.name` at `token` or `auth_token`, the cookies the guard reads. With `golem15.user.jwt.token_cookie` on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set that HttpOnly session cookie (`bouncer.LoadCookieConfig` attributes, Max-Age `refresh_ttl`) and leave `token` out of the body; `user`, and `message` on activate-by-code, stay. With it off (the default) the token is in the body and no cookie is set. SameSite stays the `auth.cookie` setting (`lax` by default) and there is no CSRF token, so do not set `same_site: none` for this cookie. With the key on, a broken `auth.cookie` block answers those three routes with the opaque 500 before they sign in, create or activate anything. Personal API tokens (`POST /tokens`) are not sessions and are unaffected. A cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the `auth.cookie` default is `lax`), as on the `jwt.auth` routes.
|
||||
- **Login gates.** Register publishes a `*classes.LoginGates` registry. Another plugin (a two-factor plugin, for example) looks it up with `app.Lookup[*classes.LoginGates]()` and adds a `classes.LoginGate` in its Boot. `POST /login` consults the gates through `controllers.CheckLoginGates` after the password, activation and throttle checks (and the bcrypt rehash) and before it restores a deactivated account, sends the reactivate mail, stamps `last_seen` and mints the token. A gate sees a `classes.LoginAttempt` (`User`, `Source`, the already-read login body as `Fields`, `Request`). A nil result allows the sign-in; a `classes.LoginGateResult` replaces the token response with its `Status` (0 means 200) and `Body`, for example `{"two_factor_required":true,"challenge_token":…,"available_methods":[…],"expires_at":…}`, and no token is issued, no account is restored and no login event fires. A gate error fails closed with the opaque 500. Without a gate, `/login` answers exactly as before. `POST /refresh` never consults the gates.
|
||||
- **Login event.** Every sign-in that issued a session token fires `classes.LoginEvent` (`classes.LoginEventName`, `golem15.user.login`, PHP's name) with `User` and `Source` (`LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor`, `LoginSourceDevice`), after the token was minted. `POST /login` fires it with `password`; another plugin that completes a sign-in fires it through `controllers.FireLoginEvent`. A listener error is logged and never fails the sign-in. Refresh and `/register` fire none.
|
||||
- **Password-change hook.** A password reset, the user's own `POST /change-password` and an administrator setting a password on the Users screen fire `classes.AccessRevokedEvent` inside the write's transaction (see Access revocation); `(*AccessRevokedEvent).PasswordChanged()` reports these three reasons. A two-factor plugin listens to it to revoke trusted devices; a listener error rolls the password write back. The bcrypt rehash on login and a new account fire nothing.
|
||||
- **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password. With `golem15.user.password.public_routes` false, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered, so they answer the router's own 404 like any unknown path (any method, so no 405 shows they exist), while the `controllers` handlers stay exported.
|
||||
- **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 client address in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. The client address is `surf.ClientIP` with the framework's `http.trusted_proxies`, as for the `/register` IP limit and the `user-api` rate limit: behind a listed reverse proxy it is the `X-Forwarded-For` client, otherwise the connection address, and an `X-Forwarded-For` header from any other peer is ignored. A deployment behind a proxy lists the proxy's address in `http.trusted_proxies`; otherwise every visitor shares the proxy's counter.
|
||||
@@ -29,6 +30,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
||||
- **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, a logo and its members: the users whose `organisation_id` points at it. The logo is a second public image attachment (`logo`, `models.OrganisationLogoField`), kept in `system_files` like the avatar. The plugin serves no frontend organisation payload, so applications that build one add the logo with `classes.OrganisationLogoFields(ctx, app, org)`, which answers `has_logo` (bool), `logo` (the attachment's disk name, or null) and `logo_url` (a 256 px thumbnail URL, `classes.OrganisationLogoSize`, or null), the keys the user payload uses for its avatar; `classes.OrganisationLogoURL(ctx, app, org, size)` answers the URL at any size (the thumbnail, or the original file's public URL when the thumbnail fails, as `AvatarURL` does) and false when there is no logo or no file bucket. `organisation_role` belongs to that membership: the admin clears it whenever a user's organisation changes (see the Organisations screen), and an application that sets `organisation_id` itself sets or clears the role with 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, 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.
|
||||
- **Admin: Reset 2FA.** The Users screen declares the record action `reset_two_factor` ("Reset 2FA", danger confirmation) behind the backend permission `golem15.users.reset_two_factor`, which no role has by default. A two-factor plugin backs it by publishing a `classes.TwoFactorAdmin` (`app.Publish[classes.TwoFactorAdmin](svc)`): the screen looks the service up on every request and offers the action only when one is published and its `HasTwoFactor` reports a second factor for the user, so an application without a two-factor plugin never shows it. Running it calls the service's `Reset` in the action's transaction.
|
||||
- **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, avatar and logo (an image upload), and on an existing organisation the Members tab. Adding a member sets that user's `organisation_id` and removing one clears it; both also clear the user's `organisation_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, clears `organisation_role` unless the same save writes it). Deleting an organisation clears `organisation_id` and `organisation_role` of its members first, and removes its avatar and logo attachments with it (the files once the delete commits).
|
||||
- **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.
|
||||
@@ -157,7 +159,7 @@ The three screens are served by the framework's generic admin API under `<admin
|
||||
| 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 (Users) | `activate`, `unban`, `unsuspend`, and `reset_two_factor` (danger, behind `golem15.users.reset_two_factor`, offered only while a published `classes.TwoFactorAdmin` reports a second factor) |
|
||||
| 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` |
|
||||
|
||||
@@ -173,6 +175,7 @@ Backend permissions, all on the tab `golem15.user::lang.plugin.tab`:
|
||||
| `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. |
|
||||
| `golem15.users.reset_two_factor` | none | The Users screen's "Reset 2FA" record action, which removes every second factor of a user through the two-factor plugin's `classes.TwoFactorAdmin`. Only a superuser or a role that was granted it holds it. |
|
||||
|
||||
Migrations added for the admin, all additive:
|
||||
|
||||
@@ -239,6 +242,7 @@ Packages:
|
||||
- `AccessRevokedEvent` / `AccessRevokedEventName` and `FireAccessRevoked(ctx, app, tx, userIDs, before, reason)` (see Access revocation).
|
||||
- `LoginAttempt`, `LoginGateResult`, `LoginGate` (`CheckLogin(ctx, attempt)`), `LoginGates` with `NewLoginGates()`, `Add(gate)` and `Check(ctx, attempt)`, the login-gate seam (see Login gates), and the sources `LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor` and `LoginSourceDevice`.
|
||||
- `LoginEvent` / `LoginEventName` (see Login event).
|
||||
- `TwoFactorAdmin` (`HasTwoFactor(ctx, tx, userID)`, `Reset(ctx, tx, user)`) and `PermissionResetTwoFactor`, the Reset 2FA slot (see Admin: Reset 2FA), and `(*AccessRevokedEvent).PasswordChanged()` (see Password-change hook).
|
||||
- `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
|
||||
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
|
||||
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `BackendLink`, `BackendLinkRequest` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`) and its logo (`OrganisationLogoField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code.
|
||||
|
||||
@@ -16,7 +16,9 @@ const userPermissionTab = "golem15.user::lang.plugin.tab"
|
||||
// or password of a privileged-group member or delete one. The sixth
|
||||
// (classes.PermissionManageBackendLinks) has no default role either: it
|
||||
// links and unlinks another administrator's frontend account on the
|
||||
// Administrators screen.
|
||||
// Administrators screen. The seventh (classes.PermissionResetTwoFactor) has
|
||||
// no default role either: it runs the Users screen's "Reset 2FA" action,
|
||||
// which a two-factor plugin backs.
|
||||
func (*Plugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{
|
||||
{
|
||||
@@ -53,5 +55,10 @@ func (*Plugin) Permissions() []pact.Permission {
|
||||
Tab: userPermissionTab,
|
||||
Label: "golem15.user::lang.plugin.manage_backend_links",
|
||||
},
|
||||
{
|
||||
Code: classes.PermissionResetTwoFactor,
|
||||
Tab: userPermissionTab,
|
||||
Label: "golem15.user::lang.plugin.reset_two_factor",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ var langKey = regexp.MustCompile(`golem15\.user::lang\.[a-z0-9_]+(\.[a-z0-9_]+)+
|
||||
func TestAdminRegistration(t *testing.T) {
|
||||
plugin := &Plugin{}
|
||||
|
||||
// The six permission codes, each on the plugin's tab.
|
||||
// The seven permission codes, each on the plugin's tab.
|
||||
var codes []string
|
||||
for _, permission := range plugin.Permissions() {
|
||||
codes = append(codes, permission.Code)
|
||||
@@ -40,15 +40,15 @@ func TestAdminRegistration(t *testing.T) {
|
||||
wantCodes := []string{
|
||||
"golem15.users.access_users", "golem15.users.access_groups", "golem15.users.access_settings",
|
||||
"golem15.users.impersonate_user", "golem15.users.manage_privileged_groups",
|
||||
"golem15.users.manage_backend_links",
|
||||
"golem15.users.manage_backend_links", "golem15.users.reset_two_factor",
|
||||
}
|
||||
if !reflect.DeepEqual(codes, wantCodes) {
|
||||
t.Fatalf("permission codes = %v, want %v", codes, wantCodes)
|
||||
}
|
||||
// The privileged-groups and backend-links permissions have no default
|
||||
// role: nobody holds them until they are granted on purpose.
|
||||
// The privileged-groups, backend-links and reset-2FA permissions have no
|
||||
// default role: nobody holds them until they are granted on purpose.
|
||||
for _, permission := range plugin.Permissions() {
|
||||
if privileged := permission.Code == permManagePrivileged || permission.Code == permManageBackendLinks; privileged != (len(permission.Roles) == 0) {
|
||||
if privileged := permission.Code == permManagePrivileged || permission.Code == permManageBackendLinks || permission.Code == permResetTwoFactor; privileged != (len(permission.Roles) == 0) {
|
||||
t.Fatalf("permission %s has roles %v", permission.Code, permission.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,14 @@ func TestAdminUsersControllerGuards(t *testing.T) {
|
||||
if action.Name == "activate" {
|
||||
continue // reads the record only
|
||||
}
|
||||
if action.Name == "reset_two_factor" {
|
||||
// No classes.TwoFactorAdmin is published, so it reads
|
||||
// nothing and is simply not offered.
|
||||
if applies, err := action.Applies(ctx, user); applies || err != nil {
|
||||
t.Fatalf("applies reset_two_factor without a service = %v, %v", applies, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, err := action.Applies(ctx, user)
|
||||
wantPlainError(t, "applies "+action.Name, err)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ func TestAdminUsersTracer(t *testing.T) {
|
||||
"golem15.users.access_users@developer", "golem15.users.access_groups@developer",
|
||||
"golem15.users.access_settings@developer", "golem15.users.impersonate_user@developer",
|
||||
classes.PermissionManagePrivilegedGroups + "@-", classes.PermissionManageBackendLinks + "@-",
|
||||
classes.PermissionResetTwoFactor + "@-",
|
||||
}; !reflect.DeepEqual(codes, want) || classes.PermissionManagePrivilegedGroups != permManagePrivileged {
|
||||
t.Fatalf("permissions = %v", codes)
|
||||
}
|
||||
@@ -610,7 +611,7 @@ func TestAdminUserActions(t *testing.T) {
|
||||
registered = append(registered, action.Name)
|
||||
}
|
||||
}
|
||||
if want := []string{"activate", "unban", "unsuspend"}; !reflect.DeepEqual(registered, want) {
|
||||
if want := []string{"activate", "unban", "unsuspend", "reset_two_factor"}; !reflect.DeepEqual(registered, want) {
|
||||
t.Fatalf("record actions = %v, want %v", registered, want)
|
||||
}
|
||||
var form struct {
|
||||
|
||||
@@ -116,6 +116,24 @@ type AccessRevokedEvent struct {
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
// PasswordChanged reports whether the event comes from a new password the
|
||||
// user or an administrator chose: a password reset, the user's own change or
|
||||
// an administrator setting one (Reason AccessRevokedPasswordReset,
|
||||
// AccessRevokedPasswordChanged or AccessRevokedPasswordSetByAdmin). The
|
||||
// bcrypt rehash on login and a new account fire no AccessRevokedEvent. A
|
||||
// two-factor plugin listens for these events to revoke the users' trusted
|
||||
// devices inside the same transaction.
|
||||
func (e *AccessRevokedEvent) PasswordChanged() bool {
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
switch e.Reason {
|
||||
case AccessRevokedPasswordReset, AccessRevokedPasswordChanged, AccessRevokedPasswordSetByAdmin:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LoginEvent fires after a sign-in issued a session token: a password login,
|
||||
// a sign-in completed by a second factor, a passkey sign-in or a device
|
||||
// hand-over (Source is one of the LoginSource constants). It does not fire
|
||||
|
||||
38
classes/two_factor_admin.go
Normal file
38
classes/two_factor_admin.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PermissionResetTwoFactor is the backend permission an administrator needs
|
||||
// to run the Users screen's "Reset 2FA" record action (reset_two_factor). No
|
||||
// role has it by default: only a superuser, or a role granted it
|
||||
// explicitly, may remove a user's second factors.
|
||||
const PermissionResetTwoFactor = "golem15.users.reset_two_factor"
|
||||
|
||||
// TwoFactorAdmin is what a two-factor plugin publishes so the Users screen
|
||||
// can offer its "Reset 2FA" record action. The screen looks it up on every
|
||||
// request: without a published service the action is never offered, so an
|
||||
// application that mounts no two-factor plugin never shows it.
|
||||
//
|
||||
// Publish it with an explicit type argument,
|
||||
//
|
||||
// app.Publish[classes.TwoFactorAdmin](svc)
|
||||
//
|
||||
// because a bare app.Publish(svc) keys the service on its concrete type and
|
||||
// the lookup misses it.
|
||||
type TwoFactorAdmin interface {
|
||||
// HasTwoFactor reports whether the user has any second factor (an
|
||||
// enabled method, a WebAuthn credential, recovery codes or a trusted
|
||||
// device) to reset. It only reads, through tx.
|
||||
HasTwoFactor(ctx context.Context, tx *gorm.DB, userID uint) (bool, error)
|
||||
// Reset disables every second-factor method of user, deletes the
|
||||
// WebAuthn credentials and recovery codes, revokes the trusted devices
|
||||
// and fires the plugin's disabled event, all on tx (the record action's
|
||||
// transaction): an error rolls the reset back. The user then signs in
|
||||
// with the password alone until they set up a second factor again.
|
||||
Reset(ctx context.Context, tx *gorm.DB, user *models.User) error
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func Login(app *backpack.App) http.HandlerFunc {
|
||||
}
|
||||
// The rehash runs before the login gates, so a user who is always
|
||||
// challenged still gets the stronger hash. It is not a password
|
||||
// change: it fires no PasswordChangedEvent. Unscoped, because a
|
||||
// change: it fires no AccessRevokedEvent (D-07). Unscoped, because a
|
||||
// deactivated account is restored only after the gates.
|
||||
cost := 10
|
||||
if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 {
|
||||
|
||||
@@ -14,7 +14,7 @@ update:
|
||||
preview:
|
||||
headerPartial: hint
|
||||
|
||||
recordActions: [activate, unban, unsuspend]
|
||||
recordActions: [activate, unban, unsuspend, reset_two_factor]
|
||||
|
||||
messages:
|
||||
create: golem15.user::lang.users.new_user
|
||||
|
||||
@@ -719,8 +719,15 @@ func (c usersAdminController) throttleState(ctx context.Context, userID uint) (b
|
||||
}
|
||||
|
||||
// AdminRecordActions are the PHP preview handlers onActivate, onUnban and
|
||||
// onUnsuspendUser. Each is offered only while it applies; the checks only
|
||||
// read. There is no impersonate action and no guest action.
|
||||
// onUnsuspendUser, plus reset_two_factor. Each is offered only while it
|
||||
// applies; the checks only read. There is no impersonate action and no
|
||||
// guest action.
|
||||
//
|
||||
// reset_two_factor ("Reset 2FA") needs classes.PermissionResetTwoFactor and
|
||||
// asks for a danger confirmation. It is offered only when a two-factor
|
||||
// plugin published a classes.TwoFactorAdmin (looked up per request) and the
|
||||
// user has a second factor; Run hands the record to that service's Reset in
|
||||
// the action's transaction.
|
||||
func (c usersAdminController) AdminRecordActions() []pact.AdminRecordAction {
|
||||
asUser := func(record any) (*models.User, error) {
|
||||
user, ok := record.(*models.User)
|
||||
@@ -785,9 +792,55 @@ func (c usersAdminController) AdminRecordActions() []pact.AdminRecordAction {
|
||||
func(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
||||
return classes.UnsuspendUser(ctx, tx, user.ID)
|
||||
}),
|
||||
c.resetTwoFactorAction(action),
|
||||
}
|
||||
}
|
||||
|
||||
// resetTwoFactorAction builds the reset_two_factor record action with the
|
||||
// shared action builder, behind classes.PermissionResetTwoFactor and marked
|
||||
// as dangerous.
|
||||
func (c usersAdminController) resetTwoFactorAction(action func(name, label, confirm, success string,
|
||||
applies func(ctx context.Context, user *models.User) (bool, error),
|
||||
run func(ctx context.Context, tx *gorm.DB, user *models.User) error,
|
||||
) pact.AdminRecordAction) pact.AdminRecordAction {
|
||||
reset := action("reset_two_factor", "reset_two_factor", "reset_two_factor_confirm", "reset_two_factor_success",
|
||||
func(ctx context.Context, user *models.User) (bool, error) {
|
||||
svc, ok := c.twoFactorAdmin()
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
db := requestDB(ctx, c.db)
|
||||
if db == nil {
|
||||
return false, errors.New("users admin: gorm handle is missing")
|
||||
}
|
||||
return svc.HasTwoFactor(ctx, db, user.ID)
|
||||
},
|
||||
func(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
||||
svc, ok := c.twoFactorAdmin()
|
||||
if !ok {
|
||||
return errors.New("users admin: no two-factor plugin publishes classes.TwoFactorAdmin")
|
||||
}
|
||||
return svc.Reset(ctx, tx, user)
|
||||
})
|
||||
reset.Permissions = []string{classes.PermissionResetTwoFactor}
|
||||
reset.Danger = true
|
||||
return reset
|
||||
}
|
||||
|
||||
// twoFactorAdmin looks up the classes.TwoFactorAdmin a two-factor plugin
|
||||
// published, on every call, so a plugin that boots later is still found.
|
||||
func (c usersAdminController) twoFactorAdmin() (classes.TwoFactorAdmin, bool) {
|
||||
app := c.application()
|
||||
if app == nil {
|
||||
return nil, false
|
||||
}
|
||||
svc, ok := app.Lookup[classes.TwoFactorAdmin]()
|
||||
if !ok || svc == nil {
|
||||
return nil, false
|
||||
}
|
||||
return svc, true
|
||||
}
|
||||
|
||||
// usersHintView is the view model of controllers/users/_hint.htm, the status
|
||||
// callout above the preview: the callout kind (danger or warning) and the
|
||||
// phrase keys of its title and text. It is empty when no state applies, and
|
||||
|
||||
@@ -9,6 +9,7 @@ plugin:
|
||||
impersonate_user: Impersonate Users
|
||||
manage_privileged_groups: Manage privileged group membership
|
||||
manage_backend_links: Link frontend accounts to administrators
|
||||
reset_two_factor: Reset two-factor authentication of users
|
||||
users:
|
||||
menu_label: Users
|
||||
new_user: New User
|
||||
@@ -52,6 +53,9 @@ users:
|
||||
unsuspend_user: Unsuspend user
|
||||
unsuspend_confirm: Unsuspend this user?
|
||||
unsuspend_success: User has been unsuspended.
|
||||
reset_two_factor: Reset 2FA
|
||||
reset_two_factor_confirm: Remove every second factor of this user? Their authenticator apps, security keys, recovery codes and trusted devices stop working, and they sign in with the password alone until they set up two-factor authentication again.
|
||||
reset_two_factor_success: Two-factor authentication of the user has been reset.
|
||||
privileged_member_forbidden: This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved.
|
||||
privileged_member_delete_forbidden: Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted.
|
||||
privileged_group_forbidden: You do not have permission to change membership of privileged groups. Nothing was saved.
|
||||
|
||||
@@ -9,6 +9,7 @@ plugin:
|
||||
impersonate_user: Wcielaj się w użytkowników
|
||||
manage_privileged_groups: Zarządzaj członkostwem w grupach uprzywilejowanych
|
||||
manage_backend_links: Łącz konta na stronie z administratorami
|
||||
reset_two_factor: Resetowanie uwierzytelniania dwuskładnikowego użytkowników
|
||||
users:
|
||||
menu_label: Użytkownicy
|
||||
new_user: Nowy użytkownik
|
||||
@@ -52,6 +53,9 @@ users:
|
||||
unsuspend_user: Odblokuj użytkownika
|
||||
unsuspend_confirm: Odblokować tego użytkownika?
|
||||
unsuspend_success: Udało się odblokować użytkownika.
|
||||
reset_two_factor: Resetuj 2FA
|
||||
reset_two_factor_confirm: Usunąć wszystkie drugie składniki tego użytkownika? Jego aplikacje uwierzytelniające, klucze bezpieczeństwa, kody odzyskiwania i zaufane urządzenia przestaną działać, a do czasu ponownego włączenia uwierzytelniania dwuskładnikowego będzie logować się samym hasłem.
|
||||
reset_two_factor_success: Uwierzytelnianie dwuskładnikowe użytkownika zostało zresetowane.
|
||||
privileged_member_forbidden: Ten użytkownik należy do grupy uprzywilejowanej. Nie masz uprawnień do zmiany jego adresu e-mail ani hasła. Nic nie zostało zapisane.
|
||||
privileged_member_delete_forbidden: Użytkowników należących do grupy uprzywilejowanej nie można usunąć bez uprawnienia do zarządzania grupami uprzywilejowanymi. Nic nie zostało usunięte.
|
||||
privileged_group_forbidden: Nie masz uprawnień do zmiany członkostwa w grupach uprzywilejowanych. Nic nie zostało zapisane.
|
||||
|
||||
153
password_changed_smoke_test.go
Normal file
153
password_changed_smoke_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
)
|
||||
|
||||
// passwordProbe records the AccessRevokedEvents of an app (the D-07
|
||||
// password-change hook) and can make the listener fail.
|
||||
type passwordProbe struct {
|
||||
mu sync.Mutex
|
||||
seen []classes.AccessRevokedEvent
|
||||
fail atomic.Bool
|
||||
}
|
||||
|
||||
func (p *passwordProbe) take() []classes.AccessRevokedEvent {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := p.seen
|
||||
p.seen = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// onePasswordChange asserts exactly one event for id with reason, fired in
|
||||
// a transaction and reporting PasswordChanged.
|
||||
func onePasswordChange(t *testing.T, probe *passwordProbe, id uint, reason string) {
|
||||
t.Helper()
|
||||
seen := probe.take()
|
||||
if len(seen) != 1 {
|
||||
t.Fatalf("%s fired %d events, want 1: %+v", reason, len(seen), seen)
|
||||
}
|
||||
ev := seen[0]
|
||||
if ev.Reason != reason || len(ev.UserIDs) != 1 || ev.UserIDs[0] != id || ev.Tx == nil || !ev.PasswordChanged() {
|
||||
t.Fatalf("%s event = %+v (PasswordChanged %v)", reason, ev, ev.PasswordChanged())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasswordChangedEventSmoke pins the D-07 hook a two-factor plugin
|
||||
// listens to: a password reset, the user's own change and an administrator
|
||||
// setting a password each fire one AccessRevokedEvent that reports
|
||||
// PasswordChanged, inside the write's transaction; the bcrypt rehash on
|
||||
// login fires none; a failing listener rolls the change back. The event
|
||||
// itself predates this test (TestAccessRevocation pins its other fields).
|
||||
func TestPasswordChangedEventSmoke(t *testing.T) {
|
||||
for reason, want := range map[string]bool{
|
||||
classes.AccessRevokedPasswordReset: true,
|
||||
classes.AccessRevokedPasswordChanged: true,
|
||||
classes.AccessRevokedPasswordSetByAdmin: true,
|
||||
classes.AccessRevokedBan: false,
|
||||
"": false,
|
||||
} {
|
||||
if got := (&classes.AccessRevokedEvent{Reason: reason}).PasswordChanged(); got != want {
|
||||
t.Fatalf("PasswordChanged(%q) = %v, want %v", reason, got, want)
|
||||
}
|
||||
}
|
||||
var nilEvent *classes.AccessRevokedEvent
|
||||
if nilEvent.PasswordChanged() {
|
||||
t.Fatal("a nil event reports a password change")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
|
||||
env := newAdminEnv(t)
|
||||
probe := &passwordProbe{}
|
||||
env.app.Events.Listen("test.password_changed", func(_ context.Context, ev *classes.AccessRevokedEvent) error {
|
||||
if probe.fail.Load() {
|
||||
return errors.New("listener refused")
|
||||
}
|
||||
probe.mu.Lock()
|
||||
probe.seen = append(probe.seen, *ev)
|
||||
probe.mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
admin := env.superAdmin(t)
|
||||
ctx := context.Background()
|
||||
session := func(user *models.User) string {
|
||||
return craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "pc-"+strconv.FormatInt(time.Now().UnixNano(), 10), time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
|
||||
}
|
||||
|
||||
t.Run("reset", func(t *testing.T) {
|
||||
user := env.seedUser(t, "pc-reset", nil)
|
||||
code, err := classes.IssueResetCode(ctx, env.gdb, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe.take()
|
||||
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{"code": packed, "password": "pc-new-password", "password_confirmation": "pc-new-password"})
|
||||
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordReset)
|
||||
})
|
||||
|
||||
t.Run("change", func(t *testing.T) {
|
||||
user := env.seedUser(t, "pc-change", func(u *models.User) { u.HasSelfSetPassword = true })
|
||||
probe.take()
|
||||
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{"current_password": "correct-horse", "password": "pc-changed-password", "password_confirmation": "pc-changed-password"})
|
||||
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordChanged)
|
||||
})
|
||||
|
||||
t.Run("admin_set", func(t *testing.T) {
|
||||
user := env.seedUser(t, "pc-admin", nil)
|
||||
probe.take()
|
||||
const set = "pc-admin-password"
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{"name": user.Name, "email": user.Email, "password": set, "password_confirmation": set})
|
||||
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordSetByAdmin)
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{"name": user.Name, "email": user.Email})
|
||||
if seen := probe.take(); len(seen) != 0 {
|
||||
t.Fatalf("an update without a password fired %+v", seen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rehash_fires_none", func(t *testing.T) {
|
||||
user := env.seedUser(t, "pc-rehash", nil)
|
||||
if err := env.app.Config.Set("golem15.user.password.bcrypt_cost", 11); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = env.app.Config.Set("golem15.user.password.bcrypt_cost", 10) })
|
||||
probe.take()
|
||||
if status, _ := env.userLogin(t, user.Email, "correct-horse"); status != http.StatusOK {
|
||||
t.Fatalf("login = %d", status)
|
||||
}
|
||||
stored := env.reload(t, user.ID).Password
|
||||
if stored == user.Password || bouncer.NeedsRehash(stored, 11) {
|
||||
t.Fatal("the login did not rehash the password at the higher cost")
|
||||
}
|
||||
if seen := probe.take(); len(seen) != 0 {
|
||||
t.Fatalf("the rehash fired %+v", seen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("listener_error_rolls_back", func(t *testing.T) {
|
||||
user := env.seedUser(t, "pc-rollback", func(u *models.User) { u.HasSelfSetPassword = true })
|
||||
probe.fail.Store(true)
|
||||
rec := env.call(t, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{"current_password": "correct-horse", "password": "pc-never-set", "password_confirmation": "pc-never-set"})
|
||||
probe.fail.Store(false)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("change with a failing listener = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if stored := env.reload(t, user.ID).Password; stored != user.Password {
|
||||
t.Fatal("the stored hash changed although the listener failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
117
reset_two_factor_smoke_test.go
Normal file
117
reset_two_factor_smoke_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const permResetTwoFactor = "golem15.users.reset_two_factor"
|
||||
|
||||
// fakeTwoFactorAdmin stands in for a two-factor plugin's service.
|
||||
type fakeTwoFactorAdmin struct {
|
||||
mu sync.Mutex
|
||||
has bool
|
||||
resets []uint
|
||||
txs []*gorm.DB
|
||||
}
|
||||
|
||||
func (f *fakeTwoFactorAdmin) HasTwoFactor(_ context.Context, tx *gorm.DB, _ uint) (bool, error) {
|
||||
if tx == nil {
|
||||
return false, nil
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.has, nil
|
||||
}
|
||||
|
||||
func (f *fakeTwoFactorAdmin) Reset(_ context.Context, tx *gorm.DB, user *models.User) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.resets = append(f.resets, user.ID)
|
||||
f.txs = append(f.txs, tx)
|
||||
f.has = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordAction returns the offered record action named name from the show
|
||||
// response of user id, or nil.
|
||||
func (e *adminEnv) recordAction(t *testing.T, token string, id uint, name string) map[string]any {
|
||||
t.Helper()
|
||||
rec := e.expect(t, http.StatusOK, http.MethodGet, userPath(id, ""), token, nil)
|
||||
var meta struct {
|
||||
Actions []map[string]any `json:"actions"`
|
||||
}
|
||||
if err := json.Unmarshal(adminDecode(t, rec).Meta, &meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, action := range meta.Actions {
|
||||
if action["name"] == name {
|
||||
return action
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestResetTwoFactorSlotSmoke pins the D-15 slot: reset_two_factor is
|
||||
// offered only when a classes.TwoFactorAdmin is published and the user has
|
||||
// a second factor, only to an administrator holding
|
||||
// golem15.users.reset_two_factor, with the danger confirmation, and running
|
||||
// it calls Reset once inside the action's transaction.
|
||||
func TestResetTwoFactorSlotSmoke(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
env := newAdminEnv(t)
|
||||
token := env.admin(t, permAccessUsers, permResetTwoFactor)
|
||||
plain := env.admin(t, permAccessUsers)
|
||||
user := env.seedUser(t, "r2fa", nil)
|
||||
actionPath := userPath(user.ID, "/actions/reset_two_factor")
|
||||
|
||||
// No two-factor plugin: never offered, never runs.
|
||||
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
|
||||
t.Fatal("reset_two_factor offered without a published TwoFactorAdmin")
|
||||
}
|
||||
if rec := env.call(t, http.MethodPost, actionPath, token, map[string]any{}); rec.Code == http.StatusOK {
|
||||
t.Fatalf("reset_two_factor ran without a TwoFactorAdmin: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
svc := &fakeTwoFactorAdmin{has: true}
|
||||
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
action := env.recordAction(t, token, user.ID, "reset_two_factor")
|
||||
if action == nil {
|
||||
t.Fatalf("reset_two_factor not offered: %v", env.offered(t, token, user.ID))
|
||||
}
|
||||
if action["danger"] != true || action["label"] != "Reset 2FA" {
|
||||
t.Fatalf("offered action = %v", action)
|
||||
}
|
||||
|
||||
// An administrator with only access_users neither sees nor runs it.
|
||||
if slices.Contains(env.offered(t, plain, user.ID), "reset_two_factor") {
|
||||
t.Fatal("reset_two_factor offered without its permission")
|
||||
}
|
||||
env.expect(t, http.StatusForbidden, http.MethodPost, actionPath, plain, map[string]any{})
|
||||
if len(svc.resets) != 0 {
|
||||
t.Fatal("a refused administrator reached Reset")
|
||||
}
|
||||
|
||||
env.expect(t, http.StatusOK, http.MethodPost, actionPath, token, map[string]any{})
|
||||
if len(svc.resets) != 1 || svc.resets[0] != user.ID || svc.txs[0] == nil {
|
||||
t.Fatalf("Reset calls = %v (tx %v)", svc.resets, svc.txs)
|
||||
}
|
||||
|
||||
// Nothing left to reset: no longer offered.
|
||||
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
|
||||
t.Fatal("reset_two_factor offered for a user without a second factor")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user