feat(web): Sessions, Rate-Limiting, Security-Header, Templates und eingebettete Assets
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
parent
e49882b8a8
commit
bb2bc223b9
16 changed files with 1292 additions and 0 deletions
14
internal/web/assets/app.js
Normal file
14
internal/web/assets/app.js
Normal 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";
|
||||||
|
});
|
||||||
|
})();
|
||||||
242
internal/web/assets/style.css
Normal file
242
internal/web/assets/style.css
Normal 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
139
internal/web/ratelimit.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// freeAttempts sind die Fehlversuche ohne Wartezeit — genug für Tippfehler.
|
||||||
|
freeAttempts = 3
|
||||||
|
// maxBackoff deckelt die Wartezeit. Ein harter Lockout wäre selbst eine
|
||||||
|
// Denial-of-Service-Möglichkeit gegen Kollegen.
|
||||||
|
maxBackoff = 5 * time.Minute
|
||||||
|
// entryTTL räumt ruhende Zähler ab.
|
||||||
|
entryTTL = 1 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
type limitEntry struct {
|
||||||
|
failures int
|
||||||
|
lastFail time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter bremst Anmeldeversuche pro Benutzername und pro Quell-IP.
|
||||||
|
// Beide Schlüssel werden geführt: die IP fängt Passwort-Spraying ab, der
|
||||||
|
// Benutzername verteilte Angriffe und den NAT-Fall.
|
||||||
|
type Limiter struct {
|
||||||
|
clock func() time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]*limitEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLimiter(clock func() time.Time) *Limiter {
|
||||||
|
if clock == nil {
|
||||||
|
clock = time.Now
|
||||||
|
}
|
||||||
|
return &Limiter{clock: clock, entries: make(map[string]*limitEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backoff berechnet die Wartezeit nach n Fehlversuchen.
|
||||||
|
// Die ersten freeAttempts Versuche sind frei: bei freeAttempts=3 sind die
|
||||||
|
// Versuche 1 bis 3 ungebremst, ab dem vierten wird gewartet.
|
||||||
|
func backoff(failures int) time.Duration {
|
||||||
|
if failures < freeAttempts {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
exp := failures - freeAttempts + 1
|
||||||
|
if exp > 20 { // schützt vor Overflow in math.Pow
|
||||||
|
return maxBackoff
|
||||||
|
}
|
||||||
|
d := time.Duration(math.Pow(2, float64(exp))) * time.Second
|
||||||
|
if d > maxBackoff {
|
||||||
|
return maxBackoff
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// keys liefert die beiden Zählerschlüssel. Benutzernamen werden
|
||||||
|
// normalisiert, damit "MMueller" und "mmueller" derselbe Zähler sind.
|
||||||
|
func keys(username, ip string) []string {
|
||||||
|
var out []string
|
||||||
|
if u := strings.ToLower(strings.TrimSpace(username)); u != "" {
|
||||||
|
out = append(out, "u:"+u)
|
||||||
|
}
|
||||||
|
if ip != "" {
|
||||||
|
out = append(out, "i:"+ip)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow meldet, ob ein Versuch erlaubt ist, und andernfalls die Restwartezeit.
|
||||||
|
// Allow zählt nicht mit — dafür ist RecordFailure zuständig.
|
||||||
|
func (l *Limiter) Allow(username, ip string) (time.Duration, bool) {
|
||||||
|
now := l.clock()
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
var worst time.Duration
|
||||||
|
for _, k := range keys(username, ip) {
|
||||||
|
e, ok := l.entries[k]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wait := backoff(e.failures) - now.Sub(e.lastFail)
|
||||||
|
if wait > worst {
|
||||||
|
worst = wait
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if worst > 0 {
|
||||||
|
return worst, false
|
||||||
|
}
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFailure erhöht beide Zähler.
|
||||||
|
func (l *Limiter) RecordFailure(username, ip string) {
|
||||||
|
now := l.clock()
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
for _, k := range keys(username, ip) {
|
||||||
|
e, ok := l.entries[k]
|
||||||
|
if !ok {
|
||||||
|
e = &limitEntry{}
|
||||||
|
l.entries[k] = e
|
||||||
|
}
|
||||||
|
e.failures++
|
||||||
|
e.lastFail = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSuccess setzt beide Zähler zurück.
|
||||||
|
func (l *Limiter) RecordSuccess(username, ip string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
for _, k := range keys(username, ip) {
|
||||||
|
delete(l.entries, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GC entfernt Zähler, die lange nicht mehr angefasst wurden.
|
||||||
|
func (l *Limiter) GC() {
|
||||||
|
now := l.clock()
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
for k, e := range l.entries {
|
||||||
|
if now.Sub(e.lastFail) > entryTTL {
|
||||||
|
delete(l.entries, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// size ist ein Testhelfer.
|
||||||
|
func (l *Limiter) size() int {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
return len(l.entries)
|
||||||
|
}
|
||||||
139
internal/web/ratelimit_test.go
Normal file
139
internal/web/ratelimit_test.go
Normal 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
92
internal/web/render.go
Normal 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())
|
||||||
|
}
|
||||||
89
internal/web/render_test.go
Normal file
89
internal/web/render_test.go
Normal 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
46
internal/web/security.go
Normal 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
|
||||||
|
}
|
||||||
90
internal/web/security_test.go
Normal file
90
internal/web/security_test.go
Normal 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
135
internal/web/session.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
internal/web/session_test.go
Normal file
130
internal/web/session_test.go
Normal 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
65
internal/web/strings.go
Normal 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
|
||||||
|
}
|
||||||
8
internal/web/templates/error.html
Normal file
8
internal/web/templates/error.html
Normal 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}}
|
||||||
12
internal/web/templates/guides.html
Normal file
12
internal/web/templates/guides.html
Normal 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}}
|
||||||
37
internal/web/templates/layout.html
Normal file
37
internal/web/templates/layout.html
Normal 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}}
|
||||||
20
internal/web/templates/login.html
Normal file
20
internal/web/templates/login.html
Normal 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}}
|
||||||
34
internal/web/templates/overview.html
Normal file
34
internal/web/templates/overview.html
Normal 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}}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue