740339567a
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
182 lines
7.1 KiB
Go
182 lines
7.1 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
|
|
)
|
|
|
|
// R-35 (D4, operator ruling 2026-10-08, `09` §3 decision 188): the dashboard's sign-ins survive the controller's own
|
|
// restarts — a settings push, an update, a crash. Before this, s.sessions lived only in memory and every restart
|
|
// signed the household out mid-flow.
|
|
//
|
|
// The disk holds a FINGERPRINT, never the cookie: the map is keyed by sha256(cookie), and only that key, the expiry
|
|
// and the CSRF token are written. The data dir rides in the whole-guest archive (plaintext by design, `07` §5), so a
|
|
// copy of the archive must not hold anything a browser could present. sha256 of 32 random bytes cannot be turned back
|
|
// into the cookie, and presenting the fingerprint itself is hashed again and misses (TestR35_FileHoldsNoToken).
|
|
//
|
|
// Expiry is unchanged (sessionMaxAge from creation; an expired row is dropped at load and at every save). Logout and
|
|
// invalidateAllSessions (password change, claim reset) write the file at once, so an ended session stays ended
|
|
// across a restart (TestR35_LogoutEndsSessionAcrossRestart, TestR35_InvalidateAllEndsSessionAcrossRestart).
|
|
//
|
|
// Two guards keep a REVOKED session from coming back if a write fails (security review 2026-10-08):
|
|
// - the file carries a fingerprint of the password hash in force when it was written (CredentialFP); at load, rows
|
|
// written under another password are dropped — a password change revokes on disk even if its own save failed
|
|
// (TestR35_PasswordChangeRevokesEvenIfSaveFailed);
|
|
// - a revoking save (logout, invalidateAllSessions) that fails removes the file instead, so the restart starts with
|
|
// no sessions rather than the stale ones (TestR35_FailedLogoutSaveRemovesFile).
|
|
//
|
|
// A missing file is normal; an unreadable or corrupt one is logged and the server starts with no sessions — never
|
|
// fatal, and the failure direction is "sign in again", never "signed in" (TestR35_CorruptFileStartsEmpty).
|
|
|
|
const sessionsFileName = "dashboard-sessions.json"
|
|
|
|
type sessionsFile struct {
|
|
Version int `json:"version"`
|
|
CredentialFP string `json:"credential_fp"`
|
|
Sessions []sessionDisk `json:"sessions"`
|
|
}
|
|
|
|
type sessionDisk struct {
|
|
Fingerprint string `json:"fingerprint"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
CSRFToken string `json:"csrf_token"`
|
|
}
|
|
|
|
// sessionFingerprint is the map key and the on-disk id of a session: hex(sha256(cookie value)).
|
|
func sessionFingerprint(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// credentialFingerprint is hex(sha256(the password hash in force)): it changes whenever the password does, from either
|
|
// source (settings.json or controller.yaml), and reveals nothing the bcrypt hash itself does not.
|
|
func (s *Server) credentialFingerprint() string {
|
|
sum := sha256.Sum256([]byte("felhom-dashboard-sessions\x00" + s.effectivePasswordHash()))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func (s *Server) sessionsPath() string {
|
|
if s.cfg == nil || s.cfg.Paths.DataDir == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(s.cfg.Paths.DataDir, sessionsFileName)
|
|
}
|
|
|
|
// loadSessions reads the persisted sessions into s.sessions. Called once from NewServer.
|
|
func (s *Server) loadSessions() {
|
|
path := s.sessionsPath()
|
|
if path == "" {
|
|
return
|
|
}
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
logx.Warnf(s.logger, "[web] sessions: cannot read %s, starting with none: %v", path, err)
|
|
} else {
|
|
logx.Debugf(s.logger, "[web] sessions: no %s yet, starting with none", sessionsFileName)
|
|
}
|
|
return
|
|
}
|
|
var f sessionsFile
|
|
if err := json.Unmarshal(b, &f); err != nil {
|
|
logx.Warnf(s.logger, "[web] sessions: %s is not valid JSON, starting with none: %v", path, err)
|
|
return
|
|
}
|
|
if f.CredentialFP != s.credentialFingerprint() {
|
|
logx.Infof(s.logger, "[web] sessions: %d session(s) on disk were written under another password — dropped, sign in again", len(f.Sessions))
|
|
return
|
|
}
|
|
now := time.Now()
|
|
loaded, expired, bad := 0, 0, 0
|
|
s.sessionsMu.Lock()
|
|
for _, d := range f.Sessions {
|
|
if len(d.Fingerprint) != sha256.Size*2 || d.CSRFToken == "" {
|
|
bad++
|
|
continue
|
|
}
|
|
if !now.Before(d.ExpiresAt) {
|
|
expired++
|
|
continue
|
|
}
|
|
s.sessions[d.Fingerprint] = &session{expiresAt: d.ExpiresAt, csrfToken: d.CSRFToken}
|
|
loaded++
|
|
}
|
|
s.sessionsMu.Unlock()
|
|
logx.Infof(s.logger, "[web] sessions: restored %d dashboard session(s) from disk (dropped %d expired, %d malformed)", loaded, expired, bad)
|
|
}
|
|
|
|
// saveSessionsLocked writes the live sessions (expired ones dropped) atomically, 0600, fsynced. The caller holds
|
|
// sessionsMu for writing. A failure is logged and returned; the in-memory state stays authoritative for this process.
|
|
func (s *Server) saveSessionsLocked() error {
|
|
path := s.sessionsPath()
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
now := time.Now()
|
|
f := sessionsFile{Version: 1, CredentialFP: s.credentialFingerprint(), Sessions: []sessionDisk{}}
|
|
for fp, sess := range s.sessions {
|
|
if !now.Before(sess.expiresAt) {
|
|
continue
|
|
}
|
|
f.Sessions = append(f.Sessions, sessionDisk{Fingerprint: fp, ExpiresAt: sess.expiresAt, CSRFToken: sess.csrfToken})
|
|
}
|
|
sort.Slice(f.Sessions, func(i, j int) bool { return f.Sessions[i].Fingerprint < f.Sessions[j].Fingerprint })
|
|
b, err := json.MarshalIndent(f, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := writeSessionsAtomic(path, b); err != nil {
|
|
logx.Warnf(s.logger, "[web] sessions: cannot save %s (sessions stay valid until this process ends): %v", path, err)
|
|
return err
|
|
}
|
|
logx.Debugf(s.logger, "[web] sessions: saved %d session(s) to %s", len(f.Sessions), sessionsFileName)
|
|
return nil
|
|
}
|
|
|
|
// saveSessionsRevokingLocked is the save for a path that ENDS sessions (logout, invalidateAllSessions). If the write
|
|
// fails, the file is removed so a restart cannot bring an ended session back; a household then signs in again, which
|
|
// is the safe direction. Caller holds sessionsMu for writing.
|
|
func (s *Server) saveSessionsRevokingLocked() {
|
|
if err := s.saveSessionsLocked(); err == nil {
|
|
return
|
|
}
|
|
path := s.sessionsPath()
|
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
logx.Errorf(s.logger, "[web] sessions: could not save NOR remove %s after ending a session — an ended session may return after a restart until the password changes: %v", path, err)
|
|
return
|
|
}
|
|
logx.Warnf(s.logger, "[web] sessions: save failed while ending a session — removed %s instead (every session ends at the next restart)", sessionsFileName)
|
|
}
|
|
|
|
// writeSessionsAtomic is tmp + fsync + rename at 0600 — the family.json shape (internal/family saveLocked).
|
|
func writeSessionsAtomic(path string, b []byte) error {
|
|
tmp := path + ".tmp"
|
|
fh, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := fh.Write(b); err != nil {
|
|
fh.Close()
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := fh.Sync(); err != nil {
|
|
fh.Close()
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := fh.Close(); err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|