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

@@ -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):