mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 17:07: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
157 lines
4.8 KiB
Go
157 lines
4.8 KiB
Go
// 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})
|
|
}
|