R-35 (D4): dashboard sign-ins survive the controller's own restart; disk holds only a fingerprint
Sessions are keyed by sha256(cookie) and persisted to dashboard-sessions.json
(0600, tmp+fsync+rename) in the data dir: fingerprint, expiry, CSRF token.
Loaded in NewServer; expired rows dropped at load and save. Logout and
invalidateAllSessions (password change, claim reset) write the file at once.
Corrupt/unreadable file = start with no sessions (never fatal).
Red-proof: with load/save as no-ops the restart test fails ('the old cookie
no longer signs in'); with the raw token as the key the file test fails
('the sessions file holds the cookie value').
Also: TestR650_NoBareDockerExec skips a non-.go file that vanished mid-walk
(a parallel stacks test's update-journal.json.tmp raced it in a full run).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -239,7 +239,8 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||
s.sessionsMu.Lock()
|
||||
delete(s.sessions, cookie.Value)
|
||||
delete(s.sessions, sessionFingerprint(cookie.Value))
|
||||
_ = s.saveSessionsLocked() // R-35: a signed-out cookie stays signed out across a restart
|
||||
s.sessionsMu.Unlock()
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] User logged out from %s", r.RemoteAddr)
|
||||
@@ -257,10 +258,12 @@ func (s *Server) createSession() string {
|
||||
csrfToken := hex.EncodeToString(csrfB)
|
||||
|
||||
s.sessionsMu.Lock()
|
||||
s.sessions[token] = &session{
|
||||
// R-35: keyed by the fingerprint, never the cookie value — this map is what session_store.go writes to disk.
|
||||
s.sessions[sessionFingerprint(token)] = &session{
|
||||
expiresAt: time.Now().Add(sessionMaxAge),
|
||||
csrfToken: csrfToken,
|
||||
}
|
||||
_ = s.saveSessionsLocked()
|
||||
sessionCount := len(s.sessions)
|
||||
s.sessionsMu.Unlock()
|
||||
|
||||
@@ -276,7 +279,7 @@ func (s *Server) createSession() string {
|
||||
func (s *Server) csrfTokenForSession(sessionToken string) string {
|
||||
s.sessionsMu.RLock()
|
||||
defer s.sessionsMu.RUnlock()
|
||||
sess, ok := s.sessions[sessionToken]
|
||||
sess, ok := s.sessions[sessionFingerprint(sessionToken)]
|
||||
if !ok || time.Now().After(sess.expiresAt) {
|
||||
return ""
|
||||
}
|
||||
@@ -286,7 +289,7 @@ func (s *Server) csrfTokenForSession(sessionToken string) string {
|
||||
func (s *Server) isValidSession(token string) bool {
|
||||
s.sessionsMu.RLock()
|
||||
defer s.sessionsMu.RUnlock()
|
||||
sess, ok := s.sessions[token]
|
||||
sess, ok := s.sessions[sessionFingerprint(token)]
|
||||
return ok && time.Now().Before(sess.expiresAt)
|
||||
}
|
||||
|
||||
@@ -296,6 +299,7 @@ func (s *Server) invalidateAllSessions() {
|
||||
s.sessionsMu.Lock()
|
||||
count := len(s.sessions)
|
||||
s.sessions = make(map[string]*session)
|
||||
_ = s.saveSessionsLocked() // R-35: the password change ends every session on disk too
|
||||
s.sessionsMu.Unlock()
|
||||
s.logger.Printf("[INFO] [web] All sessions invalidated (cleared %d)", count)
|
||||
}
|
||||
@@ -318,6 +322,9 @@ func (s *Server) cleanupSessions() {
|
||||
}
|
||||
}
|
||||
remaining := len(s.sessions)
|
||||
if expired > 0 {
|
||||
_ = s.saveSessionsLocked()
|
||||
}
|
||||
s.sessionsMu.Unlock()
|
||||
if expired > 0 {
|
||||
s.logger.Printf("[INFO] [web] Cleaned up %d expired sessions, %d remaining", expired, remaining)
|
||||
|
||||
Reference in New Issue
Block a user