mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
- cookie-based auth (/login /logout) replacing Basic Auth for UI, enabling logout - roles: superadmin (account management only), admin (full except accounts), viewer/audit (read-only status+cluster, export logs) - readonly accounts hide edit buttons (added remote node, group ops, canvas layout/save/import, cluster manage, install) instead of greying them - auditors see status+cluster only; ordinary admins lose the accounts nav; last-admin guard covers superadmin - remove pink theme entirely (switcher, [data-theme=pink], leftover localStorage), keep white/blue
164 lines
6.1 KiB
Go
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))
|
|
}
|
|
}
|
|
}
|