diff --git a/README.md b/README.md index 19d2574..d7eb87e 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap - **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never), so it is untrusted client input. `CreatedEvent` (`classes.CreatedEventName` `user.Created`) fires from API register and admin create. `/register` logs a failing `RegisterEvent` or `CreatedEvent` listener at Error level and continues: a committed registration is never undone or failed by a listener. `DeletingEvent` (`classes.DeletingEventName` `user.Deleting`) fires from `FormAfterDelete` before `ForceDeleteCleanup` / unscoped delete, inside the same transaction. `GetApiArrayEvent` lets other plugins merge keys into the user payload. - **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload. - **OAuth providers.** `GET /oauth-providers` lists 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=`, 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 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=`, 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`. Both POST routes read the code from the body only (JSON, or a form body), never from the query string. `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:`) 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 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:` and `user.permission:` 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. diff --git a/backend_link_show_test.go b/backend_link_show_test.go index b9dd107..71f4b74 100644 --- a/backend_link_show_test.go +++ b/backend_link_show_test.go @@ -3,6 +3,7 @@ package user import ( "bytes" "net/http" + "net/http/httptest" "regexp" "strings" "testing" @@ -15,7 +16,8 @@ import ( // TestBackendLinkShowPost pins plan 08's link code transport (C-WR-01, // C-IN-06): POST /backend-link/show answers exactly like the GET route (the // data for a live code, 404 for an unknown, a used or an expired one), both -// with Cache-Control no-store, private; My account answers the link as +// with Cache-Control no-store, private; a code in the query string of the +// show or confirm POST is ignored (RC-IN-01); My account answers the link as // /link-admin#code=<64 hex> with no query string; // golem15.user.backend_links.page_path changes the path, and a value // without a leading slash gets one. Postgres only: under -short nothing @@ -81,6 +83,35 @@ func TestBackendLinkShowPost(t *testing.T) { same(t, expired, http.StatusNotFound) }) + t.Run("a code in the query string is ignored", func(t *testing.T) { + live := env.startLink(t, env.admin(t)) + path := "/_user/api/v1/backend-link/show?code=" + live + for name, send := range map[string]func() *httptest.ResponseRecorder{ + "no body": func() *httptest.ResponseRecorder { return env.send(t, http.MethodPost, path, "", nil, "", nil) }, + "a JSON body without a code": func() *httptest.ResponseRecorder { return env.call(t, http.MethodPost, path, "", map[string]string{}) }, + "a form body without a code": func() *httptest.ResponseRecorder { + return env.send(t, http.MethodPost, path, "", []byte("other=1"), "application/x-www-form-urlencoded", nil) + }, + } { + if got := send(); got.Code != http.StatusNotFound { + t.Fatalf("show with ?code= and %s = %d %s, want 404", name, got.Code, got.Body.String()) + } + } + user := env.seedUser(t, "show-query", nil) + rec := env.send(t, http.MethodPost, "/_user/api/v1/backend-link/confirm?code="+live, freshUserToken(t, user), []byte("other=1"), "application/x-www-form-urlencoded", nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("confirm with ?code= = %d %s, want 404", rec.Code, rec.Body.String()) + } + // A form body still carries the code, and the query string never did. + form := env.send(t, http.MethodPost, "/_user/api/v1/backend-link/show", "", []byte("code="+live), "application/x-www-form-urlencoded", nil) + if form.Code != http.StatusOK { + t.Fatalf("show with the code in a form body = %d %s", form.Code, form.Body.String()) + } + if status, body := env.confirmLink(t, freshUserToken(t, user), live); status != http.StatusOK { + t.Fatalf("confirm with the code in the body after the refusals = %d %s", status, body) + } + }) + t.Run("the My account URL carries the code in the fragment only", func(t *testing.T) { var out struct { URL string `json:"url"` diff --git a/controllers/api_controller.go b/controllers/api_controller.go index 0ec6ef0..9fabf1c 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -1165,6 +1165,26 @@ func activationLink(app *backpack.App, id uint, code string) string { return base + "?" + q.Encode() } +// readBodyFields is readFields without the query string: a JSON body, or a +// form-encoded body read from r.PostForm. The routes that carry a secret in +// a field use it, so the secret is never taken from a URL that access logs +// record. +func readBodyFields(r *http.Request) (map[string]any, error) { + if strings.Contains(r.Header.Get("Content-Type"), "application/json") { + return readFields(r) + } + if err := r.ParseForm(); err != nil { + return nil, err + } + fields := map[string]any{} + for k, v := range r.PostForm { + if len(v) > 0 { + fields[k] = v[0] + } + } + return fields, nil +} + func readFields(r *http.Request) (map[string]any, error) { ct := r.Header.Get("Content-Type") if strings.Contains(ct, "application/json") { diff --git a/controllers/backend_link_controller.go b/controllers/backend_link_controller.go index 9b4e978..7682eb5 100644 --- a/controllers/backend_link_controller.go +++ b/controllers/backend_link_controller.go @@ -41,10 +41,11 @@ func BackendLinkShow(app *backpack.App) http.HandlerFunc { // BackendLinkShowPost serves POST /_user/api/v1/backend-link/show: body // {"code": ...}, the same answers as BackendLinkShow. The code travels in -// the body, so it stays out of the URLs servers and proxies log. +// the body, so it stays out of the URLs servers and proxies log; a code in +// the query string is ignored. func BackendLinkShowPost(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - fields, err := readFields(r) + fields, err := readBodyFields(r) if err != nil { writeNoStoreJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"}) return @@ -85,6 +86,7 @@ func writeNoStoreJSON(w http.ResponseWriter, status int, v any) { // token without auth_time (minted before auth_time existed, or refreshed from // one), answers 409 with the stale-session error, an account already linked // on either side 409 already_linked, an unknown, used or expired code 404. +// The code is read from the body only; one in the query string is ignored. func BackendLinkConfirm(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { principal, ok := bouncer.User(r.Context()) @@ -108,7 +110,7 @@ func BackendLinkConfirm(app *backpack.App) http.HandlerFunc { writeJSON(w, http.StatusConflict, map[string]any{"error": "stale_session"}) return } - fields, err := readFields(r) + fields, err := readBodyFields(r) if err != nil { linkNotFound(w) return