- golem15_user_backend_links (1:1, both sides unique) and one-shot hashed
golem15_user_backend_link_requests (golem15.user.backend_links.request_ttl, 10m)
- classes.BackendLinks: CreateLinkRequest, ConfirmLinkRequest (row lock,
JWT iat not before the request), SetLink, RemoveLink, LinkedBackendUser;
published as *classes.BackendLinks and pact.MCPLinkedBackendResolver
- GET /_user/api/v1/backend-link/{code} and POST /backend-link/confirm (jwt.auth)
- My account section frontend_account with link and unlink
- BackendLinkedEvent, BackendUnlinkedEvent and pact.MCPSubjectChanged after commit;
deleting or deactivating a frontend user removes its link
- golem15.user.frontend_url (falls back to app.url)
119 lines
3.9 KiB
Go
119 lines
3.9 KiB
Go
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"
|
|
)
|
|
|
|
// backendLinks returns the published link service, or one over app.
|
|
func backendLinks(app *backpack.App) *classes.BackendLinks {
|
|
if app != nil {
|
|
if links, ok := app.Lookup[*classes.BackendLinks](); ok && links != nil {
|
|
return links
|
|
}
|
|
}
|
|
return &classes.BackendLinks{App: 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.
|
|
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"))
|
|
if err != nil {
|
|
if isLinkGone(err) {
|
|
linkNotFound(w)
|
|
return
|
|
}
|
|
writeOpaque500(w)
|
|
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),
|
|
}})
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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 iat.
|
|
sub, iat, _, _, err := bouncer.VerifyClaims(classes.SessionToken(r), secret)
|
|
if err != nil || sub != strconv.FormatUint(uint64(principal.ID), 10) {
|
|
writeUnauthorized(w)
|
|
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)
|
|
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)
|
|
}
|