Files
ClusterCanvas/service/internal/auth/session.go
T
Squid 463aa9a7a3 Add activity logs, sidebar badges, and logout with working alert colors.
Record auth audits and surface them in Activity, poll sidebar counts without resetting idle, and add a profile logout path plus theme red/green/blue tokens so failure badges render.
2026-07-18 21:43:44 +02:00

295 lines
7.6 KiB
Go

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.
// When touchActivity is true, LastSeenAt is updated and the session may rotate.
// When touchActivity is false, the session is validated only (no LastSeenAt update,
// rotation, or sessions file write unless the session is being destroyed as idle/expired).
func (manager *SessionManager) LookupValidSession(
writer http.ResponseWriter,
request *http.Request,
security settings.SecuritySettings,
touchActivity bool,
) (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 = &copySession
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
}
if !touchActivity {
return *found, nil
}
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
}