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

139
internal/web/ratelimit.go Normal file
View file

@ -0,0 +1,139 @@
package web
import (
"math"
"strings"
"sync"
"time"
)
const (
// freeAttempts sind die Fehlversuche ohne Wartezeit — genug für Tippfehler.
freeAttempts = 3
// maxBackoff deckelt die Wartezeit. Ein harter Lockout wäre selbst eine
// Denial-of-Service-Möglichkeit gegen Kollegen.
maxBackoff = 5 * time.Minute
// entryTTL räumt ruhende Zähler ab.
entryTTL = 1 * time.Hour
)
type limitEntry struct {
failures int
lastFail time.Time
}
// Limiter bremst Anmeldeversuche pro Benutzername und pro Quell-IP.
// Beide Schlüssel werden geführt: die IP fängt Passwort-Spraying ab, der
// Benutzername verteilte Angriffe und den NAT-Fall.
type Limiter struct {
clock func() time.Time
mu sync.Mutex
entries map[string]*limitEntry
}
func NewLimiter(clock func() time.Time) *Limiter {
if clock == nil {
clock = time.Now
}
return &Limiter{clock: clock, entries: make(map[string]*limitEntry)}
}
// backoff berechnet die Wartezeit nach n Fehlversuchen.
// Die ersten freeAttempts Versuche sind frei: bei freeAttempts=3 sind die
// Versuche 1 bis 3 ungebremst, ab dem vierten wird gewartet.
func backoff(failures int) time.Duration {
if failures < freeAttempts {
return 0
}
exp := failures - freeAttempts + 1
if exp > 20 { // schützt vor Overflow in math.Pow
return maxBackoff
}
d := time.Duration(math.Pow(2, float64(exp))) * time.Second
if d > maxBackoff {
return maxBackoff
}
return d
}
// keys liefert die beiden Zählerschlüssel. Benutzernamen werden
// normalisiert, damit "MMueller" und "mmueller" derselbe Zähler sind.
func keys(username, ip string) []string {
var out []string
if u := strings.ToLower(strings.TrimSpace(username)); u != "" {
out = append(out, "u:"+u)
}
if ip != "" {
out = append(out, "i:"+ip)
}
return out
}
// Allow meldet, ob ein Versuch erlaubt ist, und andernfalls die Restwartezeit.
// Allow zählt nicht mit — dafür ist RecordFailure zuständig.
func (l *Limiter) Allow(username, ip string) (time.Duration, bool) {
now := l.clock()
l.mu.Lock()
defer l.mu.Unlock()
var worst time.Duration
for _, k := range keys(username, ip) {
e, ok := l.entries[k]
if !ok {
continue
}
wait := backoff(e.failures) - now.Sub(e.lastFail)
if wait > worst {
worst = wait
}
}
if worst > 0 {
return worst, false
}
return 0, true
}
// RecordFailure erhöht beide Zähler.
func (l *Limiter) RecordFailure(username, ip string) {
now := l.clock()
l.mu.Lock()
defer l.mu.Unlock()
for _, k := range keys(username, ip) {
e, ok := l.entries[k]
if !ok {
e = &limitEntry{}
l.entries[k] = e
}
e.failures++
e.lastFail = now
}
}
// RecordSuccess setzt beide Zähler zurück.
func (l *Limiter) RecordSuccess(username, ip string) {
l.mu.Lock()
defer l.mu.Unlock()
for _, k := range keys(username, ip) {
delete(l.entries, k)
}
}
// GC entfernt Zähler, die lange nicht mehr angefasst wurden.
func (l *Limiter) GC() {
now := l.clock()
l.mu.Lock()
defer l.mu.Unlock()
for k, e := range l.entries {
if now.Sub(e.lastFail) > entryTTL {
delete(l.entries, k)
}
}
}
// size ist ein Testhelfer.
func (l *Limiter) size() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.entries)
}