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:
2026-10-08 14:32:20 +02:00
parent fcd9f09927
commit 1040cfe225
5 changed files with 362 additions and 4 deletions
+11 -4
View File
@@ -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)