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:
parent
e49882b8a8
commit
bb2bc223b9
16 changed files with 1292 additions and 0 deletions
139
internal/web/ratelimit.go
Normal file
139
internal/web/ratelimit.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue