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.Message}}
+ {{if .Data.ShowLoginLink}}{{end}} + {{if .Data.ShowOverviewLink}}{{end}} +{{t "guides_intro"}}
+{{range .Data.Guides}} +{{.Client}}
+{{t "overview_intro"}}
+{{.ExpiryWarning}}
{{end}} +{{t "logout_reminder"}}
+{{else}} +{{t "overview_no_certs"}}
+ {{if .Data.SupportHint}}{{.Data.SupportHint}}
{{end}} +