package controllers import ( "errors" "net/http" "strconv" "time" "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. func backendLinks(app *backpack.App) *classes.BackendLinks { return classes.BackendLinksFor(app) } // linkNotFound is the one answer for an unknown, used or expired code and // for an administrator who is no longer available: the page cannot tell // them apart. func linkNotFound(w http.ResponseWriter) { writeJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"}) } // BackendLinkShow serves GET /_user/api/v1/backend-link/{code}, the site's // "link this account to the administrator?" page: the administrator's login // 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) { 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; 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 := readBodyFields(r) if err != nil { writeNoStoreJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"}) return } 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 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. // 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()) if !ok || principal == nil || principal.ID == 0 { writeUnauthorized(w) return } secret, err := classes.JWTSecret(app) if err != nil { writeOpaque500(w) return } // The token jwt.auth accepted (bearer header, then the session // 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 := readBodyFields(r) if err != nil { linkNotFound(w) return } links := backendLinks(app) _, err = links.ConfirmLinkRequest(r.Context(), strField(fields, "code"), principal.ID, authTime) switch { case err == nil: case errors.Is(err, classes.ErrStaleSession): writeJSON(w, http.StatusConflict, map[string]any{"error": "stale_session"}) return case errors.Is(err, classes.ErrAlreadyLinked): writeJSON(w, http.StatusConflict, map[string]any{"error": "already_linked"}) return case isLinkGone(err): linkNotFound(w) return default: writeOpaque500(w) return } linked, found, err := links.LinkedBackendUser(r.Context(), principal.ID) if err != nil || !found { writeOpaque500(w) return } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ "backend_user": map[string]any{"login": linked.Login, "name": linked.DisplayName}, }}) } } func isLinkGone(err error) bool { return errors.Is(err, classes.ErrLinkRequestNotFound) || errors.Is(err, classes.ErrLinkRequestUsed) || errors.Is(err, classes.ErrLinkRequestExpired) || errors.Is(err, classes.ErrBackendUserUnavailable) || errors.Is(err, classes.ErrFrontendUserUnavailable) }