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

View file

@ -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";
});
})();

View file

@ -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; }
}

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)
}

View file

@ -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)
}
}

92
internal/web/render.go Normal file
View file

@ -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())
}

View file

@ -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{
"<!DOCTYPE html>", `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: `<script>alert(1)</script>`,
Data: errorPageData{Title: "T", Message: `"><img src=x onerror=alert(1)>`},
})
body := rec.Body.String()
if strings.Contains(body, "<script>alert(1)</script>") {
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)
}
}

46
internal/web/security.go Normal file
View file

@ -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
}

View file

@ -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")
}
}

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)
}
}
}

View file

@ -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")
}
}

65
internal/web/strings.go Normal file
View file

@ -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
}

View file

@ -0,0 +1,8 @@
{{define "content"}}
<h1>{{.Data.Title}}</h1>
<div class="card error">
<p>{{.Data.Message}}</p>
{{if .Data.ShowLoginLink}}<p><a href="/login">{{t "login_submit"}}</a></p>{{end}}
{{if .Data.ShowOverviewLink}}<p><a href="/">{{t "back_to_overview"}}</a></p>{{end}}
</div>
{{end}}

View file

@ -0,0 +1,12 @@
{{define "content"}}
<h1>{{t "guides_title"}}</h1>
<p>{{t "guides_intro"}}</p>
{{range .Data.Guides}}
<section class="card{{if .Suggested}} suggested{{end}}">
<h2>{{.Platform}}{{if .Suggested}} <span class="badge">{{t "guides_suggested"}}</span>{{end}}</h2>
<p class="client">{{.Client}}</p>
<ol>{{range .Steps}}<li>{{.}}</li>{{end}}</ol>
</section>
{{end}}
<p><a href="/">{{t "back_to_overview"}}</a></p>
{{end}}

View file

@ -0,0 +1,37 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{{.Brand.Title}}</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
{{if .Brand.HasLogo}}<img src="/assets/logo" alt="" class="logo">{{end}}
<span class="brand-title">{{.Brand.Title}}</span>
</div>
{{if .User}}
<nav class="topnav">
<a href="/anleitungen">{{t "guides_link"}}</a>
<span class="user">{{.User}}</span>
<form method="post" action="/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<button type="submit" class="secondary">{{t "logout"}}</button>
</form>
</nav>
{{end}}
</header>
<main>
{{if .Flash}}<div class="flash flash-{{.FlashKind}}" role="status">{{.Flash}}</div>{{end}}
{{block "content" .}}{{end}}
</main>
<footer class="footer">
<span>{{t "footer_version"}} {{.Brand.Version}}</span>
</footer>
</body>
</html>{{end}}

View file

@ -0,0 +1,20 @@
{{define "content"}}
<h1>{{t "login_title"}}</h1>
<form method="post" action="/login" class="card form" autocomplete="on">
<label for="username">{{t "login_username"}}</label>
<input type="text" id="username" name="username" required autofocus
autocomplete="username" autocapitalize="none" spellcheck="false"
aria-describedby="username-hint">
<small id="username-hint">{{t "login_username_hint"}}</small>
<label for="password">{{t "login_password"}}</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<div class="toggle-wrap" hidden id="toggle-wrap">
<input type="checkbox" id="show-password">
<label for="show-password">{{t "login_show_password"}}</label>
</div>
<button type="submit">{{t "login_submit"}}</button>
</form>
<script src="/assets/app.js" defer></script>
{{end}}

View file

@ -0,0 +1,34 @@
{{define "content"}}
<h1>{{t "overview_title"}}</h1>
{{if .Data.Entries}}
<p>{{t "overview_intro"}}</p>
<ul class="cards">
{{range .Data.Entries}}
<li class="card">
<h2>{{.InstanceName}}</h2>
<dl>
<dt>{{t "common_name"}}</dt><dd>{{.CommonName}}</dd>
{{if .ValidUntil}}<dt>{{t "valid_until"}}</dt><dd>{{.ValidUntil}}</dd>{{end}}
</dl>
{{if .ExpiryWarning}}<p class="warn">{{.ExpiryWarning}}</p>{{end}}
<div class="actions">
{{range .Formats}}
<form method="post" action="/download" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="cert" value="{{.Token}}">
<input type="hidden" name="format" value="{{.Format}}">
<button type="submit">{{.Label}}</button>
</form>
{{end}}
</div>
</li>
{{end}}
</ul>
<p class="logout-hint" role="note">{{t "logout_reminder"}}</p>
{{else}}
<div class="card empty">
<p>{{t "overview_no_certs"}}</p>
{{if .Data.SupportHint}}<p>{{.Data.SupportHint}}</p>{{end}}
</div>
{{end}}
{{end}}