From bb2bc223b93b5243b20578da95e8666a97ca87de Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:20:38 +0200 Subject: [PATCH] feat(web): Sessions, Rate-Limiting, Security-Header, Templates und eingebettete Assets Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- internal/web/assets/app.js | 14 ++ internal/web/assets/style.css | 242 +++++++++++++++++++++++++++ internal/web/ratelimit.go | 139 +++++++++++++++ internal/web/ratelimit_test.go | 139 +++++++++++++++ internal/web/render.go | 92 ++++++++++ internal/web/render_test.go | 89 ++++++++++ internal/web/security.go | 46 +++++ internal/web/security_test.go | 90 ++++++++++ internal/web/session.go | 135 +++++++++++++++ internal/web/session_test.go | 130 ++++++++++++++ internal/web/strings.go | 65 +++++++ internal/web/templates/error.html | 8 + internal/web/templates/guides.html | 12 ++ internal/web/templates/layout.html | 37 ++++ internal/web/templates/login.html | 20 +++ internal/web/templates/overview.html | 34 ++++ 16 files changed, 1292 insertions(+) create mode 100644 internal/web/assets/app.js create mode 100644 internal/web/assets/style.css create mode 100644 internal/web/ratelimit.go create mode 100644 internal/web/ratelimit_test.go create mode 100644 internal/web/render.go create mode 100644 internal/web/render_test.go create mode 100644 internal/web/security.go create mode 100644 internal/web/security_test.go create mode 100644 internal/web/session.go create mode 100644 internal/web/session_test.go create mode 100644 internal/web/strings.go create mode 100644 internal/web/templates/error.html create mode 100644 internal/web/templates/guides.html create mode 100644 internal/web/templates/layout.html create mode 100644 internal/web/templates/login.html create mode 100644 internal/web/templates/overview.html diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js new file mode 100644 index 0000000..b1dda4e --- /dev/null +++ b/internal/web/assets/app.js @@ -0,0 +1,14 @@ +// Optionale Bequemlichkeit: Passwort-Anzeigen-Schalter. +// Die Anmeldung funktioniert vollständig ohne JavaScript; der Schalter ist +// im Markup als hidden angelegt und wird erst hier eingeblendet. +(function () { + "use strict"; + var wrap = document.getElementById("toggle-wrap"); + var toggle = document.getElementById("show-password"); + var field = document.getElementById("password"); + if (!wrap || !toggle || !field) return; + wrap.hidden = false; + toggle.addEventListener("change", function () { + field.type = toggle.checked ? "text" : "password"; + }); +})(); diff --git a/internal/web/assets/style.css b/internal/web/assets/style.css new file mode 100644 index 0000000..39adbf9 --- /dev/null +++ b/internal/web/assets/style.css @@ -0,0 +1,242 @@ +/* VPN-Portal — schlichtes, neutrales Layout. + Bewusst zurückhaltend: das Portal wird pro Kunde mit eigenem Titel und Logo + ausgeliefert und soll sich nicht gegen dessen Erscheinungsbild stellen. + Keine externen Ressourcen (Schriften, Bilder) — die CSP verbietet sie. */ + +:root { + --bg: #f4f5f7; + --surface: #ffffff; + --text: #1c1f23; + --text-muted: #5a6472; + --border: #d3d8de; + --accent: #1f5f9e; + --accent-text: #ffffff; + --accent-hover: #17497a; + --error-bg: #fdeaea; + --error-border: #d34d4d; + --error-text: #8a1f1f; + --info-bg: #e8f1fa; + --info-border: #4a86c4; + --info-text: #14456f; + --warn-bg: #fdf3e2; + --warn-border: #d08b1e; + --warn-text: #7a4d05; + --radius: 6px; + --focus: #0b6bcb; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #16191d; + --surface: #1f242a; + --text: #e8ebef; + --text-muted: #9aa5b1; + --border: #39424c; + --accent: #4a90d9; + --accent-text: #0d1013; + --accent-hover: #6aa8e6; + --error-bg: #3a1f1f; + --error-border: #c95a5a; + --error-text: #f3c3c3; + --info-bg: #1c2d3d; + --info-border: #4a86c4; + --info-text: #bcd8f2; + --warn-bg: #3a2f18; + --warn-border: #d08b1e; + --warn-text: #f0d9a8; + --focus: #7db6f0; + } +} + +*, *::before, *::after { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 1rem; + line-height: 1.5; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* ---------- Kopfzeile ---------- */ + +.topbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1rem; + padding: 0.75rem 1.25rem; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.brand { display: flex; align-items: center; gap: 0.75rem; min-width: 0; } +.brand-title { font-weight: 600; font-size: 1.05rem; } +.logo { max-height: 2.25rem; max-width: 12rem; width: auto; height: auto; } + +.topnav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1rem; +} +.topnav .user { color: var(--text-muted); font-size: 0.9rem; } + +/* ---------- Inhalt ---------- */ + +main { + flex: 1; + width: 100%; + max-width: 46rem; + margin: 0 auto; + padding: 1.5rem 1.25rem 3rem; +} + +h1 { font-size: 1.5rem; margin: 0 0 1rem; } +h2 { font-size: 1.15rem; margin: 0 0 0.5rem; } +p { margin: 0 0 1rem; } +p:last-child { margin-bottom: 0; } + +a { color: var(--accent); } +a:hover { color: var(--accent-hover); } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + margin-bottom: 1rem; +} + +.cards { list-style: none; margin: 0; padding: 0; } + +.card dl { + display: grid; + grid-template-columns: minmax(6rem, auto) 1fr; + gap: 0.25rem 1rem; + margin: 0 0 1rem; +} +.card dt { color: var(--text-muted); font-size: 0.9rem; } +.card dd { margin: 0; overflow-wrap: anywhere; } + +.card .client { color: var(--text-muted); margin-bottom: 0.75rem; } +.card ol { margin: 0; padding-left: 1.25rem; } +.card ol li { margin-bottom: 0.4rem; } + +.suggested { border-color: var(--info-border); } +.badge { + display: inline-block; + font-size: 0.75rem; + font-weight: 400; + vertical-align: middle; + padding: 0.15rem 0.5rem; + border-radius: 999px; + background: var(--info-bg); + color: var(--info-text); + border: 1px solid var(--info-border); +} + +/* ---------- Meldungen ---------- */ + +.flash { + padding: 0.85rem 1rem; + border-radius: var(--radius); + border: 1px solid; + margin-bottom: 1.25rem; +} +.flash-error { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); } +.flash-info { background: var(--info-bg); border-color: var(--info-border); color: var(--info-text); } + +.warn { + background: var(--warn-bg); + border-left: 3px solid var(--warn-border); + color: var(--warn-text); + padding: 0.6rem 0.85rem; + border-radius: 0 var(--radius) var(--radius) 0; +} + +.logout-hint { color: var(--text-muted); font-size: 0.9rem; } + +/* ---------- Formulare ---------- */ + +.form { display: flex; flex-direction: column; } +.form label { font-weight: 500; margin-bottom: 0.3rem; } +.form small { color: var(--text-muted); margin: 0.3rem 0 1rem; } + +input[type="text"], input[type="password"] { + font: inherit; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.6rem 0.7rem; + width: 100%; + min-height: 2.75rem; +} + +.toggle-wrap { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 1.25rem; +} +.toggle-wrap label { font-weight: 400; margin: 0; } +.toggle-wrap input { min-height: auto; width: auto; } + +button { + font: inherit; + font-weight: 500; + min-height: 2.75rem; + padding: 0.55rem 1.1rem; + border: 1px solid var(--accent); + border-radius: var(--radius); + background: var(--accent); + color: var(--accent-text); + cursor: pointer; +} +button:hover { background: var(--accent-hover); border-color: var(--accent-hover); } + +button.secondary { + background: transparent; + color: var(--accent); + border-color: var(--border); + min-height: 2.25rem; + padding: 0.35rem 0.8rem; +} +button.secondary:hover { background: var(--info-bg); border-color: var(--accent); } + +.inline { display: inline; margin: 0; } + +.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; } +.actions .inline { display: block; } + +:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +/* ---------- Fußzeile ---------- */ + +.footer { + padding: 1rem 1.25rem; + border-top: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.85rem; + text-align: center; +} + +/* ---------- Schmale Bildschirme ---------- */ + +@media (max-width: 40rem) { + .topbar { flex-direction: column; align-items: flex-start; } + .topnav { width: 100%; } + .actions { flex-direction: column; align-items: stretch; } + .actions button { width: 100%; } + .card dl { grid-template-columns: 1fr; gap: 0; } + .card dt { margin-top: 0.5rem; } +} diff --git a/internal/web/ratelimit.go b/internal/web/ratelimit.go new file mode 100644 index 0000000..7799c00 --- /dev/null +++ b/internal/web/ratelimit.go @@ -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) +} diff --git a/internal/web/ratelimit_test.go b/internal/web/ratelimit_test.go new file mode 100644 index 0000000..6104109 --- /dev/null +++ b/internal/web/ratelimit_test.go @@ -0,0 +1,139 @@ +package web + +import ( + "fmt" + "testing" + "time" +) + +func TestLimiterAllowsFirstAttempts(t *testing.T) { + l := NewLimiter(nil) + for i := 0; i < 3; i++ { + if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok { + t.Fatalf("Versuch %d muss erlaubt sein", i+1) + } + l.RecordFailure("mmueller", "10.0.0.1") + } + if _, ok := l.Allow("mmueller", "10.0.0.1"); ok { + t.Fatal("nach 3 Fehlversuchen muss gebremst werden") + } +} + +func TestBackoffGrowsAndExpires(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + l := NewLimiter(func() time.Time { return now }) + + for i := 0; i < 4; i++ { + l.RecordFailure("mmueller", "10.0.0.1") + } + wait1, ok := l.Allow("mmueller", "10.0.0.1") + if ok { + t.Fatal("nach 4 Fehlversuchen muss gebremst werden") + } + if wait1 <= 0 { + t.Fatalf("retryAfter = %v, muss positiv sein", wait1) + } + + // Wartezeit absitzen -> wieder erlaubt. + now = now.Add(wait1 + time.Second) + if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok { + t.Fatal("nach Ablauf der Wartezeit muss wieder erlaubt sein") + } + + // Weiterer Fehlversuch -> längere Wartezeit als vorher. + l.RecordFailure("mmueller", "10.0.0.1") + wait2, ok := l.Allow("mmueller", "10.0.0.1") + if ok { + t.Fatal("erneuter Fehlversuch muss wieder bremsen") + } + if wait2 <= wait1 { + t.Errorf("Backoff muss wachsen: %v -> %v", wait1, wait2) + } +} + +func TestBackoffIsCapped(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + l := NewLimiter(func() time.Time { return now }) + for i := 0; i < 40; i++ { + l.RecordFailure("mmueller", "10.0.0.1") + } + wait, ok := l.Allow("mmueller", "10.0.0.1") + if ok { + t.Fatal("muss gebremst sein") + } + if wait > maxBackoff { + t.Errorf("retryAfter = %v, Deckel ist %v", wait, maxBackoff) + } +} + +func TestIPLimitProtectsAgainstUserSpray(t *testing.T) { + l := NewLimiter(nil) + // Angreifer probiert viele verschiedene Benutzernamen von einer IP. + for i := 0; i < 12; i++ { + l.RecordFailure(fmt.Sprintf("user%d", i), "10.0.0.9") + } + if _, ok := l.Allow("nochNieProbiert", "10.0.0.9"); ok { + t.Fatal("die IP muss trotz wechselnder Benutzernamen gebremst werden") + } +} + +func TestUserLimitProtectsAcrossIPs(t *testing.T) { + l := NewLimiter(nil) + // Verteilter Angriff auf einen Benutzer von vielen IPs. + for i := 0; i < 12; i++ { + l.RecordFailure("mmueller", fmt.Sprintf("10.0.1.%d", i)) + } + if _, ok := l.Allow("mmueller", "192.168.99.99"); ok { + t.Fatal("der Benutzer muss auch von einer neuen IP gebremst werden") + } +} + +func TestUsernameIsCaseInsensitive(t *testing.T) { + l := NewLimiter(nil) + for i := 0; i < 6; i++ { + l.RecordFailure("MMueller", "10.0.0.1") + } + // Andere Schreibweise darf den Zähler nicht umgehen. + if _, ok := l.Allow("mmueller", "192.168.5.5"); ok { + t.Fatal("Schreibweise des Benutzernamens darf das Limit nicht umgehen") + } +} + +func TestSuccessResetsBothCounters(t *testing.T) { + l := NewLimiter(nil) + for i := 0; i < 5; i++ { + l.RecordFailure("mmueller", "10.0.0.1") + } + if _, ok := l.Allow("mmueller", "10.0.0.1"); ok { + t.Fatal("Vorbedingung: muss gebremst sein") + } + l.RecordSuccess("mmueller", "10.0.0.1") + if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok { + t.Fatal("erfolgreiche Anmeldung muss die Zähler zurücksetzen") + } +} + +func TestNoHardLockout(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + l := NewLimiter(func() time.Time { return now }) + for i := 0; i < 100; i++ { + l.RecordFailure("opfer", "10.0.0.1") + } + // Nach dem gedeckelten Backoff darf der Benutzer wieder anklopfen — + // sonst könnte ein Dritter Kollegen dauerhaft aussperren. + now = now.Add(maxBackoff + time.Second) + if _, ok := l.Allow("opfer", "10.0.0.1"); !ok { + t.Fatal("es darf keinen dauerhaften Lockout geben") + } +} + +func TestGCDropsIdleEntries(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + l := NewLimiter(func() time.Time { return now }) + l.RecordFailure("mmueller", "10.0.0.1") + now = now.Add(2 * entryTTL) + l.GC() + if n := l.size(); n != 0 { + t.Fatalf("GC muss veraltete Einträge entfernen, size = %d", n) + } +} diff --git a/internal/web/render.go b/internal/web/render.go new file mode 100644 index 0000000..52399fe --- /dev/null +++ b/internal/web/render.go @@ -0,0 +1,92 @@ +package web + +import ( + "bytes" + "embed" + "fmt" + "html/template" + "net/http" +) + +//go:embed templates/*.html +var templateFS embed.FS + +// AssetsFS enthält CSS und JavaScript; wird vom Asset-Handler ausgeliefert. +// +//go:embed assets +var AssetsFS embed.FS + +// Brand bündelt die White-Label-Angaben. +type Brand struct { + Title string + SupportContact string + Version string + HasLogo bool +} + +// PageData ist das Wurzelobjekt jedes Templates. +type PageData struct { + Brand Brand + Flash string + FlashKind string // "error" oder "info" + CSRF string + User string + Data any +} + +// errorPageData füllt error.html. +type errorPageData struct { + Title string + Message string + ShowLoginLink bool + ShowOverviewLink bool +} + +// Renderer hält die beim Start geparsten Templates. +type Renderer struct { + brand Brand + pages map[string]*template.Template +} + +// pageNames sind die Inhaltstemplates, die jeweils mit dem Layout kombiniert werden. +var pageNames = []string{"login", "overview", "guides", "error"} + +// NewRenderer parst alle Templates beim Start; ein Fehler bricht den Start ab, +// damit ein Tippfehler im Template nicht erst im Betrieb auffällt. +func NewRenderer(brand Brand) (*Renderer, error) { + funcs := template.FuncMap{"t": T} + pages := make(map[string]*template.Template, len(pageNames)) + for _, name := range pageNames { + tmpl, err := template.New("layout.html").Funcs(funcs). + ParseFS(templateFS, "templates/layout.html", "templates/"+name+".html") + if err != nil { + return nil, fmt.Errorf("Template %s: %w", name, err) + } + pages[name] = tmpl + } + return &Renderer{brand: brand, pages: pages}, nil +} + +// Brand liefert die White-Label-Angaben für Handler. +func (rn *Renderer) Brand() Brand { return rn.brand } + +// Render schreibt eine Seite. Es wird zuerst in einen Puffer gerendert, damit +// ein Template-Fehler nicht zu einer halb geschriebenen Antwort führt. +func (rn *Renderer) Render(w http.ResponseWriter, r *http.Request, status int, page string, data PageData) { + tmpl, ok := rn.pages[page] + if !ok { + http.Error(w, T("error_internal"), http.StatusInternalServerError) + return + } + if data.Brand.Title == "" { + data.Brand = rn.brand + } + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil { + http.Error(w, T("error_internal"), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + w.Write(buf.Bytes()) +} diff --git a/internal/web/render_test.go b/internal/web/render_test.go new file mode 100644 index 0000000..f7011ea --- /dev/null +++ b/internal/web/render_test.go @@ -0,0 +1,89 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testRenderer(t *testing.T) *Renderer { + t.Helper() + rn, err := NewRenderer(Brand{Title: "VPN-Portal Firma GmbH", Version: "1.2.3", + SupportContact: "it@firma.de"}) + if err != nil { + t.Fatalf("NewRenderer: %v", err) + } + return rn +} + +func TestRenderLoginPage(t *testing.T) { + rn := testRenderer(t) + rec := httptest.NewRecorder() + rn.Render(rec, httptest.NewRequest(http.MethodGet, "/login", nil), http.StatusOK, + "login", PageData{Brand: rn.brand}) + + if rec.Code != http.StatusOK { + t.Fatalf("Code = %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + "", `lang="de"`, "VPN-Portal Firma GmbH", + S["login_submit"], S["login_username_hint"], "1.2.3", + `name="password"`, `type="password"`, + } { + if !strings.Contains(body, want) { + t.Errorf("Login-Seite enthält %q nicht", want) + } + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content-Type = %q", ct) + } +} + +func TestRenderEscapesUserContent(t *testing.T) { + rn := testRenderer(t) + rec := httptest.NewRecorder() + rn.Render(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusOK, + "error", PageData{ + Brand: rn.brand, + Flash: ``, + Data: errorPageData{Title: "T", Message: `">`}, + }) + body := rec.Body.String() + if strings.Contains(body, "") { + t.Error("Flash-Text wurde nicht escapt") + } + if strings.Contains(body, "onerror=alert(1)>") { + t.Error("Fehlermeldung wurde nicht escapt") + } +} + +func TestRenderSetsStatusCode(t *testing.T) { + rn := testRenderer(t) + rec := httptest.NewRecorder() + rn.Render(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusServiceUnavailable, + "error", PageData{Brand: rn.brand, Data: errorPageData{Title: "x", Message: "y"}}) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("Code = %d, want 503", rec.Code) + } +} + +func TestAllTemplatesParse(t *testing.T) { + rn := testRenderer(t) + for _, page := range pageNames { + if rn.pages[page] == nil { + t.Errorf("Template %q wurde nicht geladen", page) + } + } +} + +func TestRenderUnknownPageDoesNotPanic(t *testing.T) { + rn := testRenderer(t) + rec := httptest.NewRecorder() + rn.Render(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusOK, + "gibtsnicht", PageData{Brand: rn.brand}) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("Code = %d, want 500", rec.Code) + } +} diff --git a/internal/web/security.go b/internal/web/security.go new file mode 100644 index 0000000..c88c04c --- /dev/null +++ b/internal/web/security.go @@ -0,0 +1,46 @@ +package web + +import ( + "net" + "net/http" + "strings" +) + +// contentSecurityPolicy ist so restriktiv möglich, weil sämtliche Assets +// eingebettet und gleich-origin ausgeliefert werden. Es gibt keine externen +// Ressourcen und kein Inline-Script. +const contentSecurityPolicy = "default-src 'self'; " + + "script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; " + + "connect-src 'none'; object-src 'none'; base-uri 'none'; " + + "form-action 'self'; frame-ancestors 'none'" + +// SecurityHeaders setzt die Sicherheits-Header auf jede Antwort. +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + h.Set("X-Frame-Options", "DENY") + h.Set("Content-Security-Policy", contentSecurityPolicy) + next.ServeHTTP(w, r) + }) +} + +// NoStore verhindert jegliches Zwischenspeichern. +func NoStore(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") +} + +// ClientIP liefert die Quell-IP der Verbindung. X-Forwarded-For wird bewusst +// ignoriert: das Portal terminiert TLS selbst, ein gesetzter Header wäre +// fälschbar und würde das Audit-Log entwerten. +func ClientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return strings.TrimSpace(r.RemoteAddr) + } + return host +} diff --git a/internal/web/security_test.go b/internal/web/security_test.go new file mode 100644 index 0000000..5a9871e --- /dev/null +++ b/internal/web/security_test.go @@ -0,0 +1,90 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSecurityHeaders(t *testing.T) { + h := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + want := map[string]string{ + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "X-Frame-Options": "DENY", + } + for k, v := range want { + if got := rec.Header().Get(k); got != v { + t.Errorf("%s = %q, want %q", k, got, v) + } + } + if hsts := rec.Header().Get("Strict-Transport-Security"); !strings.Contains(hsts, "max-age=") { + t.Errorf("Strict-Transport-Security = %q", hsts) + } + csp := rec.Header().Get("Content-Security-Policy") + for _, directive := range []string{"default-src 'self'", "frame-ancestors 'none'", "form-action 'self'"} { + if !strings.Contains(csp, directive) { + t.Errorf("CSP %q fehlt %q", csp, directive) + } + } + if strings.Contains(csp, "unsafe-inline") || strings.Contains(csp, "unsafe-eval") { + t.Errorf("CSP darf kein unsafe-* enthalten: %q", csp) + } +} + +func TestNoStore(t *testing.T) { + rec := httptest.NewRecorder() + NoStore(rec) + if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") { + t.Errorf("Cache-Control = %q", got) + } +} + +func TestClientIPIgnoresForwardedHeader(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "10.1.20.34:51234" + r.Header.Set("X-Forwarded-For", "1.2.3.4") + // Das Portal terminiert TLS selbst; ein gefälschter Header darf das + // Audit-Log nicht verfälschen. + if got := ClientIP(r); got != "10.1.20.34" { + t.Errorf("ClientIP = %q, want 10.1.20.34", got) + } +} + +func TestClientIPHandlesIPv6AndMalformed(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "[2001:db8::1]:443" + if got := ClientIP(r); got != "2001:db8::1" { + t.Errorf("ClientIP = %q", got) + } + r.RemoteAddr = "kaputt" + if got := ClientIP(r); got != "kaputt" { + t.Errorf("ClientIP = %q, malformed muss unverändert durchgereicht werden", got) + } +} + +func TestStringsAreComplete(t *testing.T) { + required := []string{ + "login_title", "login_username", "login_password", "login_submit", + "login_username_hint", "error_generic_login", "error_password_expired", + "error_rate_limited", "error_session_expired", "overview_title", + "overview_no_certs", "overview_expiry_warning", "download_ovpn", + "download_viscosity", "logout", "guides_title", "error_service_unavailable", + "error_forbidden", "logout_reminder", "valid_until", "vpn_instance", + } + for _, k := range required { + v, ok := S[k] + if !ok || strings.TrimSpace(v) == "" { + t.Errorf("String %q fehlt oder ist leer", k) + } + } + if T("gibt_es_nicht") != "gibt_es_nicht" { + t.Error("T muss bei unbekanntem Schlüssel den Schlüssel liefern, nie einen leeren String") + } +} diff --git a/internal/web/session.go b/internal/web/session.go new file mode 100644 index 0000000..80e1de0 --- /dev/null +++ b/internal/web/session.go @@ -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) + } + } +} diff --git a/internal/web/session_test.go b/internal/web/session_test.go new file mode 100644 index 0000000..179d5d2 --- /dev/null +++ b/internal/web/session_test.go @@ -0,0 +1,130 @@ +package web + +import ( + "testing" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" +) + +func testIdentity() *auth.Identity { + return &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}} +} + +func TestCreateAndGetSession(t *testing.T) { + st := NewSessionStore(10*time.Minute, nil) + sess, err := st.Create(testIdentity()) + if err != nil { + t.Fatalf("Create: %v", err) + } + if len(sess.Token) < 32 { + t.Errorf("Token %q ist zu kurz für ausreichende Entropie", sess.Token) + } + if sess.CSRF == "" || sess.CSRF == sess.Token { + t.Error("CSRF-Token muss existieren und vom Session-Token verschieden sein") + } + got, ok := st.Get(sess.Token) + if !ok || got.Identity.Username != "mmueller" { + t.Fatalf("Get = %+v, ok=%v", got, ok) + } +} + +func TestTokensAreUnique(t *testing.T) { + st := NewSessionStore(time.Minute, nil) + seen := map[string]bool{} + for i := 0; i < 200; i++ { + s, err := st.Create(testIdentity()) + if err != nil { + t.Fatal(err) + } + if seen[s.Token] { + t.Fatalf("doppelter Token %q", s.Token) + } + seen[s.Token] = true + } +} + +func TestSessionExpires(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + st := NewSessionStore(10*time.Minute, func() time.Time { return now }) + + sess, _ := st.Create(testIdentity()) + now = now.Add(9 * time.Minute) + if _, ok := st.Get(sess.Token); !ok { + t.Fatal("Session darf nach 9 von 10 Minuten nicht abgelaufen sein") + } + now = now.Add(2 * time.Minute) + if _, ok := st.Get(sess.Token); ok { + t.Fatal("Session muss nach Ablauf der TTL ungültig sein") + } + if st.Count() != 0 { + t.Errorf("abgelaufene Session muss entfernt werden, Count = %d", st.Count()) + } +} + +func TestTTLIsAbsoluteNotSliding(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + st := NewSessionStore(10*time.Minute, func() time.Time { return now }) + sess, _ := st.Create(testIdentity()) + + // Regelmäßige Zugriffe dürfen die Sitzung nicht verlängern. + for i := 0; i < 5; i++ { + now = now.Add(2 * time.Minute) + st.Get(sess.Token) + } + if _, ok := st.Get(sess.Token); ok { + t.Fatal("die TTL ist absolut; Aktivität darf sie nicht verlängern") + } +} + +func TestDestroyRemovesSession(t *testing.T) { + st := NewSessionStore(time.Minute, nil) + sess, _ := st.Create(testIdentity()) + st.Destroy(sess.Token) + if _, ok := st.Get(sess.Token); ok { + t.Fatal("zerstörte Session darf nicht mehr auffindbar sein") + } + st.Destroy("existiert-nicht") // darf nicht paniken +} + +func TestGetRejectsUnknownAndEmptyToken(t *testing.T) { + st := NewSessionStore(time.Minute, nil) + if _, ok := st.Get(""); ok { + t.Error("leerer Token darf nie gültig sein") + } + if _, ok := st.Get("beliebig"); ok { + t.Error("unbekannter Token darf nie gültig sein") + } +} + +func TestValidCSRF(t *testing.T) { + st := NewSessionStore(time.Minute, nil) + sess, _ := st.Create(testIdentity()) + if !sess.ValidCSRF(sess.CSRF) { + t.Error("eigenes CSRF-Token muss akzeptiert werden") + } + for _, bad := range []string{"", "falsch", sess.CSRF + "x", sess.CSRF[:len(sess.CSRF)-1]} { + if sess.ValidCSRF(bad) { + t.Errorf("CSRF-Token %q darf nicht akzeptiert werden", bad) + } + } +} + +func TestGCRemovesOnlyExpired(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + st := NewSessionStore(10*time.Minute, func() time.Time { return now }) + old, _ := st.Create(testIdentity()) + now = now.Add(11 * time.Minute) + fresh, _ := st.Create(testIdentity()) + + st.GC() + if st.Count() != 1 { + t.Fatalf("Count = %d, want 1", st.Count()) + } + if _, ok := st.Get(old.Token); ok { + t.Error("alte Session muss weg sein") + } + if _, ok := st.Get(fresh.Token); !ok { + t.Error("frische Session muss bleiben") + } +} diff --git a/internal/web/strings.go b/internal/web/strings.go new file mode 100644 index 0000000..d58b234 --- /dev/null +++ b/internal/web/strings.go @@ -0,0 +1,65 @@ +package web + +// S enthält alle Texte der Oberfläche. Die Map-Struktur ist bewusst +// vorbereitet für spätere Mehrsprachigkeit; v1 füllt nur Deutsch. +// Kein deutschsprachiger Text gehört direkt in einen Handler. +var S = map[string]string{ + "login_title": "Anmeldung", + "login_username": "Benutzername", + "login_password": "Passwort", + "login_submit": "Anmelden", + "login_username_hint": "z. B. mmueller oder m.mueller@firma.de", + "login_show_password": "Passwort anzeigen", + + "error_generic_login": "Anmeldung fehlgeschlagen. Bitte prüfen Sie Benutzername und Passwort.", + "error_password_expired": "Ihr Passwort ist abgelaufen. Bitte ändern Sie es zuerst an Ihrem Arbeitsplatz.", + "error_rate_limited": "Zu viele Anmeldeversuche. Bitte versuchen Sie es in %s erneut.", + "error_session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + + "error_service_unavailable": "Dienst vorübergehend nicht verfügbar", + "error_service_unavailable_body": "Die Firewall ist derzeit nicht erreichbar. " + + "Bitte versuchen Sie es in einigen Minuten erneut.", + "error_forbidden": "Zugriff verweigert", + "error_forbidden_body": "Diese Anfrage ist nicht zulässig. " + + "Bitte starten Sie den Download erneut über die Übersicht.", + "error_not_found": "Seite nicht gefunden", + "error_not_found_body": "Die aufgerufene Adresse gibt es nicht.", + "error_csrf": "Die Anfrage ist abgelaufen oder ungültig. Bitte versuchen Sie es erneut.", + "error_internal": "Unerwarteter Fehler", + "error_internal_body": "Es ist ein interner Fehler aufgetreten. Bitte versuchen Sie es erneut.", + + "overview_title": "Ihre VPN-Konfigurationen", + "overview_intro": "Laden Sie hier Ihre persönliche OpenVPN-Konfiguration herunter.", + "overview_no_certs": "Für Ihr Benutzerkonto ist derzeit keine VPN-Konfiguration hinterlegt.", + "overview_no_certs_contact": "Bitte wenden Sie sich an %s.", + "overview_expiry_warning": "Achtung: Dieses Zertifikat läuft in %d Tagen ab.", + "overview_expired_today": "Achtung: Dieses Zertifikat läuft heute ab.", + + "vpn_instance": "VPN-Verbindung", + "common_name": "Zertifikat", + "valid_until": "Gültig bis", + + "download_ovpn": "OpenVPN-Datei (.ovpn)", + "download_viscosity": "Viscosity-Paket", + + "logout": "Abmelden", + "logout_reminder": "Bitte melden Sie sich nach dem Herunterladen ab.", + "logout_done": "Sie wurden abgemeldet.", + + "guides_title": "Anleitungen", + "guides_intro": "Kurzanleitungen zum Einrichten der VPN-Verbindung auf Ihrem Gerät.", + "guides_link": "Anleitungen", + "guides_suggested": "Passt vermutlich zu Ihrem Gerät", + + "back_to_overview": "Zurück zur Übersicht", + "footer_version": "Version", +} + +// T liefert den Text zu einem Schlüssel. Fehlt der Schlüssel, wird er selbst +// zurückgegeben — eine leere Fläche in der Oberfläche wäre schlimmer. +func T(key string) string { + if v, ok := S[key]; ok { + return v + } + return key +} diff --git a/internal/web/templates/error.html b/internal/web/templates/error.html new file mode 100644 index 0000000..6a0f722 --- /dev/null +++ b/internal/web/templates/error.html @@ -0,0 +1,8 @@ +{{define "content"}} +

{{.Data.Title}}

+
+

{{.Data.Message}}

+ {{if .Data.ShowLoginLink}}

{{t "login_submit"}}

{{end}} + {{if .Data.ShowOverviewLink}}

{{t "back_to_overview"}}

{{end}} +
+{{end}} diff --git a/internal/web/templates/guides.html b/internal/web/templates/guides.html new file mode 100644 index 0000000..ee27955 --- /dev/null +++ b/internal/web/templates/guides.html @@ -0,0 +1,12 @@ +{{define "content"}} +

{{t "guides_title"}}

+

{{t "guides_intro"}}

+{{range .Data.Guides}} +
+

{{.Platform}}{{if .Suggested}} {{t "guides_suggested"}}{{end}}

+

{{.Client}}

+
    {{range .Steps}}
  1. {{.}}
  2. {{end}}
+
+{{end}} +

{{t "back_to_overview"}}

+{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html new file mode 100644 index 0000000..0e6ee4e --- /dev/null +++ b/internal/web/templates/layout.html @@ -0,0 +1,37 @@ +{{define "layout"}} + + + + + +{{.Brand.Title}} + + + +
+
+ {{if .Brand.HasLogo}}{{end}} + {{.Brand.Title}} +
+ {{if .User}} + + {{end}} +
+ +
+ {{if .Flash}}
{{.Flash}}
{{end}} + {{block "content" .}}{{end}} +
+ +
+ {{t "footer_version"}} {{.Brand.Version}} +
+ +{{end}} diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html new file mode 100644 index 0000000..2b6e875 --- /dev/null +++ b/internal/web/templates/login.html @@ -0,0 +1,20 @@ +{{define "content"}} +

{{t "login_title"}}

+
+ + + {{t "login_username_hint"}} + + + + + + +
+ +{{end}} diff --git a/internal/web/templates/overview.html b/internal/web/templates/overview.html new file mode 100644 index 0000000..a670a20 --- /dev/null +++ b/internal/web/templates/overview.html @@ -0,0 +1,34 @@ +{{define "content"}} +

{{t "overview_title"}}

+{{if .Data.Entries}} +

{{t "overview_intro"}}

+ +

{{t "logout_reminder"}}

+{{else}} +
+

{{t "overview_no_certs"}}

+ {{if .Data.SupportHint}}

{{.Data.SupportHint}}

{{end}} +
+{{end}} +{{end}}