package httpapi import ( "context" "crypto/subtle" "net/http" "strings" "webui4frpc/internal/store" ) // identity is the authenticated principal for a request, stashed in the // request context so handlers can read who/what level the caller is. // // Type is one of: // - "user": a row in the users table, authenticated via Basic + bcrypt. // - "service": the -user/-password flag creds (inter-node cluster traffic // and bootstrap), authenticated via a constant-time compare. // - "key": an API key, authenticated via Bearer + sha256 lookup. // // Level is the effective access tier: "read" | "write" | "admin" | "superadmin". // For users it derives from the role (viewer -> read, admin -> admin, // superadmin -> superadmin); for keys it is the key's scope; for the service // identity (the -user/-password flags, the built-in operator) it is always // superadmin so account bootstrap/management keeps working. type identity struct { Type string `json:"type"` // "user" | "service" | "key" Name string `json:"name"` // username / flag user / key label Level string `json:"level"` // "read" | "write" | "admin" | "superadmin" UserID int64 `json:"userId"` // users.id for Type=="user"|"key" } type ctxKey struct{} // identityFrom returns the authenticated identity from the request context, // or a zero identity (Level=="") when unauthenticated. func identityFrom(r *http.Request) identity { if v, ok := r.Context().Value(ctxKey{}).(identity); ok { return v } return identity{} } // levelRank orders the access tiers: read < write < admin < superadmin. // Unknown = 0. func levelRank(level string) int { switch level { case "read": return 1 case "write": return 2 case "admin": return 3 case "superadmin": return 4 } return 0 } // roleLevel maps a user role to an access level. viewer -> read, admin -> // admin, superadmin -> superadmin. A superadmin is the only role that may // manage accounts (the users/apikeys routes require "superadmin"). func roleLevel(role string) string { switch role { case "admin": return "admin" case "superadmin": return "superadmin" } return "read" } // hasLevel reports whether the request's identity meets minLevel. Handlers // serving both read and mutating methods on one path use this to gate the // mutating branch (the route itself is registered at the read level so that // viewer GETs succeed, then PUT/POST/DELETE branches re-check here). func hasLevel(r *http.Request, minLevel string) bool { return levelRank(identityFrom(r).Level) >= levelRank(minLevel) } // forbidden writes a 403 with a small JSON body. func forbidden(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"error":"forbidden: insufficient scope"}`)) } // auth wraps a handler with authentication AND authorization. Requests split // into two worlds: // - SPA traffic (X-W4F-UI header, set by web/src/api.ts) authenticates ONLY // by the session cookie issued at POST /api/manager/login. Basic/Bearer // headers are ignored so the browser never falls back to cached Basic // credentials — that is what makes logout work. // - everything else (curl/API clients, inter-node relay) resolves the // principal from the session cookie, then HTTP Basic (users table bcrypt, // with a flag-creds admin fast path that keeps inter-node cluster traffic // working), then a Bearer API key (sha256-looked-up). // // Identities below minLevel get 403; missing/bad credentials get 401. The 401 // deliberately omits WWW-Authenticate: that header would make the browser pop // its Basic-Auth prompt and cache the credentials, which can never be cleared // from JS and would defeat the session-based logout. // // The flag-creds fast path is checked BEFORE the users table: inter-node token // relay and API Basic clients hit it on every request, and a bcrypt verify per // hop would be wasteful; the flags are the built-in operator and are synced // into a system=1 admin row by SyncSystemUser anyway, so this shortcut grants // no privilege that the flags themselves do not already confer. func (h *Handler) auth(minLevel string) func(http.HandlerFunc) http.HandlerFunc { need := levelRank(minLevel) return func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var id identity switch { case r.Header.Get("X-W4F-UI") == "1": // SPA requests: the session cookie is the ONLY accepted credential. // Basic/Bearer headers are ignored here — including Basic creds the // browser cached before sessions existed — so a cleared cookie truly // signs the browser out (logout cannot work through cached Basic). if h.Sessions != nil { id, _ = h.Sessions.validate(r) } case h.Sessions != nil: if sid, ok := h.Sessions.validate(r); ok { id = sid break } fallthrough case strings.HasPrefix(r.Header.Get("Authorization"), "Basic "): u, p, ok := r.BasicAuth() if !ok { break } if subtle.ConstantTimeCompare([]byte(u), []byte(h.User)) == 1 && subtle.ConstantTimeCompare([]byte(p), []byte(h.Password)) == 1 { id = identity{Type: "service", Name: h.User, Level: "superadmin"} } else if usr, ok := h.Store.VerifyUserPassword(u, p); ok { id = identity{Type: "user", Name: usr.Username, Level: roleLevel(usr.Role), UserID: usr.ID} _ = h.Store.TouchUserLogin(usr.ID) } case strings.HasPrefix(r.Header.Get("Authorization"), "Bearer "): key := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if k, usr, ok := h.Store.LookupApiKey(store.HashApiKey(key)); ok { id = identity{Type: "key", Name: k.Label, Level: k.Scope, UserID: usr.ID} _ = h.Store.TouchApiKey(k.ID) } } if id.Level == "" { // No WWW-Authenticate here on purpose (see comment above). w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) return } if levelRank(id.Level) < need { forbidden(w) return } ctx := context.WithValue(r.Context(), ctxKey{}, id) next(w, r.WithContext(ctx)) } } }