fix: account link proof by auth_time, code in the URL fragment and POST show

- BackendLinkConfirm reads the session's auth_time (bouncer.VerifyAuthTime);
  a token without it, or a refreshed old session, answers 409 stale_session
- POST /_user/api/v1/backend-link/show takes the code in the body; GET
  /backend-link/{code} stays, deprecated; both answer Cache-Control no-store
- My account answers <frontend_url><page_path>#code=<code>; new key
  golem15.user.backend_links.page_path (default /link-admin)
- TestBackendLink now crafts auth_time tokens; TestBackendLinkAuthTimeSmoke
This commit is contained in:
Jakub Zych
2026-10-23 16:38:55 +02:00
parent 72de822a84
commit 32f92591d6
10 changed files with 225 additions and 44 deletions

View File

@@ -40,7 +40,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`) + `/link-admin?code=<code>`, which the admin opens in a new tab. The site page reads `GET /backend-link/{code}` (the administrator's login and display name and the expiry, nothing else), has the person sign in on the site as the frontend user, and posts the code to `POST /backend-link/confirm` behind `jwt.auth`. The link is written only there, in one transaction with the request row locked, and only when the session token was issued at or after the request was created (compared in whole seconds), so a browser that was already signed in must sign in again; password, social login and any login gate apply as usual. 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=<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.
- **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. 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.
- **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.
@@ -110,7 +110,8 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle
| `POST /tokens` | `controllers.APITokenStore` (`jwt.auth`) |
| `GET /tokens` | `controllers.APITokenIndex` (`jwt.auth`) |
| `DELETE /tokens/{id}` | `controllers.APITokenDestroy` (`jwt.auth`) |
| `GET /backend-link/{code}` | `controllers.BackendLinkShow` |
| `GET /backend-link/{code}` | `controllers.BackendLinkShow` (deprecated: the code is in the logged path; use the POST route) |
| `POST /backend-link/show` | `controllers.BackendLinkShowPost` |
| `POST /backend-link/confirm` | `controllers.BackendLinkConfirm` (`jwt.auth`) |
| `GET /token/me` | `controllers.TokenMe` (`user.api_token`, `user.scope:read`) |
@@ -118,7 +119,7 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle
With `golem15.user.password.public_routes` false the `/login`, `/forgot-password` and `/reset-password` rows are not registered.
`GET /backend-link/{code}` (`code` is 64 lower-case hex characters) answers `{"data":{"admin":{"login":…,"name":…},"expires_at":"<RFC 3339>"}}` for a usable request and `404 {"error":"not_found"}` for an unknown, used or expired one or when the administrator was deactivated since. `POST /backend-link/confirm` takes `{"code":…}` from the signed-in frontend user and answers `200 {"data":{"backend_user":{"login":…,"name":…}}}`; `409 {"error":"stale_session"}` when the session token was issued before the request (sign in again), `409 {"error":"already_linked"}` when the user or the administrator is already linked, and `404 {"error":"not_found"}` as the show route. `GET /token/me` answers the `{"user": …}` body of `/fetch` to a personal API token with the `read` scope (401 `{"error":true,"message":"Invalid token."}` from the guard, 403 `Missing required scope: read` from the scope check); it is the target of the MCP tool `user_me`.
`POST /backend-link/show` takes `{"code":…}` (64 lower-case hex characters) and, like the deprecated `GET /backend-link/{code}`, answers `{"data":{"admin":{"login":…,"name":…},"expires_at":"<RFC 3339>"}}` for a usable request and `404 {"error":"not_found"}` for an unknown, used or expired one or when the administrator was deactivated since. `POST /backend-link/confirm` takes `{"code":…}` from the signed-in frontend user and answers `200 {"data":{"backend_user":{"login":…,"name":…}}}`; `409 {"error":"stale_session"}` when the session's `auth_time` is before the request or the token carries no `auth_time` (sign in again), `409 {"error":"already_linked"}` when the user or the administrator is already linked, and `404 {"error":"not_found"}` as the show route. Both show routes send `Cache-Control: no-store, private`. `GET /token/me` answers the `{"user": …}` body of `/fetch` to a personal API token with the `read` scope (401 `{"error":true,"message":"Invalid token."}` from the guard, 403 `Missing required scope: read` from the scope check); it is the target of the MCP tool `user_me`.
### Host-mounted OAuth identities
@@ -249,7 +250,8 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
| `golem15.user.password.public_routes` | `true` | When `false`, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered and answer 404 like any unknown path; `POST /register` (still gated by `allow_registration` and guarded by `golem15.user.turnstile`) and every other route stay. It is read when the routes are built, so a change needs a restart. It takes `true` or `false`, also as a string (`SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false`); an empty value means `true`; any other value fails boot. |
| `golem15.user.api_tokens.prefix` | `usr_` | Prefix for newly minted and accepted shared API-token secrets. Override an existing application's legacy prefix without creating a plugin-specific token table. |
| `golem15.user.privileged_groups` | `[admin]` | Codes of the privileged user groups. Membership of such a group, the group's code and its delete, and the email, password and permanent delete of its members need `golem15.users.manage_privileged_groups`. Read on every request. An empty list protects no group. |
| `golem15.user.frontend_url` | `""` | The site the plugin's own links point to: the account link page is this URL + `/link-admin?code=<code>`. Empty falls back to `app.url`; a trailing slash is dropped. |
| `golem15.user.frontend_url` | `""` | The site the plugin's own links point to: the account link page is this URL + `backend_links.page_path` + `#code=<code>`. Empty falls back to `app.url`; a trailing slash is dropped. |
| `golem15.user.backend_links.page_path` | `/link-admin` | The site page that confirms an account link. A value without a leading `/` gets one. |
| `golem15.user.backend_links.request_ttl` | `10m` | How long a link request an administrator starts on My account stays usable (a Go duration). A value that does not parse or is not positive fails boot. |
| `golem15.user.turnstile` | `""` | Cloudflare Turnstile on `POST /login` and `POST /register` (only `/register` while `golem15.user.password.public_routes` is false). Empty adds nothing; `open` or `closed` appends the framework's `turnstile:open` or `turnstile:closed` middleware to those two routes only (summercms v0.3.0 and later; secret in `turnstile.secret`, token in the `turnstile_token` field). `open` lets requests pass when Cloudflare is unreachable, `closed` refuses them. Any other value fails boot. |

View File

@@ -110,8 +110,11 @@ func (p *Plugin) frontendEmail(ctx context.Context, userID uint) (string, error)
}
// startFrontendLink creates a link request for the signed-in administrator
// and answers the site page that confirms it:
// golem15.user.frontend_url (else app.url) + /link-admin?code=<code>.
// and answers the site page that confirms it: golem15.user.frontend_url
// (else app.url), then golem15.user.backend_links.page_path (default
// /link-admin), then the code in the URL fragment. Browsers never send a
// fragment to a server, so the code stays out of access logs; the page reads
// it in the browser and posts it to /_user/api/v1/backend-link/show.
func (p *Plugin) startFrontendLink(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) {
plain, _, err := p.backendLinks().CreateLinkRequest(ctx, in.AdminID)
if err != nil {
@@ -119,7 +122,7 @@ func (p *Plugin) startFrontendLink(ctx context.Context, in pact.AccountActionInp
}
return pact.AccountActionResult{
Message: backendLinkLang + "link_started",
URL: classes.FrontendURL(p.app) + "/link-admin?code=" + plain,
URL: classes.FrontendURL(p.app) + classes.BackendLinkPagePath(p.app) + "#code=" + plain,
}, nil
}

View File

@@ -0,0 +1,103 @@
package user
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"github.com/golang-jwt/jwt/v5"
)
// craftSignInToken is craftToken with an auth_time claim: a session token
// issued at iat for a sign-in at authTime.
func craftSignInToken(t *testing.T, sub, jti string, iat, authTime time.Time) string {
t.Helper()
claims := jwt.MapClaims{
"sub": sub,
"iss": "http://example.com/_user/api/v1/login",
"iat": iat.Unix(),
"nbf": iat.Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
"jti": jti,
"prv": prvHash,
bouncer.ClaimAuthTime: authTime.Unix(),
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(sessionSecret))
if err != nil {
t.Fatal(err)
}
return signed
}
// TestBackendLinkAuthTimeSmoke: the account link proof is the sign-in time.
// A session signed in before the request and refreshed after it is refused
// with stale_session, a sign-in after the request confirms, POST show
// answers the administrator for the code in the body, and My account's URL
// carries the code in the fragment, never in a query string. Skips under
// -short.
func TestBackendLinkAuthTimeSmoke(t *testing.T) {
env := newAdminEnv(t)
user := env.seedUser(t, "auth-time", nil)
sub := strconv.FormatUint(uint64(user.ID), 10)
admin := env.admin(t)
login := env.adminLogin(t, adminID(t, admin))
// 1. The user signed in an hour ago.
old := craftSignInToken(t, sub, "auth-time-old-"+adminStamp(), time.Now().Add(-time.Hour), time.Now().Add(-time.Hour))
// 2. The administrator starts a link; 6. its URL holds the code in the
// fragment only.
var started struct {
URL string `json:"url"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), admin, map[string]any{}), &started)
if !strings.Contains(started.URL, "/link-admin#code=") || strings.Contains(started.URL, "?") {
t.Fatalf("link URL = %q", started.URL)
}
m := linkURL.FindStringSubmatch(started.URL)
if m == nil {
t.Fatalf("link URL = %q", started.URL)
}
code := m[1]
// 5. POST show answers the administrator for the code in the body.
rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/backend-link/show", "", map[string]string{"code": code})
var shown struct {
Data struct {
Admin map[string]any `json:"admin"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &shown); err != nil || shown.Data.Admin["login"] != login {
t.Fatalf("POST show = %s (%v)", rec.Body.String(), err)
}
if got := rec.Header().Get("Cache-Control"); got != "no-store, private" {
t.Fatalf("POST show Cache-Control = %q", got)
}
env.expect(t, http.StatusNotFound, http.MethodPost, "/_user/api/v1/backend-link/show", "", map[string]string{"code": strings.Repeat("0", 64)})
// 3. Refreshing the old session after the request does not make it a
// fresh sign-in.
rec = env.refresh(t, old)
var refreshed struct {
Token string `json:"token"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &refreshed); rec.Code != http.StatusOK || err != nil || refreshed.Token == "" {
t.Fatalf("refresh = %d %s", rec.Code, rec.Body.String())
}
if status, body := env.confirmLink(t, refreshed.Token, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
t.Fatalf("refreshed confirm = %d %s", status, body)
}
// 4. A sign-in after the request confirms.
status, fresh := env.userLogin(t, user.Email, "correct-horse")
if status != http.StatusOK || fresh == "" {
t.Fatalf("login = %d", status)
}
if status, body := env.confirmLink(t, fresh, code); status != http.StatusOK || !strings.Contains(body, `"login":"`+login+`"`) {
t.Fatalf("fresh confirm = %d %s", status, body)
}
}

View File

@@ -113,7 +113,7 @@ func (e *adminEnv) frontendSection(t *testing.T, token string) accountSection {
return accountSection{}
}
var linkURL = regexp.MustCompile(`^https://app\.example\.test/link-admin\?code=([a-f0-9]{64})$`)
var linkURL = regexp.MustCompile(`^https://app\.example\.test/link-admin#code=([a-f0-9]{64})$`)
// startLink runs My account's link action and returns the code in its URL.
func (e *adminEnv) startLink(t *testing.T, token string) string {

View File

@@ -31,8 +31,9 @@ func (e *adminEnv) requestCreatedAt(t *testing.T, adminID uint) time.Time {
}
// TestBackendLink (D-03..D-08) extends the link smokes: the fresh sign-in
// rule at the second (an iat equal to the request's created_at confirms,
// one second earlier is stale_session); 1:1 on the frontend side too (a
// rule at the second (an auth_time equal to the request's created_at
// confirms, one second earlier is stale_session, and a token without
// auth_time is stale_session whatever its iat); 1:1 on the frontend side too (a
// linked user cannot confirm another administrator's request); the show
// route exposes the administrator's login and name and the expiry only;
// SetLink records linked_by and fires BackendLinkedEvent and both subject
@@ -54,13 +55,17 @@ func TestBackendLink(t *testing.T) {
sub := strconv.FormatUint(uint64(user.ID), 10)
code := env.startLink(t, token)
created := env.requestCreatedAt(t, admin).Truncate(time.Second)
early := craftToken(t, sub, "early-"+adminStamp(), created.Add(-time.Second), time.Now().Add(time.Hour))
early := craftSignInToken(t, sub, "early-"+adminStamp(), time.Now(), created.Add(-time.Second))
if status, body := env.confirmLink(t, early, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
t.Fatalf("iat one second early = %d %s", status, body)
t.Fatalf("auth_time one second early = %d %s", status, body)
}
exact := craftToken(t, sub, "exact-"+adminStamp(), created, time.Now().Add(time.Hour))
noAuthTime := craftToken(t, sub, "legacy-"+adminStamp(), time.Now(), time.Now().Add(time.Hour))
if status, body := env.confirmLink(t, noAuthTime, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
t.Fatalf("no auth_time = %d %s", status, body)
}
exact := craftSignInToken(t, sub, "exact-"+adminStamp(), created, created)
if status, body := env.confirmLink(t, exact, code); status != http.StatusOK {
t.Fatalf("iat equal to created_at = %d %s", status, body)
t.Fatalf("auth_time equal to created_at = %d %s", status, body)
}
})

View File

@@ -71,6 +71,27 @@ func BackendLinkRequestTTL(app *backpack.App) (time.Duration, error) {
return ttl, nil
}
// DefaultBackendLinkPagePath is the site page the account link points to when
// golem15.user.backend_links.page_path is not set.
const DefaultBackendLinkPagePath = "/link-admin"
// BackendLinkPagePath reads golem15.user.backend_links.page_path, the path of
// the site page that confirms an account link (default /link-admin). A value
// without a leading slash gets one.
func BackendLinkPagePath(app *backpack.App) string {
if app == nil || app.Config == nil {
return DefaultBackendLinkPagePath
}
path := strings.TrimSpace(app.Config.String("golem15.user.backend_links.page_path"))
if path == "" {
return DefaultBackendLinkPagePath
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
// FrontendURL is the site the plugin sends people to for pages it does not
// serve itself (the account link page): golem15.user.frontend_url, falling
// back to app.url, without a trailing slash. Empty when neither is set.
@@ -203,14 +224,17 @@ func requestUsable(req *models.BackendLinkRequest, now time.Time) error {
// ConfirmLinkRequest stores the link between the signed-in frontend user and
// the administrator who created the request, in one transaction: the request
// row is locked, must be unused and unexpired, and the session must have
// been signed in at or after the request was created (sessionIssuedAt is
// the JWT iat, compared in whole seconds because iat has no fraction), so a
// browser that was already signed in has to sign in again (D-05). Neither
// been signed in at or after the request was created, so a browser that was
// already signed in has to sign in again (D-05). sessionAuthTime is the
// session token's auth_time claim (bouncer.VerifyAuthTime): the time of the
// sign-in itself, which a refresh copies and never renews. It is compared in
// whole seconds with the request's created_at because the claim has no
// fraction; a zero time is ErrStaleSession. Neither
// account may be linked already (ErrAlreadyLinked) and the administrator
// must still be activated (ErrBackendUserUnavailable). The request is used
// up with the link; BackendLinkedEvent fires inside the transaction and the
// two pact.MCPSubjectChanged events after it commits.
func (s *BackendLinks) ConfirmLinkRequest(ctx context.Context, plain string, frontendUserID uint, sessionIssuedAt time.Time) (*models.BackendLink, error) {
func (s *BackendLinks) ConfirmLinkRequest(ctx context.Context, plain string, frontendUserID uint, sessionAuthTime time.Time) (*models.BackendLink, error) {
if !validLinkCode(plain) {
return nil, ErrLinkRequestNotFound
}
@@ -236,7 +260,7 @@ func (s *BackendLinks) ConfirmLinkRequest(ctx context.Context, plain string, fro
if err := requestUsable(req, now); err != nil {
return err
}
if sessionIssuedAt.IsZero() || sessionIssuedAt.Before(req.CreatedAt.Truncate(time.Second)) {
if sessionAuthTime.IsZero() || sessionAuthTime.Before(req.CreatedAt.Truncate(time.Second)) {
return ErrStaleSession
}
if _, err := activeBackendUser(ctx, tx, req.BackendUserID); err != nil {

View File

@@ -38,10 +38,15 @@ privileged_groups:
# refuses them. The turnstile:* middleware comes from the framework (summercms
# v0.3.0 and later); its secret is turnstile.secret (SUMMER_TURNSTILE__SECRET).
turnstile: ""
# The site this plugin's links point to (the account link page
# /link-admin?code=...). Empty falls back to app.url.
# The site this plugin's links point to (the account link page,
# <frontend_url><backend_links.page_path>#code=...). Empty falls back to
# app.url.
frontend_url: ""
backend_links:
# How long a link request an administrator starts on My account stays
# usable (a Go duration). The code is one-shot.
request_ttl: 10m
# The site page that confirms a link. My account sends the administrator
# to <frontend_url><page_path>#code=<code>; the code is in the fragment,
# which browsers never send to a server.
page_path: /link-admin

View File

@@ -9,6 +9,7 @@ import (
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/wire"
)
// backendLinks returns the published link service, or one over app.
@@ -33,30 +34,62 @@ func linkNotFound(w http.ResponseWriter) {
// and display name and when the code expires, nothing else. Anyone holding
// the code may read it; a code that is unknown, used or expired, or whose
// administrator was deactivated, answers 404.
//
// Deprecated: the code sits in the request path, which access logs record.
// Pages call BackendLinkShowPost (POST /_user/api/v1/backend-link/show with
// the code in the body); this route stays for compatibility.
func BackendLinkShow(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
req, admin, err := backendLinks(app).LinkRequestByCode(r.Context(), r.PathValue("code"))
showLinkRequest(w, r, app, r.PathValue("code"))
}
}
// 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.
func BackendLinkShowPost(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fields, err := readFields(r)
if err != nil {
if isLinkGone(err) {
linkNotFound(w)
return
}
writeOpaque500(w)
writeNoStoreJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"admin": map[string]any{"login": admin.Login, "name": classes.BackendDisplayName(admin)},
"expires_at": req.ExpiresAt.UTC().Format(time.RFC3339),
}})
showLinkRequest(w, r, app, strField(fields, "code"))
}
}
// showLinkRequest answers the link request behind code. Both show routes
// mark every answer as not storable, since it is keyed by a secret.
func showLinkRequest(w http.ResponseWriter, r *http.Request, app *backpack.App, code string) {
req, admin, err := backendLinks(app).LinkRequestByCode(r.Context(), code)
if err != nil {
if isLinkGone(err) {
writeNoStoreJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeOpaque500(w)
return
}
writeNoStoreJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"admin": map[string]any{"login": admin.Login, "name": classes.BackendDisplayName(admin)},
"expires_at": req.ExpiresAt.UTC().Format(time.RFC3339),
}})
}
// writeNoStoreJSON is writeJSON with Cache-Control: no-store, private.
func writeNoStoreJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Cache-Control", "no-store, private")
wire.WriteJSON(w, status, v)
}
// BackendLinkConfirm serves POST /_user/api/v1/backend-link/confirm behind
// jwt.auth: body {"code": ...}. The signed-in frontend user is linked to the
// administrator who created the code, only when the session token was
// issued at or after the request (the user signed in fresh for it): an
// older session answers 409 with the stale-session error, an account already linked on
// either side 409 already_linked, an unknown, used or expired code 404.
// administrator who created the code, only when the session was signed in at
// or after the request: the token's auth_time (bouncer.VerifyAuthTime), which
// a refresh copies and never renews, is the proof. An older sign-in, or a
// 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.
func BackendLinkConfirm(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
principal, ok := bouncer.User(r.Context())
@@ -70,19 +103,23 @@ func BackendLinkConfirm(app *backpack.App) http.HandlerFunc {
return
}
// The token jwt.auth accepted (bearer header, then the session
// cookies), read again for its iat.
sub, iat, _, _, err := bouncer.VerifyClaims(classes.SessionToken(r), secret)
// cookies), read again for its sign-in time.
sub, authTime, signedIn, err := bouncer.VerifyAuthTime(classes.SessionToken(r), secret, bouncer.AudienceUser)
if err != nil || sub != strconv.FormatUint(uint64(principal.ID), 10) {
writeUnauthorized(w)
return
}
if !signedIn {
writeJSON(w, http.StatusConflict, map[string]any{"error": "stale_session"})
return
}
fields, err := readFields(r)
if err != nil {
linkNotFound(w)
return
}
links := backendLinks(app)
_, err = links.ConfirmLinkRequest(r.Context(), strField(fields, "code"), principal.ID, iat)
_, err = links.ConfirmLinkRequest(r.Context(), strField(fields, "code"), principal.ID, authTime)
switch {
case err == nil:
case errors.Is(err, classes.ErrStaleSession):

View File

@@ -95,10 +95,11 @@ func TestPasswordPublicRoutes(t *testing.T) {
if err != nil {
t.Fatalf("unset: Routes error %v", err)
}
// 18 routes up to v0.1.9, plus GET /backend-link/{code} and POST
// /backend-link/confirm (the account link) and GET /token/me.
if len(base) != 21 {
t.Fatalf("unset: %d routes, want 21", len(base))
// 18 routes up to v0.1.9, plus GET /backend-link/{code}, POST
// /backend-link/show and POST /backend-link/confirm (the account link)
// and GET /token/me.
if len(base) != 22 {
t.Fatalf("unset: %d routes, want 22", len(base))
}
seen := 0
for _, route := range base {
@@ -110,8 +111,8 @@ func TestPasswordPublicRoutes(t *testing.T) {
t.Fatalf("unset: found %d of the password routes, want %d", seen, len(passwordRouteKeys))
}
off := withoutPasswordRoutes(base)
if len(off) != 18 {
t.Fatalf("base without the password routes has %d routes, want 18", len(off))
if len(off) != 19 {
t.Fatalf("base without the password routes has %d routes, want 19", len(off))
}
for _, c := range []struct {

View File

@@ -44,6 +44,7 @@ func (p *Plugin) Routes(r pact.Router) error {
g.Where("id", "[0-9]+")
g.Get("/backend-link/{code}", controllers.BackendLinkShow(p.app))
g.Where("code", "[a-f0-9]{64}")
g.Post("/backend-link/show", controllers.BackendLinkShowPost(p.app))
g.Post("/backend-link/confirm", controllers.BackendLinkConfirm(p.app), "jwt.auth")
g.Get("/token/me", controllers.TokenMe(p.app), "user.api_token", "user.scope:read")
})