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