mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
feat: session-cookie login/logout + three-tier roles (superadmin/admin/viewer) with read-only UI + remove pink theme
- 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
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"}`))
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
468
internal/httpapi/dist/favicon.svg
vendored
468
internal/httpapi/dist/favicon.svg
vendored
@ -1,257 +1,257 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" height="500" shape-rendering="crispEdges">
|
||||
<defs>
|
||||
<!-- 主字母像素块(黑色) -->
|
||||
<rect id="p" width="12" height="12" fill="#000000" />
|
||||
<!-- frpc 小像素块(蓝色) -->
|
||||
<rect id="q" width="5" height="5" fill="#2563eb" />
|
||||
<!-- 装饰小方块(浅灰) -->
|
||||
<rect id="p" width="14" height="14" fill="#000000" />
|
||||
<rect id="q" width="7" height="7" fill="#2563eb" />
|
||||
<rect id="d" width="8" height="8" fill="#94a3b8" />
|
||||
<!-- 小十字星装饰 -->
|
||||
<rect id="s" width="6" height="6" fill="#94a3b8" />
|
||||
</defs>
|
||||
|
||||
<!-- 圆角白色背景(模拟 App 图标外框) -->
|
||||
<!-- 圆角白色背景 -->
|
||||
<rect width="500" height="500" rx="40" fill="#ffffff" stroke="#e2e8f0" stroke-width="4" />
|
||||
|
||||
<!-- ========== 主字母整体居中(横向间距更合理) ========== -->
|
||||
<g transform="translate(70, 160)">
|
||||
<!-- ===== W(基于原始结构,高度约 150px) ===== -->
|
||||
<!-- ========== 主字母 WUI ========== -->
|
||||
<g transform="translate(70, 100)">
|
||||
<!-- W -->
|
||||
<g transform="translate(0, 0)">
|
||||
<!-- 左竖线(占2列) -->
|
||||
<use href="#p" x="0" y="0" />
|
||||
<use href="#p" x="12" y="0" />
|
||||
<use href="#p" x="0" y="12" />
|
||||
<use href="#p" x="12" y="12" />
|
||||
<use href="#p" x="0" y="24" />
|
||||
<use href="#p" x="12" y="24" />
|
||||
<use href="#p" x="0" y="36" />
|
||||
<use href="#p" x="12" y="36" />
|
||||
<use href="#p" x="0" y="48" />
|
||||
<use href="#p" x="12" y="48" />
|
||||
<use href="#p" x="0" y="60" />
|
||||
<use href="#p" x="12" y="60" />
|
||||
<use href="#p" x="0" y="72" />
|
||||
<use href="#p" x="12" y="72" />
|
||||
<use href="#p" x="14" y="0" />
|
||||
<use href="#p" x="0" y="14" />
|
||||
<use href="#p" x="14" y="14" />
|
||||
<use href="#p" x="0" y="28" />
|
||||
<use href="#p" x="14" y="28" />
|
||||
<use href="#p" x="0" y="42" />
|
||||
<use href="#p" x="14" y="42" />
|
||||
<use href="#p" x="0" y="56" />
|
||||
<use href="#p" x="14" y="56" />
|
||||
<use href="#p" x="0" y="70" />
|
||||
<use href="#p" x="14" y="70" />
|
||||
<use href="#p" x="0" y="84" />
|
||||
<use href="#p" x="12" y="84" />
|
||||
<use href="#p" x="0" y="96" />
|
||||
<use href="#p" x="12" y="96" />
|
||||
<use href="#p" x="0" y="108" />
|
||||
<use href="#p" x="12" y="108" />
|
||||
<use href="#p" x="0" y="120" />
|
||||
<use href="#p" x="12" y="120" />
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
|
||||
<!-- 右竖线(占2列) -->
|
||||
<use href="#p" x="84" y="0" />
|
||||
<use href="#p" x="96" y="0" />
|
||||
<use href="#p" x="84" y="12" />
|
||||
<use href="#p" x="96" y="12" />
|
||||
<use href="#p" x="84" y="24" />
|
||||
<use href="#p" x="96" y="24" />
|
||||
<use href="#p" x="84" y="36" />
|
||||
<use href="#p" x="96" y="36" />
|
||||
<use href="#p" x="84" y="48" />
|
||||
<use href="#p" x="96" y="48" />
|
||||
<use href="#p" x="84" y="60" />
|
||||
<use href="#p" x="96" y="60" />
|
||||
<use href="#p" x="84" y="72" />
|
||||
<use href="#p" x="96" y="72" />
|
||||
<use href="#p" x="14" y="84" />
|
||||
<use href="#p" x="0" y="98" />
|
||||
<use href="#p" x="14" y="98" />
|
||||
<use href="#p" x="0" y="112" />
|
||||
<use href="#p" x="14" y="112" />
|
||||
<use href="#p" x="0" y="126" />
|
||||
<use href="#p" x="14" y="126" />
|
||||
<use href="#p" x="0" y="140" />
|
||||
<use href="#p" x="14" y="140" />
|
||||
<use href="#p" x="0" y="154" />
|
||||
<use href="#p" x="14" y="154" />
|
||||
<use href="#p" x="0" y="168" />
|
||||
<use href="#p" x="14" y="168" />
|
||||
<use href="#p" x="98" y="0" />
|
||||
<use href="#p" x="112" y="0" />
|
||||
<use href="#p" x="98" y="14" />
|
||||
<use href="#p" x="112" y="14" />
|
||||
<use href="#p" x="98" y="28" />
|
||||
<use href="#p" x="112" y="28" />
|
||||
<use href="#p" x="98" y="42" />
|
||||
<use href="#p" x="112" y="42" />
|
||||
<use href="#p" x="98" y="56" />
|
||||
<use href="#p" x="112" y="56" />
|
||||
<use href="#p" x="98" y="70" />
|
||||
<use href="#p" x="112" y="70" />
|
||||
<use href="#p" x="98" y="84" />
|
||||
<use href="#p" x="112" y="84" />
|
||||
<use href="#p" x="98" y="98" />
|
||||
<use href="#p" x="112" y="98" />
|
||||
<use href="#p" x="98" y="112" />
|
||||
<use href="#p" x="112" y="112" />
|
||||
<use href="#p" x="98" y="126" />
|
||||
<use href="#p" x="112" y="126" />
|
||||
<use href="#p" x="98" y="140" />
|
||||
<use href="#p" x="112" y="140" />
|
||||
<use href="#p" x="98" y="154" />
|
||||
<use href="#p" x="112" y="154" />
|
||||
<use href="#p" x="98" y="168" />
|
||||
<use href="#p" x="112" y="168" />
|
||||
<use href="#p" x="28" y="70" />
|
||||
<use href="#p" x="56" y="70" />
|
||||
<use href="#p" x="84" y="70" />
|
||||
<use href="#p" x="28" y="84" />
|
||||
<use href="#p" x="56" y="84" />
|
||||
<use href="#p" x="84" y="84" />
|
||||
<use href="#p" x="96" y="84" />
|
||||
<use href="#p" x="84" y="96" />
|
||||
<use href="#p" x="96" y="96" />
|
||||
<use href="#p" x="84" y="108" />
|
||||
<use href="#p" x="96" y="108" />
|
||||
<use href="#p" x="84" y="120" />
|
||||
<use href="#p" x="96" y="120" />
|
||||
<use href="#p" x="84" y="132" />
|
||||
<use href="#p" x="96" y="132" />
|
||||
|
||||
<!-- 中间 V 形(从 y=60 开始) -->
|
||||
<use href="#p" x="24" y="60" />
|
||||
<use href="#p" x="48" y="60" />
|
||||
<use href="#p" x="72" y="60" />
|
||||
<use href="#p" x="24" y="72" />
|
||||
<use href="#p" x="48" y="72" />
|
||||
<use href="#p" x="72" y="72" />
|
||||
<use href="#p" x="24" y="84" />
|
||||
<use href="#p" x="48" y="84" />
|
||||
<use href="#p" x="72" y="84" />
|
||||
<use href="#p" x="24" y="96" />
|
||||
<use href="#p" x="48" y="96" />
|
||||
<use href="#p" x="72" y="96" />
|
||||
<!-- 底部汇合 -->
|
||||
<use href="#p" x="36" y="108" />
|
||||
<use href="#p" x="60" y="108" />
|
||||
<use href="#p" x="36" y="120" />
|
||||
<use href="#p" x="60" y="120" />
|
||||
<use href="#p" x="36" y="132" />
|
||||
<use href="#p" x="60" y="132" />
|
||||
<use href="#p" x="48" y="132" />
|
||||
<use href="#p" x="28" y="98" />
|
||||
<use href="#p" x="56" y="98" />
|
||||
<use href="#p" x="84" y="98" />
|
||||
<use href="#p" x="28" y="112" />
|
||||
<use href="#p" x="56" y="112" />
|
||||
<use href="#p" x="84" y="112" />
|
||||
<use href="#p" x="42" y="126" />
|
||||
<use href="#p" x="70" y="126" />
|
||||
<use href="#p" x="42" y="140" />
|
||||
<use href="#p" x="70" y="140" />
|
||||
<use href="#p" x="42" y="154" />
|
||||
<use href="#p" x="70" y="154" />
|
||||
<use href="#p" x="56" y="154" />
|
||||
<use href="#p" x="56" y="168" />
|
||||
</g>
|
||||
|
||||
<!-- ===== U(高度与 W 一致,内部空间增大) ===== -->
|
||||
<g transform="translate(124, 0)">
|
||||
<!-- 左竖线(占2列) -->
|
||||
<!-- U -->
|
||||
<g transform="translate(144, 0)">
|
||||
<use href="#p" x="0" y="0" />
|
||||
<use href="#p" x="12" y="0" />
|
||||
<use href="#p" x="0" y="12" />
|
||||
<use href="#p" x="12" y="12" />
|
||||
<use href="#p" x="0" y="24" />
|
||||
<use href="#p" x="12" y="24" />
|
||||
<use href="#p" x="0" y="36" />
|
||||
<use href="#p" x="12" y="36" />
|
||||
<use href="#p" x="0" y="48" />
|
||||
<use href="#p" x="12" y="48" />
|
||||
<use href="#p" x="0" y="60" />
|
||||
<use href="#p" x="12" y="60" />
|
||||
<use href="#p" x="0" y="72" />
|
||||
<use href="#p" x="12" y="72" />
|
||||
<use href="#p" x="14" y="0" />
|
||||
<use href="#p" x="0" y="14" />
|
||||
<use href="#p" x="14" y="14" />
|
||||
<use href="#p" x="0" y="28" />
|
||||
<use href="#p" x="14" y="28" />
|
||||
<use href="#p" x="0" y="42" />
|
||||
<use href="#p" x="14" y="42" />
|
||||
<use href="#p" x="0" y="56" />
|
||||
<use href="#p" x="14" y="56" />
|
||||
<use href="#p" x="0" y="70" />
|
||||
<use href="#p" x="14" y="70" />
|
||||
<use href="#p" x="0" y="84" />
|
||||
<use href="#p" x="12" y="84" />
|
||||
<use href="#p" x="0" y="96" />
|
||||
<use href="#p" x="12" y="96" />
|
||||
<use href="#p" x="0" y="108" />
|
||||
<use href="#p" x="12" y="108" />
|
||||
<use href="#p" x="0" y="120" />
|
||||
<use href="#p" x="12" y="120" />
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
<use href="#p" x="14" y="84" />
|
||||
<use href="#p" x="0" y="98" />
|
||||
<use href="#p" x="14" y="98" />
|
||||
<use href="#p" x="0" y="112" />
|
||||
<use href="#p" x="14" y="112" />
|
||||
<use href="#p" x="0" y="126" />
|
||||
<use href="#p" x="14" y="126" />
|
||||
<use href="#p" x="0" y="140" />
|
||||
<use href="#p" x="14" y="140" />
|
||||
<use href="#p" x="0" y="154" />
|
||||
<use href="#p" x="14" y="154" />
|
||||
<use href="#p" x="0" y="168" />
|
||||
<use href="#p" x="14" y="168" />
|
||||
<use href="#p" x="98" y="0" />
|
||||
<use href="#p" x="112" y="0" />
|
||||
<use href="#p" x="98" y="14" />
|
||||
<use href="#p" x="112" y="14" />
|
||||
<use href="#p" x="98" y="28" />
|
||||
<use href="#p" x="112" y="28" />
|
||||
<use href="#p" x="98" y="42" />
|
||||
<use href="#p" x="112" y="42" />
|
||||
<use href="#p" x="98" y="56" />
|
||||
<use href="#p" x="112" y="56" />
|
||||
<use href="#p" x="98" y="70" />
|
||||
<use href="#p" x="112" y="70" />
|
||||
<use href="#p" x="98" y="84" />
|
||||
<use href="#p" x="112" y="84" />
|
||||
<use href="#p" x="98" y="98" />
|
||||
<use href="#p" x="112" y="98" />
|
||||
<use href="#p" x="98" y="112" />
|
||||
<use href="#p" x="112" y="112" />
|
||||
<use href="#p" x="98" y="126" />
|
||||
<use href="#p" x="112" y="126" />
|
||||
<use href="#p" x="98" y="140" />
|
||||
<use href="#p" x="112" y="140" />
|
||||
<use href="#p" x="98" y="154" />
|
||||
<use href="#p" x="112" y="154" />
|
||||
<use href="#p" x="98" y="168" />
|
||||
<use href="#p" x="112" y="168" />
|
||||
<use href="#p" x="0" y="168" />
|
||||
<use href="#p" x="14" y="168" />
|
||||
<use href="#p" x="28" y="168" />
|
||||
<use href="#p" x="42" y="168" />
|
||||
<use href="#p" x="56" y="168" />
|
||||
<use href="#p" x="70" y="168" />
|
||||
<use href="#p" x="84" y="168" />
|
||||
<use href="#p" x="98" y="168" />
|
||||
<use href="#p" x="112" y="168" />
|
||||
|
||||
<!-- 右竖线(占2列) -->
|
||||
<use href="#p" x="84" y="0" />
|
||||
<use href="#p" x="96" y="0" />
|
||||
<use href="#p" x="84" y="12" />
|
||||
<use href="#p" x="96" y="12" />
|
||||
<use href="#p" x="84" y="24" />
|
||||
<use href="#p" x="96" y="24" />
|
||||
<use href="#p" x="84" y="36" />
|
||||
<use href="#p" x="96" y="36" />
|
||||
<use href="#p" x="84" y="48" />
|
||||
<use href="#p" x="96" y="48" />
|
||||
<use href="#p" x="84" y="60" />
|
||||
<use href="#p" x="96" y="60" />
|
||||
<use href="#p" x="84" y="72" />
|
||||
<use href="#p" x="96" y="72" />
|
||||
<use href="#p" x="84" y="84" />
|
||||
<use href="#p" x="96" y="84" />
|
||||
<use href="#p" x="84" y="96" />
|
||||
<use href="#p" x="96" y="96" />
|
||||
<use href="#p" x="84" y="108" />
|
||||
<use href="#p" x="96" y="108" />
|
||||
<use href="#p" x="84" y="120" />
|
||||
<use href="#p" x="96" y="120" />
|
||||
<use href="#p" x="84" y="132" />
|
||||
<use href="#p" x="96" y="132" />
|
||||
<!-- ===== frpc(修正版:f 有完整竖线) ===== -->
|
||||
<g transform="translate(49, 20)">
|
||||
<!--
|
||||
每个字母高度约 28px(4行×7px)
|
||||
间距:字母之间空 14px(2行×7px)
|
||||
f: y=0-27(竖线4行)
|
||||
r: y=41-62
|
||||
p: y=76-97
|
||||
c: y=111-132
|
||||
-->
|
||||
|
||||
<!-- 底部横线 -->
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
<use href="#p" x="24" y="132" />
|
||||
<use href="#p" x="36" y="132" />
|
||||
<use href="#p" x="48" y="132" />
|
||||
<use href="#p" x="60" y="132" />
|
||||
<use href="#p" x="72" y="132" />
|
||||
<use href="#p" x="84" y="132" />
|
||||
<use href="#p" x="96" y="132" />
|
||||
|
||||
<!-- ===== U 内部:竖向排列的 frpc(蓝色,5×5 像素) ===== -->
|
||||
<g transform="translate(32, 18)">
|
||||
<!-- f -->
|
||||
<!-- ===== f:竖线(完整4行)+ 顶横 + 中横 ===== -->
|
||||
<!-- 竖线(左侧,从顶到底连续4行) -->
|
||||
<use href="#q" x="0" y="0" />
|
||||
<use href="#q" x="0" y="6" />
|
||||
<use href="#q" x="0" y="12" />
|
||||
<use href="#q" x="0" y="18" />
|
||||
<use href="#q" x="6" y="0" />
|
||||
<use href="#q" x="12" y="0" />
|
||||
<use href="#q" x="18" y="0" />
|
||||
<use href="#q" x="6" y="12" />
|
||||
<use href="#q" x="12" y="12" />
|
||||
<use href="#q" x="0" y="7" />
|
||||
<use href="#q" x="0" y="14" />
|
||||
<use href="#q" x="0" y="21" />
|
||||
<!-- 顶横(向右延伸) -->
|
||||
<use href="#q" x="7" y="0" />
|
||||
<use href="#q" x="14" y="0" />
|
||||
<!-- 中横(向右延伸) -->
|
||||
<use href="#q" x="7" y="14" />
|
||||
<use href="#q" x="14" y="14" />
|
||||
|
||||
<!-- r -->
|
||||
<use href="#q" x="0" y="28" />
|
||||
<use href="#q" x="0" y="34" />
|
||||
<use href="#q" x="0" y="40" />
|
||||
<use href="#q" x="0" y="46" />
|
||||
<use href="#q" x="6" y="28" />
|
||||
<use href="#q" x="12" y="28" />
|
||||
<use href="#q" x="18" y="28" />
|
||||
<use href="#q" x="6" y="40" />
|
||||
<use href="#q" x="12" y="40" />
|
||||
<!-- ===== r:竖线 + 顶横(一竖一横) ===== -->
|
||||
<!-- 竖线(左侧,3行) -->
|
||||
<use href="#q" x="0" y="41" />
|
||||
<use href="#q" x="0" y="48" />
|
||||
<use href="#q" x="0" y="55" />
|
||||
<!-- 顶横(向右延伸) -->
|
||||
<use href="#q" x="7" y="41" />
|
||||
<use href="#q" x="14" y="41" />
|
||||
|
||||
<!-- p -->
|
||||
<use href="#q" x="0" y="56" />
|
||||
<use href="#q" x="0" y="62" />
|
||||
<use href="#q" x="0" y="68" />
|
||||
<use href="#q" x="0" y="74" />
|
||||
<use href="#q" x="0" y="80" />
|
||||
<use href="#q" x="6" y="56" />
|
||||
<use href="#q" x="12" y="56" />
|
||||
<use href="#q" x="18" y="56" />
|
||||
<use href="#q" x="18" y="62" />
|
||||
<use href="#q" x="18" y="68" />
|
||||
<use href="#q" x="6" y="74" />
|
||||
<use href="#q" x="12" y="74" />
|
||||
<use href="#q" x="18" y="74" />
|
||||
<!-- ===== p:竖线(向下伸出)+ 右侧圆圈 ===== -->
|
||||
<!-- 竖线(左侧,向下伸出5行) -->
|
||||
<use href="#q" x="0" y="76" />
|
||||
<use href="#q" x="0" y="83" />
|
||||
<use href="#q" x="0" y="90" />
|
||||
<use href="#q" x="0" y="97" />
|
||||
<!-- 圆圈(右侧3×3闭合) -->
|
||||
<use href="#q" x="7" y="76" />
|
||||
<use href="#q" x="14" y="76" />
|
||||
<use href="#q" x="14" y="83" />
|
||||
<use href="#q" x="14" y="90" />
|
||||
<use href="#q" x="7" y="90" />
|
||||
<use href="#q" x="14" y="90" />
|
||||
|
||||
<!-- c -->
|
||||
<use href="#q" x="6" y="90" />
|
||||
<use href="#q" x="12" y="90" />
|
||||
<use href="#q" x="18" y="90" />
|
||||
<use href="#q" x="0" y="96" />
|
||||
<use href="#q" x="0" y="102" />
|
||||
<use href="#q" x="6" y="108" />
|
||||
<use href="#q" x="12" y="108" />
|
||||
<use href="#q" x="18" y="108" />
|
||||
<!-- ===== c:开口朝右的弧形(左侧半圆) ===== -->
|
||||
<!-- 左竖 -->
|
||||
<use href="#q" x="0" y="111" />
|
||||
<use href="#q" x="0" y="118" />
|
||||
<use href="#q" x="0" y="125" />
|
||||
<!-- 上弧 -->
|
||||
<use href="#q" x="7" y="111" />
|
||||
<use href="#q" x="14" y="111" />
|
||||
<!-- 下弧 -->
|
||||
<use href="#q" x="7" y="125" />
|
||||
<use href="#q" x="14" y="125" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- ===== I(中间竖线 2 列宽,高度一致) ===== -->
|
||||
<g transform="translate(248, 0)">
|
||||
<!-- 顶部横线(占6列) -->
|
||||
<!-- I -->
|
||||
<g transform="translate(288, 0)">
|
||||
<use href="#p" x="0" y="0" />
|
||||
<use href="#p" x="12" y="0" />
|
||||
<use href="#p" x="24" y="0" />
|
||||
<use href="#p" x="36" y="0" />
|
||||
<use href="#p" x="48" y="0" />
|
||||
<use href="#p" x="60" y="0" />
|
||||
|
||||
<!-- 中间竖线(2 列宽) -->
|
||||
<use href="#p" x="24" y="12" />
|
||||
<use href="#p" x="36" y="12" />
|
||||
<use href="#p" x="24" y="24" />
|
||||
<use href="#p" x="36" y="24" />
|
||||
<use href="#p" x="24" y="36" />
|
||||
<use href="#p" x="36" y="36" />
|
||||
<use href="#p" x="24" y="48" />
|
||||
<use href="#p" x="36" y="48" />
|
||||
<use href="#p" x="24" y="60" />
|
||||
<use href="#p" x="36" y="60" />
|
||||
<use href="#p" x="24" y="72" />
|
||||
<use href="#p" x="36" y="72" />
|
||||
<use href="#p" x="24" y="84" />
|
||||
<use href="#p" x="36" y="84" />
|
||||
<use href="#p" x="24" y="96" />
|
||||
<use href="#p" x="36" y="96" />
|
||||
<use href="#p" x="24" y="108" />
|
||||
<use href="#p" x="36" y="108" />
|
||||
<use href="#p" x="24" y="120" />
|
||||
<use href="#p" x="36" y="120" />
|
||||
|
||||
<!-- 底部横线(占6列) -->
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
<use href="#p" x="24" y="132" />
|
||||
<use href="#p" x="36" y="132" />
|
||||
<use href="#p" x="48" y="132" />
|
||||
<use href="#p" x="60" y="132" />
|
||||
<use href="#p" x="14" y="0" />
|
||||
<use href="#p" x="28" y="0" />
|
||||
<use href="#p" x="42" y="0" />
|
||||
<use href="#p" x="56" y="0" />
|
||||
<use href="#p" x="70" y="0" />
|
||||
<use href="#p" x="28" y="14" />
|
||||
<use href="#p" x="42" y="14" />
|
||||
<use href="#p" x="28" y="28" />
|
||||
<use href="#p" x="42" y="28" />
|
||||
<use href="#p" x="28" y="42" />
|
||||
<use href="#p" x="42" y="42" />
|
||||
<use href="#p" x="28" y="56" />
|
||||
<use href="#p" x="42" y="56" />
|
||||
<use href="#p" x="28" y="70" />
|
||||
<use href="#p" x="42" y="70" />
|
||||
<use href="#p" x="28" y="84" />
|
||||
<use href="#p" x="42" y="84" />
|
||||
<use href="#p" x="28" y="98" />
|
||||
<use href="#p" x="42" y="98" />
|
||||
<use href="#p" x="28" y="112" />
|
||||
<use href="#p" x="42" y="112" />
|
||||
<use href="#p" x="28" y="126" />
|
||||
<use href="#p" x="42" y="126" />
|
||||
<use href="#p" x="28" y="140" />
|
||||
<use href="#p" x="42" y="140" />
|
||||
<use href="#p" x="28" y="154" />
|
||||
<use href="#p" x="42" y="154" />
|
||||
<use href="#p" x="0" y="168" />
|
||||
<use href="#p" x="14" y="168" />
|
||||
<use href="#p" x="28" y="168" />
|
||||
<use href="#p" x="42" y="168" />
|
||||
<use href="#p" x="56" y="168" />
|
||||
<use href="#p" x="70" y="168" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- ========== 外围装饰元素(像素风,增加层次) ========== -->
|
||||
<!-- 左上角小十字星 -->
|
||||
<!-- ========== 外围装饰 ========== -->
|
||||
<g transform="translate(40, 40)">
|
||||
<use href="#s" x="0" y="0" />
|
||||
<use href="#s" x="0" y="12" />
|
||||
@ -259,11 +259,8 @@
|
||||
<use href="#s" x="12" y="0" />
|
||||
<use href="#s" x="12" y="12" />
|
||||
</g>
|
||||
<!-- 右上角小方块 -->
|
||||
<use href="#d" x="440" y="40" />
|
||||
<!-- 左下角小方块 -->
|
||||
<use href="#d" x="40" y="440" />
|
||||
<!-- 右下角小十字星 -->
|
||||
<g transform="translate(436, 436)">
|
||||
<use href="#s" x="0" y="0" />
|
||||
<use href="#s" x="0" y="12" />
|
||||
@ -272,12 +269,13 @@
|
||||
<use href="#s" x="12" y="12" />
|
||||
</g>
|
||||
|
||||
<!-- 三个随机黑色装饰点(更大一点,12×12) -->
|
||||
<rect x="90" y="380" width="12" height="12" fill="#000000" />
|
||||
<rect x="420" y="140" width="12" height="12" fill="#000000" />
|
||||
<rect x="240" y="60" width="12" height="12" fill="#000000" />
|
||||
<!-- 黑色装饰点 -->
|
||||
<rect x="90" y="380" width="14" height="14" fill="#000000" />
|
||||
<rect x="420" y="140" width="14" height="14" fill="#000000" />
|
||||
<rect x="230" y="50" width="14" height="14" fill="#000000" />
|
||||
|
||||
<!-- 额外小点缀:蓝色小点,呼应 frpc 颜色 -->
|
||||
<rect x="180" y="400" width="6" height="6" fill="#2563eb" />
|
||||
<rect x="370" y="80" width="6" height="6" fill="#2563eb" />
|
||||
<!-- 蓝色小点缀 -->
|
||||
<rect x="180" y="400" width="8" height="8" fill="#2563eb" />
|
||||
<rect x="370" y="80" width="8" height="8" fill="#2563eb" />
|
||||
<rect x="50" y="250" width="8" height="8" fill="#2563eb" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
4
internal/httpapi/dist/index.html
vendored
4
internal/httpapi/dist/index.html
vendored
@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>webui4frpc</title>
|
||||
<script type="module" crossorigin src="/assets/index-C5WLVFP2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CL42Ur3_.css">
|
||||
<script type="module" crossorigin src="/assets/index-CbqC9m-j.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-RFHYrCiX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@ -93,8 +93,8 @@ func (h *Handler) handleUserByName(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
// Guard: never disable/demote the last admin.
|
||||
if u.Role == "admin" && (role != "admin" || !enabled) {
|
||||
// Guard: never disable/demote the last admin or superadmin.
|
||||
if (u.Role == "admin" || u.Role == "superadmin") && (role != u.Role || !enabled) {
|
||||
n, _ := h.Store.CountAdmins()
|
||||
if n <= 1 {
|
||||
http.Error(w, "cannot demote or disable the last admin", http.StatusConflict)
|
||||
@ -112,7 +112,7 @@ func (h *Handler) handleUserByName(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "system user is managed by -user/-password flags", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if u.Role == "admin" {
|
||||
if u.Role == "admin" || u.Role == "superadmin" {
|
||||
n, _ := h.Store.CountAdmins()
|
||||
if n <= 1 {
|
||||
http.Error(w, "cannot delete the last admin", http.StatusConflict)
|
||||
|
||||
@ -31,6 +31,8 @@ type Handler struct {
|
||||
BinDir string
|
||||
User string
|
||||
Password string
|
||||
// Sessions issues the UI login cookies (see session.go).
|
||||
Sessions *SessionStore
|
||||
|
||||
// InstallBinary downloads and activates a frpc binary. Set by the app to
|
||||
// avoid an import cycle with the install package.
|
||||
@ -120,10 +122,15 @@ func NewServeMux(h *Handler) (http.Handler, error) {
|
||||
|
||||
// Account & API key management (admin only).
|
||||
mux.HandleFunc(apiPrefix+"/me", h.auth("read")(h.handleMe))
|
||||
mux.HandleFunc(apiPrefix+"/users", h.auth("admin")(h.handleUsers))
|
||||
mux.HandleFunc(apiPrefix+"/users/", h.auth("admin")(h.handleUserByName))
|
||||
mux.HandleFunc(apiPrefix+"/apikeys", h.auth("admin")(h.handleApiKeys))
|
||||
mux.HandleFunc(apiPrefix+"/apikeys/", h.auth("admin")(h.handleApiKeyByID))
|
||||
// UI session login/logout. Deliberately NOT behind auth(): login must be
|
||||
// reachable without credentials, and logout must work even when the
|
||||
// session is already gone.
|
||||
mux.HandleFunc(apiPrefix+"/login", h.handleLogin)
|
||||
mux.HandleFunc(apiPrefix+"/logout", h.handleLogout)
|
||||
mux.HandleFunc(apiPrefix+"/users", h.auth("superadmin")(h.handleUsers))
|
||||
mux.HandleFunc(apiPrefix+"/users/", h.auth("superadmin")(h.handleUserByName))
|
||||
mux.HandleFunc(apiPrefix+"/apikeys", h.auth("superadmin")(h.handleApiKeys))
|
||||
mux.HandleFunc(apiPrefix+"/apikeys/", h.auth("superadmin")(h.handleApiKeyByID))
|
||||
|
||||
// M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds).
|
||||
// Not under /api so peers hit it directly; auth still applied. Peers
|
||||
|
||||
156
internal/httpapi/session.go
Normal file
156
internal/httpapi/session.go
Normal file
@ -0,0 +1,156 @@
|
||||
// UI sessions. Basic Auth credentials are cached by the browser and cannot be
|
||||
// cleared from JS, which made "logout" impossible. To fix that, the SPA signs
|
||||
// in through POST /api/manager/login and receives an HttpOnly session cookie;
|
||||
// the auth middleware now prefers this cookie over the Basic/Bearer headers
|
||||
// (which remain for API clients and inter-node traffic). Logout just clears
|
||||
// the cookie — a real, working sign-out.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sessionCookie is the HttpOnly cookie name carrying the UI session token.
|
||||
const sessionCookie = "w4f_session"
|
||||
|
||||
// sessionTTL is how long a session lives without activity; every validated
|
||||
// request slides the expiry forward.
|
||||
const sessionTTL = 24 * time.Hour
|
||||
|
||||
type sessionEntry struct {
|
||||
id identity
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
// SessionStore keeps signed-in UI sessions in memory. It is per-node state:
|
||||
// sessions are not replicated across the ring — each webui instance has its
|
||||
// own browser sessions, so nothing needs to cross nodes.
|
||||
type SessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]sessionEntry
|
||||
}
|
||||
|
||||
// NewSessionStore returns an empty session store.
|
||||
func NewSessionStore() *SessionStore {
|
||||
return &SessionStore{sessions: make(map[string]sessionEntry)}
|
||||
}
|
||||
|
||||
// create issues a fresh session for id and sets the session cookie.
|
||||
func (s *SessionStore) create(w http.ResponseWriter, id identity) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand failure is fatal in practice; refuse to create a session.
|
||||
http.Error(w, "session error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tok := hex.EncodeToString(b[:])
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
s.purgeLocked(now)
|
||||
s.sessions[tok] = sessionEntry{id: id, expiry: now.Add(sessionTTL)}
|
||||
s.mu.Unlock()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: tok,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// validate reads the session cookie and returns the identity if it is live,
|
||||
// sliding the expiry on success.
|
||||
func (s *SessionStore) validate(r *http.Request) (identity, bool) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
return identity{}, false
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, ok := s.sessions[c.Value]
|
||||
if !ok || now.After(e.expiry) {
|
||||
delete(s.sessions, c.Value)
|
||||
return identity{}, false
|
||||
}
|
||||
e.expiry = now.Add(sessionTTL)
|
||||
s.sessions[c.Value] = e
|
||||
return e.id, true
|
||||
}
|
||||
|
||||
// destroy clears the session and expires the cookie.
|
||||
func (s *SessionStore) destroy(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, c.Value)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// purgeLocked removes expired entries; caller holds the mutex.
|
||||
func (s *SessionStore) purgeLocked(now time.Time) {
|
||||
for k, e := range s.sessions {
|
||||
if now.After(e.expiry) {
|
||||
delete(s.sessions, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleLogin issues a UI session cookie from username/password. It is
|
||||
// deliberately NOT wrapped in auth(): the whole point is to obtain the first
|
||||
// credential. The same checks as the auth middleware run here (flag fast path
|
||||
// + users table), then a session cookie is set so the SPA stops using Basic.
|
||||
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Username == "" || req.Password == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password required"})
|
||||
return
|
||||
}
|
||||
var id identity
|
||||
if subtle.ConstantTimeCompare([]byte(req.Username), []byte(h.User)) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.Password)) == 1 {
|
||||
id = identity{Type: "service", Name: h.User, Level: "superadmin"}
|
||||
} else if usr, ok := h.Store.VerifyUserPassword(req.Username, req.Password); ok {
|
||||
id = identity{Type: "user", Name: usr.Username, Level: roleLevel(usr.Role), UserID: usr.ID}
|
||||
_ = h.Store.TouchUserLogin(usr.ID)
|
||||
}
|
||||
if id.Level == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
h.Sessions.create(w, id)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"name": id.Name, "level": id.Level, "type": id.Type})
|
||||
}
|
||||
|
||||
// handleLogout clears the UI session cookie.
|
||||
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
h.Sessions.destroy(w, r)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
@ -142,14 +142,15 @@ type Forward struct {
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// User is an authenticated account. Role gates UI/API access (admin = full,
|
||||
// viewer = read-only + exports, for auditors). System users are synced from
|
||||
// the -user/-password flags and are read-only in the account-management UI.
|
||||
// User is an authenticated account. Role gates UI/API access (superadmin =
|
||||
// full + account management, admin = full except account management, viewer =
|
||||
// read-only + exports, for auditors). System users are synced from the
|
||||
// -user/-password flags and are read-only in the account-management UI.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"` // never serialized to clients
|
||||
Role string `json:"role"` // "admin" | "viewer"
|
||||
Role string `json:"role"` // "admin" | "viewer" | "superadmin"
|
||||
Enabled bool `json:"enabled"`
|
||||
System bool `json:"system"` // true = flag-synced, UI read-only
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
@ -229,7 +230,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin', -- 'admin' | 'viewer'
|
||||
role TEXT NOT NULL DEFAULT 'admin', -- 'admin' | 'viewer' | 'superadmin'
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
system INTEGER NOT NULL DEFAULT 0, -- 1 = synced from -user/-password flags, UI read-only
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
@ -773,7 +774,7 @@ func (s *Store) CreateUser(username, plainPassword, role string) (User, error) {
|
||||
if username == "" || plainPassword == "" {
|
||||
return User{}, ErrInvalid
|
||||
}
|
||||
if role != "admin" && role != "viewer" {
|
||||
if role != "admin" && role != "viewer" && role != "superadmin" {
|
||||
return User{}, ErrInvalid
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
|
||||
@ -802,7 +803,7 @@ func (s *Store) UpdateUser(id int64, role string, enabled bool, plainPassword st
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if role != "admin" && role != "viewer" {
|
||||
if role != "admin" && role != "viewer" && role != "superadmin" {
|
||||
return ErrInvalid
|
||||
}
|
||||
if u.System && plainPassword != "" {
|
||||
@ -840,10 +841,11 @@ func (s *Store) DeleteUser(id int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// CountAdmins returns the count of enabled admin users (for the last-admin guard).
|
||||
// CountAdmins returns the count of enabled admin/superadmin users (for the
|
||||
// last-admin guard; both roles can manage others and must never be wiped out).
|
||||
func (s *Store) CountAdmins() (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'admin' AND enabled = 1").Scan(&n)
|
||||
err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role IN ('admin','superadmin') AND enabled = 1").Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user