// Package web enthält HTTP-Handler, Templates, Sessions und Schutzmechanismen. package web import ( "crypto/rand" "crypto/subtle" "encoding/base64" "fmt" "sync" "time" "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" ) // SessionCookieName ist der Name des Session-Cookies. const SessionCookieName = "vpnportal_session" // tokenBytes ergibt nach Base64-URL-Kodierung 43 Zeichen bei 256 Bit Entropie. const tokenBytes = 32 // Session ist ein angemeldeter Benutzer. Sessions existieren ausschließlich im // RAM; ein Neustart meldet alle Benutzer ab — das ist beabsichtigt. type Session struct { Token string CSRF string Identity *auth.Identity Created time.Time Expires time.Time } // ValidCSRF vergleicht in konstanter Zeit. func (s *Session) ValidCSRF(token string) bool { if token == "" || s.CSRF == "" { return false } return subtle.ConstantTimeCompare([]byte(s.CSRF), []byte(token)) == 1 } // SessionStore hält Sessions im Speicher. type SessionStore struct { ttl time.Duration clock func() time.Time mu sync.Mutex sessions map[string]*Session } // NewSessionStore erzeugt den Store. clock ist injizierbar; nil = time.Now. func NewSessionStore(ttl time.Duration, clock func() time.Time) *SessionStore { if clock == nil { clock = time.Now } if ttl <= 0 { ttl = 10 * time.Minute } return &SessionStore{ttl: ttl, clock: clock, sessions: make(map[string]*Session)} } // randomToken liefert einen kryptografisch zufälligen, URL-sicheren Token. func randomToken() (string, error) { buf := make([]byte, tokenBytes) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("Zufallszahlengenerator nicht verfügbar: %w", err) } return base64.RawURLEncoding.EncodeToString(buf), nil } // Create legt eine neue Session an. func (s *SessionStore) Create(id *auth.Identity) (*Session, error) { token, err := randomToken() if err != nil { return nil, err } csrf, err := randomToken() if err != nil { return nil, err } now := s.clock() sess := &Session{ Token: token, CSRF: csrf, Identity: id, Created: now, Expires: now.Add(s.ttl), } s.mu.Lock() s.sessions[token] = sess s.mu.Unlock() return sess, nil } // Get liefert eine gültige Session. Abgelaufene Sessions werden entfernt. // Die TTL ist absolut und wird bewusst nicht durch Aktivität verlängert. func (s *SessionStore) Get(token string) (*Session, bool) { if token == "" { return nil, false } s.mu.Lock() defer s.mu.Unlock() sess, ok := s.sessions[token] if !ok { return nil, false } if !s.clock().Before(sess.Expires) { delete(s.sessions, token) return nil, false } return sess, true } // Destroy meldet eine Session ab. func (s *SessionStore) Destroy(token string) { s.mu.Lock() delete(s.sessions, token) s.mu.Unlock() } // Count liefert die Zahl gespeicherter Sessions (Tests, /healthz). func (s *SessionStore) Count() int { s.mu.Lock() defer s.mu.Unlock() return len(s.sessions) } // GC entfernt abgelaufene Sessions; wird periodisch aufgerufen. func (s *SessionStore) GC() { now := s.clock() s.mu.Lock() defer s.mu.Unlock() for token, sess := range s.sessions { if !now.Before(sess.Expires) { delete(s.sessions, token) } } }