package cabana import ( "crypto/subtle" "errors" "log/slog" "net" "net/http" "strings" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" ) // ErrUnauthenticated is returned by ResolveBackend when the request has no // usable Bearer JWT or personal API token. var ErrUnauthenticated = errors.New("cabana: unauthenticated") // ErrTokenScope is returned when a personal API token is authenticated but // its scopes do not allow the request method or cover codes. var ErrTokenScope = errors.New("cabana: token scope") // scopeError wraps ErrTokenScope so WriteUnauthorized can answer 403. // errors.Is(err, ErrTokenScope) is true. type scopeError struct { err error } func (e *scopeError) Error() string { if e == nil || e.err == nil { return ErrTokenScope.Error() } return e.err.Error() } func (e *scopeError) Unwrap() error { return ErrTokenScope } func newScopeError() error { return &scopeError{err: ErrTokenScope} } // compositeGuard routes Bearer g15_… values to the personal API token path and // every other request to the wrapped JWT guard. WriteUnauthorized answers 403 // for a scopeError and the usual 401 unauthenticated envelope otherwise. type compositeGuard struct { jwt bouncer.Guard store tokenStore users bouncer.UserProvider reg *Registry // stamps debounces last_used_at writes for this guard (see stampThrottle). stamps *stampThrottle } var ( _ bouncer.CredentialGuard = (*compositeGuard)(nil) _ bouncer.UnauthorizedWriter = (*compositeGuard)(nil) ) func newCompositeGuard(jwt bouncer.Guard, store tokenStore, users bouncer.UserProvider, reg *Registry) *compositeGuard { return &compositeGuard{jwt: jwt, store: store, users: users, reg: reg, stamps: &stampThrottle{}} } func (g *compositeGuard) AuthenticateCredential(r *http.Request) (*bouncer.Principal, any, error) { if g == nil || r == nil { return nil, nil, ErrUnauthenticated } raw := authorizationBearer(r) if strings.HasPrefix(raw, APITokenPrefix) { return g.authenticateToken(r, raw) } if g.jwt == nil { return nil, nil, ErrUnauthenticated } p, err := g.jwt.Authenticate(r) return p, nil, err } func (g *compositeGuard) authenticateToken(r *http.Request, raw string) (*bouncer.Principal, any, error) { if !wellFormedAPIToken(raw) { logTokenAuth(r, "failed", 0, 0) return nil, nil, ErrUnauthenticated } if g.store == nil { logTokenAuth(r, "failed", 0, 0) return nil, nil, ErrUnauthenticated } now := time.Now().UTC() hash := hashAPIToken(raw) row, err := g.store.FindUsableByHash(r.Context(), hash, now) if err != nil || row == nil { logTokenAuth(r, "failed", 0, 0) return nil, nil, ErrUnauthenticated } if subtle.ConstantTimeCompare([]byte(row.TokenHash), []byte(hash)) != 1 { logTokenAuth(r, "failed", 0, row.ID) return nil, nil, ErrUnauthenticated } if g.users == nil { logTokenAuth(r, "failed", 0, row.ID) return nil, nil, ErrUnauthenticated } principal, err := g.users.FindByID(r.Context(), row.BackendUserID) if err != nil || principal == nil { logTokenAuth(r, "failed", row.BackendUserID, row.ID) return nil, nil, ErrUnauthenticated } stored := row.Scopes.Get() if !row.Scopes.Valid { stored = nil } limit, known, methodOK := methodFilterScopes(g.reg.tokenScopes, stored, r.Method) if known == 0 { logTokenAuth(r, "failed", principal.ID, row.ID) return nil, nil, ErrUnauthenticated } if methodOK == 0 { logTokenAuth(r, "denied", principal.ID, row.ID) return nil, nil, newScopeError() } principal.Scope = limit stampLastUsed(r.Context(), g.stamps, g.store, row.ID, now) logTokenAuth(r, "success", principal.ID, row.ID) return principal, row, nil } // WriteUnauthorized answers 403 forbidden with details.reason token_scope for // a scopeError; every other failure uses the 401 unauthenticated envelope. func (g *compositeGuard) WriteUnauthorized(w http.ResponseWriter, err error) { if errors.Is(err, ErrTokenScope) { WriteErrorDetails(w, http.StatusForbidden, "forbidden", msgForbidden, map[string]any{ "reason": "token_scope", }) return } writeUnauthenticated(w, err) } func authorizationBearer(r *http.Request) string { if r == nil { return "" } token, ok := strings.CutPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ") if !ok { return "" } return strings.TrimSpace(token) } // interactiveOnly refuses personal API tokens (credential or raw g15_ Bearer) // with 403 forbidden details.reason interactive_only. Used on account, token, // refresh and logout routes that must stay session/JWT-only. func interactiveOnly(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if cred, _ := bouncer.Credential(r.Context()); cred != nil { if _, ok := cred.(*BackendAPIToken); ok { WriteErrorDetails(w, http.StatusForbidden, "forbidden", msgForbidden, map[string]any{ "reason": "interactive_only", }) return } } if strings.HasPrefix(authorizationBearer(r), APITokenPrefix) { WriteErrorDetails(w, http.StatusForbidden, "forbidden", msgForbidden, map[string]any{ "reason": "interactive_only", }) return } next(w, r) } } // tokenRouteGate refuses a non-full scoped principal with 403 token_scope // (same as Allows(principal, nil) refusing). Used on routes that never call // Allows, such as /field-types and /markdown/preview. func tokenRouteGate(principal *bouncer.Principal, w http.ResponseWriter) bool { if principal != nil && principal.Scope != nil && !principal.Scope.Full { WriteErrorDetails(w, http.StatusForbidden, "forbidden", msgForbidden, map[string]any{ "reason": "token_scope", }) return false } return true } // backendResolver is the published cookie-less composite guard used by // ResolveBackend on plugin routes outside the admin middleware. type backendResolver struct { guard *compositeGuard } // ResolveBackend authenticates r from the Authorization Bearer header only // (admin JWT or g15_ personal API token — never the admin cookie). For a // token it requires a method-allowed scope that is full or matches every // cover code. Routes registered before cabana.Activate must call this per // request (lookup the published resolver each time). Returns // ErrUnauthenticated or ErrTokenScope. func ResolveBackend(app *backpack.App, r *http.Request, cover ...string) (*http.Request, *bouncer.Principal, error) { if app == nil || r == nil || authorizationBearer(r) == "" { return nil, nil, ErrUnauthenticated } res, ok := app.Lookup[*backendResolver]() if !ok || res == nil || res.guard == nil { return nil, nil, ErrUnauthenticated } principal, cred, err := res.guard.AuthenticateCredential(r) if err != nil { if errors.Is(err, ErrTokenScope) { return nil, nil, ErrTokenScope } return nil, nil, ErrUnauthenticated } if principal == nil { return nil, nil, ErrUnauthenticated } if principal.Scope != nil && !principal.Scope.Full { if !scopeCoversAll(principal.Scope, cover) { return nil, nil, ErrTokenScope } } ctx := bouncer.WithUser(r.Context(), principal) if cred != nil { ctx = bouncer.WithCredential(ctx, cred) } return r.WithContext(ctx), principal, nil } func logTokenAuth(r *http.Request, outcome string, adminID, tokenID uint) { remote := "" method := "" path := "" if r != nil { method = r.Method if r.URL != nil { path = r.URL.Path } remote = r.RemoteAddr if host, _, err := net.SplitHostPort(remote); err == nil { remote = host } } args := []any{"outcome", outcome, "method", method, "path", path, "remote", remote} if adminID != 0 { args = append(args, "admin_id", adminID) } if tokenID != 0 { args = append(args, "token_id", tokenID) } slog.Default().Info("admin.auth", args...) }