mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-21 17:37:56 +00:00
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
This commit is contained in:
@ -18,14 +18,15 @@ import (
|
||||
// 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).
|
||||
// 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"
|
||||
Level string `json:"level"` // "read" | "write" | "admin" | "superadmin"
|
||||
UserID int64 `json:"userId"` // users.id for Type=="user"|"key"
|
||||
}
|
||||
|
||||
@ -40,7 +41,8 @@ func identityFrom(r *http.Request) identity {
|
||||
return identity{}
|
||||
}
|
||||
|
||||
// levelRank orders the access tiers: read < write < admin. Unknown = 0.
|
||||
// levelRank orders the access tiers: read < write < admin < superadmin.
|
||||
// Unknown = 0.
|
||||
func levelRank(level string) int {
|
||||
switch level {
|
||||
case "read":
|
||||
@ -49,14 +51,21 @@ func levelRank(level string) int {
|
||||
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).
|
||||
// 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 {
|
||||
if role == "admin" {
|
||||
switch role {
|
||||
case "admin":
|
||||
return "admin"
|
||||
case "superadmin":
|
||||
return "superadmin"
|
||||
}
|
||||
return "read"
|
||||
}
|
||||
@ -76,22 +85,47 @@ func forbidden(w http.ResponseWriter) {
|
||||
_, _ = 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.
|
||||
// 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 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.
|
||||
// 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 {
|
||||
@ -99,7 +133,7 @@ func (h *Handler) auth(minLevel string) func(http.HandlerFunc) http.HandlerFunc
|
||||
}
|
||||
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"}
|
||||
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)
|
||||
@ -112,7 +146,7 @@ func (h *Handler) auth(minLevel string) func(http.HandlerFunc) http.HandlerFunc
|
||||
}
|
||||
}
|
||||
if id.Level == "" {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc", Bearer realm="webui4frpc-apikey"`)
|
||||
// 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"}`))
|
||||
|
||||
Reference in New Issue
Block a user