diff --git a/README.md b/README.md index 6d8efce..5233df4 100644 --- a/README.md +++ b/README.md @@ -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 ` 0 { diff --git a/controllers/users/config_form.yaml b/controllers/users/config_form.yaml index 4ffa124..9032b23 100644 --- a/controllers/users/config_form.yaml +++ b/controllers/users/config_form.yaml @@ -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 diff --git a/controllers/users_admin_controller.go b/controllers/users_admin_controller.go index 2c34001..de58320 100644 --- a/controllers/users_admin_controller.go +++ b/controllers/users_admin_controller.go @@ -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 diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index fa66531..2d0f95a 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 50978b8..e834721 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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. diff --git a/password_changed_smoke_test.go b/password_changed_smoke_test.go new file mode 100644 index 0000000..56d9481 --- /dev/null +++ b/password_changed_smoke_test.go @@ -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") + } + }) +} diff --git a/reset_two_factor_smoke_test.go b/reset_two_factor_smoke_test.go new file mode 100644 index 0000000..2b2f74e --- /dev/null +++ b/reset_two_factor_smoke_test.go @@ -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") + } +}