Files
webui4frpc/internal/httpapi/session.go
jianf f29ec81e4a 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
2026-08-20 09:08:15 +08:00

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})
}