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". For users it // derives from the role (viewer -> read, admin -> admin); for keys it is the // key's scope; for the service identity it is always admin (the flags are the // built-in operator). 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" 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. Unknown = 0. func levelRank(level string) int { switch level { case "read": return 1 case "write": return 2 case "admin": return 3 } return 0 } // roleLevel maps a user role to an access level (viewer -> read, admin -> admin). func roleLevel(role string) string { if role == "admin" { return "admin" } 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. It accepts either // HTTP Basic (users table bcrypt, with a flag-creds admin fast path that keeps // inter-node cluster traffic working) or a Bearer API key (sha256-looked-up). // Identities below minLevel get 403; missing/bad credentials get 401. // // The flag-creds fast path is checked BEFORE the users table: inter-node token // relay and the browser-cached Basic header 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 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: "admin"} } 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 == "" { w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc", Bearer realm="webui4frpc-apikey"`) 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)) } } }