package auth import ( "crypto/rand" "encoding/base64" "errors" "fmt" "net" "net/http" "strings" "sync" "time" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" ) const ( SessionCookieName = "__Host-ClusterCanvas-Session" sessionIDBytes = 32 sessionRotateAfter = time.Hour ) var ( ErrSessionNotFound = errors.New("session not found") ErrSessionIdle = errors.New("session idle") ) // SessionManager persists sessions in sessions.enc and sets hardened cookies. type SessionManager struct { configDir string key []byte mu sync.Mutex } func NewSessionManager(configDir string, key []byte) *SessionManager { return &SessionManager{configDir: configDir, key: key} } // CreateSession invalidates prior sessions for the user, stores a new session, and sets the cookie. func (manager *SessionManager) CreateSession( writer http.ResponseWriter, userID string, security settings.SecuritySettings, ) (settings.SessionRecord, error) { manager.mu.Lock() defer manager.mu.Unlock() store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key) if err != nil { return settings.SessionRecord{}, err } now := time.Now().UTC() lifetime := time.Duration(security.SessionLifetimeHours) * time.Hour filtered := make([]settings.SessionRecord, 0, len(store.Sessions)) for _, session := range store.Sessions { if session.UserID == userID { continue } if session.ExpiresAt.Before(now) { continue } filtered = append(filtered, session) } sessionID, err := newSessionID() if err != nil { return settings.SessionRecord{}, err } record := settings.SessionRecord{ ID: sessionID, UserID: userID, CreatedAt: now, LastSeenAt: now, LastReauthAt: now, ExpiresAt: now.Add(lifetime), } filtered = append(filtered, record) store.Sessions = filtered if err := settings.SaveSessions(manager.configDir, store, manager.key); err != nil { return settings.SessionRecord{}, err } setSessionCookie(writer, sessionID, int(lifetime.Seconds())) return record, nil } // LookupValidSession finds a non-expired, non-idle session and may rotate it. func (manager *SessionManager) LookupValidSession( writer http.ResponseWriter, request *http.Request, security settings.SecuritySettings, ) (settings.SessionRecord, error) { cookie, err := request.Cookie(SessionCookieName) if err != nil || strings.TrimSpace(cookie.Value) == "" { return settings.SessionRecord{}, ErrSessionNotFound } manager.mu.Lock() defer manager.mu.Unlock() store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key) if err != nil { return settings.SessionRecord{}, err } now := time.Now().UTC() idleLimit := time.Duration(security.IdleTimeoutMinutes) * time.Minute lifetime := time.Duration(security.SessionLifetimeHours) * time.Hour var found *settings.SessionRecord idleMatched := false remaining := make([]settings.SessionRecord, 0, len(store.Sessions)) for index := range store.Sessions { session := store.Sessions[index] if session.ExpiresAt.Before(now) { continue } if session.ID == cookie.Value { if now.Sub(session.LastSeenAt) > idleLimit { idleMatched = true continue } copySession := session found = ©Session continue } remaining = append(remaining, session) } if found == nil { store.Sessions = remaining _ = settings.SaveSessions(manager.configDir, store, manager.key) clearSessionCookie(writer) if idleMatched { return settings.SessionRecord{}, ErrSessionIdle } return settings.SessionRecord{}, ErrSessionNotFound } shouldRotate := now.Sub(found.CreatedAt) >= sessionRotateAfter || now.Sub(found.CreatedAt) >= lifetime/2 found.LastSeenAt = now if shouldRotate { newID, err := newSessionID() if err != nil { return settings.SessionRecord{}, err } found.ID = newID found.CreatedAt = now // LastReauthAt is preserved across rotation. setSessionCookie(writer, newID, int(found.ExpiresAt.Sub(now).Seconds())) } remaining = append(remaining, *found) store.Sessions = remaining if err := settings.SaveSessions(manager.configDir, store, manager.key); err != nil { return settings.SessionRecord{}, err } return *found, nil } // MarkReauth updates LastReauthAt for the session identified by sessionID. func (manager *SessionManager) MarkReauth(sessionID string) error { manager.mu.Lock() defer manager.mu.Unlock() store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key) if err != nil { return err } now := time.Now().UTC() found := false for index := range store.Sessions { if store.Sessions[index].ID != sessionID { continue } store.Sessions[index].LastReauthAt = now found = true break } if !found { return ErrSessionNotFound } return settings.SaveSessions(manager.configDir, store, manager.key) } // InvalidateUserSessions removes all persisted sessions for the given user ID. func (manager *SessionManager) InvalidateUserSessions(userID string) error { manager.mu.Lock() defer manager.mu.Unlock() store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key) if err != nil { return err } filtered := make([]settings.SessionRecord, 0, len(store.Sessions)) for _, session := range store.Sessions { if session.UserID == userID { continue } filtered = append(filtered, session) } store.Sessions = filtered return settings.SaveSessions(manager.configDir, store, manager.key) } // DestroySession removes the presented session and clears the cookie. func (manager *SessionManager) DestroySession(writer http.ResponseWriter, request *http.Request) error { cookie, err := request.Cookie(SessionCookieName) clearSessionCookie(writer) if err != nil || cookie.Value == "" { return nil } manager.mu.Lock() defer manager.mu.Unlock() store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key) if err != nil { return err } filtered := make([]settings.SessionRecord, 0, len(store.Sessions)) for _, session := range store.Sessions { if session.ID == cookie.Value { continue } filtered = append(filtered, session) } store.Sessions = filtered return settings.SaveSessions(manager.configDir, store, manager.key) } func newSessionID() (string, error) { buffer := make([]byte, sessionIDBytes) if _, err := rand.Read(buffer); err != nil { return "", fmt.Errorf("session id: %w", err) } return base64.RawURLEncoding.EncodeToString(buffer), nil } func setSessionCookie(writer http.ResponseWriter, sessionID string, maxAgeSeconds int) { if maxAgeSeconds < 0 { maxAgeSeconds = 0 } http.SetCookie(writer, &http.Cookie{ Name: SessionCookieName, Value: sessionID, Path: "/", MaxAge: maxAgeSeconds, HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, }) } func clearSessionCookie(writer http.ResponseWriter) { http.SetCookie(writer, &http.Cookie{ Name: SessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, }) } // ClientIP extracts the remote IP, preferring X-Forwarded-For when present. func ClientIP(request *http.Request) string { if forwarded := strings.TrimSpace(request.Header.Get("X-Forwarded-For")); forwarded != "" { parts := strings.Split(forwarded, ",") return strings.TrimSpace(parts[0]) } host, _, err := net.SplitHostPort(request.RemoteAddr) if err != nil { return request.RemoteAddr } return host }