Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
135 lines
3.2 KiB
Go
135 lines
3.2 KiB
Go
// 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)
|
|
}
|
|
}
|
|
}
|