fix: a self-service password change revokes every OAuth grant, however recently refreshed

ChangePassword fired the access-revoked event with Before = the calling
session's iat, so an OAuth/MCP grant whose client refreshed it after that
instant kept working. Before is now the instant of the request; the stored
tokens_valid_after still keeps the calling session.
This commit is contained in:
2026-10-23 20:48:38 +02:00
parent 38551a4104
commit 30f7a01d3d
3 changed files with 26 additions and 16 deletions

View File

@@ -54,7 +54,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
- **OAuth providers.** `GET /oauth-providers` lists the social sign-in providers a social-login plugin has enabled, read on every request from its published `classes.OAuthProviderSource`. Without one it answers the empty list, as before.
- **Frontend account link for administrators.** A backend administrator's account can be linked to one frontend user, and a frontend user to one administrator (`golem15_user_backend_links`, unique on both sides). The administrator starts it on My account (section `frontend_account`, action `link`): the plugin stores a one-shot request (32 random bytes as hex, kept only as its SHA-256, valid `golem15.user.backend_links.request_ttl`) and answers the site URL `golem15.user.frontend_url` (else `app.url`) + `golem15.user.backend_links.page_path` (default `/link-admin`, `classes.BackendLinkPagePath`) + `#code=<code>`, which the admin opens in a new tab. The code travels in the URL fragment, which browsers never send to a server, so it stays out of access logs. The site page reads the fragment, keeps the code in the browser (for example `sessionStorage`) across the sign-in, shows the request through `POST /backend-link/show` with `{"code":…}` in the body (the administrator's login and display name and the expiry, nothing else), shows which frontend account is signed in, and posts the code to `POST /backend-link/confirm` behind `jwt.auth`. `GET /backend-link/{code}` answers the same as the POST show route and stays for compatibility; it is deprecated, because its path puts the code in access logs, so pages use POST. The link is written only by the confirm, in one transaction with the request row locked, and only when the session was signed in at or after the request was created: the proof is the session token's `auth_time` claim (`bouncer.VerifyAuthTime`), the time of the sign-in itself, compared in whole seconds with the request. `POST /refresh` copies `auth_time` and never renews it, so a refreshed old session does not pass, and a browser that was already signed in must sign in again; password, social login and any login gate apply as usual. Upgrade note: session tokens issued before the framework stamped `auth_time` (SummerCMS v0.5.0) carry no `auth_time`, and a refresh carries that absence forward, so a frontend user signed in before the upgrade gets `stale_session` and must sign in again before linking. A code works once; a used, expired or unknown code answers 404. No link is ever made by matching emails. The administrator removes it on My account (`unlink`), and an administrator holding `golem15.users.manage_backend_links` links or unlinks another administrator on the Administrators screen (see Admin). Deleting or deactivating the frontend user removes the link in the same transaction; deleting an administrator removes it through the foreign key, and a deactivated or soft-deleted administrator is not answered by the resolver. Boot publishes `*classes.BackendLinks` and the same value as `pact.MCPLinkedBackendResolver` (`LinkedBackendUser`, one query per call), which the framework's MCP endpoint uses to give a frontend user the linked administrator's backend tools. `BackendLinkedEvent` and `BackendUnlinkedEvent` fire inside the write transaction.
- **Frontend permission codes from plugins.** Another plugin declares the frontend permission codes its routes check (`user.permission:<code>`) with `pact.HasFrontendPermissions`. The Users and User Groups permission editors offer the rows of `golem15_user_frontend_permissions` first, unchanged, then the declared codes of the activated plugins in activation order (`classes.FrontendPermissionCatalog`); a seeded row wins over a declared code with the same code, and the first declaring plugin wins over a later one. Nothing is written to the table; the stored permissions keep their shape.
- **Access revocation.** Banning a user (the admin `ban` bulk action, for the users it newly bans), a password reset (`POST /reset-password`), an administrator setting a password on the Users screen and the user's own `POST /change-password` fire `classes.AccessRevokedEvent` (`user.AccessRevoked`: `UserIDs`, `Before`, `Reason`, `Tx`) through `classes.FireAccessRevoked`, inside the write's transaction (a listener error rolls the write back), and a "revoked" `pact.MCPSubjectChanged` per user after it commits, so the users' MCP sessions end. `Before` is the untruncated instant of the revocation (for the self-service change, the start of the current session's sign-in second, so tokens issued since that sign-in survive, as the session does); `tokens_valid_after` keeps its whole-second value. `Reason` is `ban`, `password_reset`, `password_set_by_admin` or `password_changed` (`AccessRevokedBan`, `AccessRevokedPasswordReset`, `AccessRevokedPasswordSetByAdmin`, `AccessRevokedPasswordChanged`). Listeners revoke what such a user still holds elsewhere: `golem15.oauthserver` revokes the users' OAuth access and refresh tokens created before `Before`. `RevokeUserAccess`, `BanUsers` and `DeactivateUsers` keep their behaviour and fire nothing: an application that calls `RevokeUserAccess` or `BanUsers` directly calls `FireAccessRevoked` in the same transaction too. Deactivation and deletion keep their own events (`DeactivatingEvent`, `DeletingEvent`).
- **Access revocation.** Banning a user (the admin `ban` bulk action, for the users it newly bans), a password reset (`POST /reset-password`), an administrator setting a password on the Users screen and the user's own `POST /change-password` fire `classes.AccessRevokedEvent` (`user.AccessRevoked`: `UserIDs`, `Before`, `Reason`, `Tx`) through `classes.FireAccessRevoked`, inside the write's transaction (a listener error rolls the write back), and a "revoked" `pact.MCPSubjectChanged` per user after it commits, so the users' MCP sessions end. `Before` is the untruncated instant of the revocation for every reason, the self-service change included, so the event ends every OAuth grant of the user however recently its client refreshed it. `tokens_valid_after` keeps its whole-second value: the second of `Before` for a ban, a reset or an administrator's password, and for the self-service change the start of the calling session's `iat` second, so the calling session and the session tokens issued since that second survive. `Reason` is `ban`, `password_reset`, `password_set_by_admin` or `password_changed` (`AccessRevokedBan`, `AccessRevokedPasswordReset`, `AccessRevokedPasswordSetByAdmin`, `AccessRevokedPasswordChanged`). Listeners revoke what such a user still holds elsewhere: `golem15.oauthserver` revokes the users' OAuth access and refresh tokens created before `Before`. `RevokeUserAccess`, `BanUsers` and `DeactivateUsers` keep their behaviour and fire nothing: an application that calls `RevokeUserAccess` or `BanUsers` directly calls `FireAccessRevoked` in the same transaction too. Deactivation and deletion keep their own events (`DeactivatingEvent`, `DeletingEvent`).
- **MCP guard and the `user_me` tool.** Boot registers `classes.MCPUserGuard` as the MCP guard provider `user` in the application's `*pact.MCPGuards` and publishes it as `pact.MCPPersonalTokenResolver`, so the framework's MCP endpoint (built with `-tags summercms_mcp`) can bind tools to routes behind `user.api_token`, `user.scope:<scope>` and `user.permission:<code>` and accept the plugin's personal API tokens (the `golem15.user.api_tokens.prefix`). The guard decides with the routes' own checks: every scope of the route must be granted to the MCP session, and every permission code must be granted by one of the user's groups, as `user.permission` checks it (a user's own permission override does not grant). A tool call runs the route with the user and an in-memory token credential carrying the session's scopes (ID 0, never saved). The plugin's one tool, `me` (exposed as `user_me`), runs `GET /token/me` and answers the signed-in user's payload. A personal token used at the MCP endpoint gets `last_used_at` stamped, and `last_used_ip` when the endpoint passes the client address (`towel.ClientIP`), as the `user.api_token` guard does. Revoking a personal token, saving a user on the Users screen, saving or deleting a group and the link changes fire `pact.MCPSubjectChanged` after they commit, so open MCP sessions re-list their tools or end; revoking a personal token (`DELETE /tokens/{id}`) carries the token's id (`TokenID`), so only the sessions that token opened end. `BackendLinkedEvent` and `BackendUnlinkedEvent`, with `Link.UserID` set, fire on every link and unlink: the confirmation, the Administrators screen, My account and the deletion or deactivation of the frontend user. Deleting an administrator removes the link through the foreign key and fires nothing.
- **Sign-in helpers for social login.** `RegisterOptions.PasswordLess`, `FinishLogin`, `RegistrationSettings`, `RegistrationsFromIP`, `RefreshTTL` and `RemoteIP` let a social-login plugin create accounts and finish logins with the user plugin's own rules instead of copies.

View File

@@ -102,9 +102,11 @@ func wholeSecondsUntruncated(t *testing.T, env *adminEnv, id uint, before time.T
}
// TestAccessRevocation pins plan 08's revocation chain on the user side
// (C-CR-01, P-35): the ban bulk action, ResetPassword, ChangePassword and an
// administrator setting a password each fire one AccessRevokedEvent in the
// write's transaction with the right ids, Before and Reason, then one
// (C-CR-01, RC-CR-01, P-35): the ban bulk action, ResetPassword,
// ChangePassword and an administrator setting a password each fire one
// AccessRevokedEvent in the write's transaction with the right ids, Before
// and Reason (for ChangePassword the instant of the request, never the
// calling session's older iat, while the session itself survives), then one
// revoked subject event per user after the commit; the ban fires only for
// newly banned users; a failing listener rolls a password reset back; and
// a banned linked user loses the backend link until unbanned. Postgres
@@ -153,20 +155,26 @@ func TestAccessRevocation(t *testing.T) {
wholeSecondsUntruncated(t, env, user.ID, got.ev.Before)
})
t.Run("ChangePassword fires with the stored cutoff plus one second", func(t *testing.T) {
t.Run("ChangePassword revokes as of the request and keeps the calling session", func(t *testing.T) {
user := env.seedUser(t, "rv-change", func(u *models.User) { u.HasSelfSetPassword = true })
status, session := env.userLogin(t, user.Email, "correct-horse")
if status != http.StatusOK {
t.Fatalf("login = %d", status)
}
// A session last refreshed twenty minutes ago: an OAuth grant its
// client rotated since then is newer than the session, and must
// still end (RC-CR-01).
iat := time.Now().Add(-20 * time.Minute)
session := craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "rv-change-"+strconv.FormatInt(time.Now().UnixNano(), 10), iat, time.Now().Add(time.Hour))
probe.take()
spy.reset()
started := time.Now()
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/change-password", session, map[string]string{"current_password": "correct-horse", "password": "rv-changed-password", "password_confirmation": "rv-changed-password"})
got := one(t, probe, spy, user.ID, classes.AccessRevokedPasswordChanged)
stored := env.reload(t, user.ID).TokensValidAfter
if stored == nil || !got.ev.Before.Equal(stored.Add(time.Second)) {
t.Fatalf("Before %s, stored cutoff %v: want the cutoff plus one second", got.ev.Before, stored)
if got.ev.Before.Before(started) || got.ev.Before.After(time.Now()) {
t.Fatalf("Before %s is not the instant of the request (started %s): grants refreshed after the session's iat %s would survive", got.ev.Before, started, iat)
}
stored := env.reload(t, user.ID).TokensValidAfter
if want := time.Unix(iat.Unix(), 0).Add(-time.Second); stored == nil || !stored.Equal(want) {
t.Fatalf("stored cutoff %v, want the session's iat second minus one (%s)", stored, want)
}
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
})
t.Run("an administrator setting a password fires with the untruncated instant", func(t *testing.T) {

View File

@@ -716,10 +716,12 @@ func ChangePassword(app *backpack.App) http.HandlerFunc {
return
}
// The cutoff keeps the current session (issued at iat) and every
// token issued since its sign-in second; the access-revoked event
// revokes OAuth grants created before that second, in the same
// transaction.
// session token issued since that second. OAuth grants are revoked
// as of now, whatever their age: the request needs none, and a
// grant its client refreshed after iat would otherwise survive the
// change (its rotated tokens are newer than the session).
cutoff := iat.Add(-time.Second)
revokedAt := time.Now()
if err := lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.WithContext(ctx).Model(&user).Updates(map[string]any{
"password": hashed,
@@ -729,7 +731,7 @@ func ChangePassword(app *backpack.App) http.HandlerFunc {
}).Error; err != nil {
return err
}
return classes.FireAccessRevoked(ctx, app, tx, []uint{user.ID}, cutoff.Add(time.Second), classes.AccessRevokedPasswordChanged)
return classes.FireAccessRevoked(ctx, app, tx, []uint{user.ID}, revokedAt, classes.AccessRevokedPasswordChanged)
}); err != nil {
writeOpaque500(w)
return