feat(web): Sessions, Rate-Limiting, Security-Header, Templates und eingebettete Assets

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
Carsten Abele 2026-08-14 09:20:38 +02:00
parent e49882b8a8
commit bb2bc223b9
16 changed files with 1292 additions and 0 deletions

135
internal/web/session.go Normal file
View file

@ -0,0 +1,135 @@
// 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)
}
}
}