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:
JianFeeeee
2026-08-24 23:30:19 +08:00
28 changed files with 1143 additions and 1084 deletions

View File

@ -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"}`))