Files
webui4frpc/internal/httpapi/auth.go
JianFeeeee f90627f542 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	internal/httpapi/dist/assets/index-CL42Ur3_.css
#	internal/httpapi/dist/assets/index-CbqC9m-j.js
#	internal/httpapi/dist/assets/index-RFHYrCiX.css
#	internal/httpapi/dist/assets/index-jvouiCgh.css
#	internal/httpapi/dist/index.html
#	internal/store/store.go
#	web/src/api.ts
#	web/src/views/ClusterView.vue
2026-08-24 23:30:19 +08:00

164 lines
6.1 KiB
Go

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))
}
}
}