feat(web): Router, Login/Logout, Übersicht, CSRF-geschützter Download, Anleitungen und /healthz

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:25:52 +02:00
parent bb2bc223b9
commit eb89d78ce2
8 changed files with 1618 additions and 0 deletions

View file

@ -0,0 +1,363 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
// fakeAuth ist ein Authenticator-Double.
type fakeAuth struct {
id *auth.Identity
err error
// calls zählt Aufrufe, um Rate-Limiting-Verhalten zu prüfen.
calls int
}
func (f *fakeAuth) Authenticate(ctx context.Context, u, p string) (*auth.Identity, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.id, nil
}
// recordAudit sammelt Events für Assertions.
type recordAudit struct{ events []audit.Event }
func (r *recordAudit) Log(e audit.Event) { r.events = append(r.events, e) }
func (r *recordAudit) find(name string) (audit.Event, bool) {
for _, e := range r.events {
if e.Event == name {
return e, true
}
}
return audit.Event{}, false
}
// fakeCerts ist eine CertSource ohne Firewall.
type fakeCerts struct {
entries []CertEntry
err error
}
func (f *fakeCerts) EntriesFor(ctx context.Context, username string) ([]CertEntry, error) {
return f.entries, f.err
}
func (f *fakeCerts) Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error) {
return nil, ErrCertNotOwned
}
func newTestServer(t *testing.T, d Deps) (*Server, *recordAudit) {
t.Helper()
rec := &recordAudit{}
if d.Audit == nil {
d.Audit = rec
} else if r, ok := d.Audit.(*recordAudit); ok {
rec = r
}
if d.Sessions == nil {
d.Sessions = NewSessionStore(10*time.Minute, d.Clock)
}
if d.Limiter == nil {
d.Limiter = NewLimiter(d.Clock)
}
if d.Certs == nil {
d.Certs = &fakeCerts{}
}
if d.Renderer == nil {
rn, err := NewRenderer(Brand{Title: "Testportal", Version: "test"})
if err != nil {
t.Fatal(err)
}
d.Renderer = rn
}
// Tests sollen nicht an der Timing-Angleichung hängen.
if d.MinLoginDuration == 0 {
d.MinLoginDuration = time.Millisecond
}
srv, err := NewServer(d)
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return srv, rec
}
func postForm(h http.Handler, path string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.RemoteAddr = "10.1.20.34:5000"
for _, c := range cookies {
r.AddCookie(c)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
return rec
}
func sessionCookie(rec *httptest.ResponseRecorder) *http.Cookie {
for _, c := range rec.Result().Cookies() {
if c.Name == SessionCookieName && c.Value != "" {
return c
}
}
return nil
}
func TestLoginGETRendersForm(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), S["login_submit"]) {
t.Error("Anmeldeformular fehlt")
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q", cc)
}
}
func TestLoginSuccessSetsHardenedCookieAndRedirects(t *testing.T) {
fa := &fakeAuth{id: &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}}
srv, rec := newTestServer(t, Deps{Auth: fa})
resp := postForm(srv.Handler(), "/login",
url.Values{"username": {"MMueller"}, "password": {"geheim"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", resp.Code)
}
if loc := resp.Header().Get("Location"); loc != "/" {
t.Errorf("Location = %q", loc)
}
c := sessionCookie(resp)
if c == nil {
t.Fatal("Session-Cookie fehlt")
}
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode || c.Path != "/" {
t.Errorf("Cookie nicht gehärtet: %+v", c)
}
ev, ok := rec.find(audit.EventLoginSuccess)
if !ok {
t.Fatal("login_success fehlt im Audit-Log")
}
if ev.User != "mmueller" || ev.SrcIP != "10.1.20.34" || ev.Session == "" {
t.Errorf("Audit-Event unvollständig: %+v", ev)
}
if strings.Contains(c.Value, ev.Session) {
t.Error("Session-Feld darf kein Teil des echten Tokens sein")
}
}
func TestLoginFailureShowsGenericMessage(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonAccountDisabled}}
srv, rec := newTestServer(t, Deps{Auth: fa})
resp := postForm(srv.Handler(), "/login",
url.Values{"username": {"mmueller"}, "password": {"x"}})
if resp.Code != http.StatusUnauthorized {
t.Fatalf("Code = %d, want 401", resp.Code)
}
body := resp.Body.String()
if !strings.Contains(body, S["error_generic_login"]) {
t.Error("generische Fehlermeldung fehlt")
}
if strings.Contains(body, "deaktiviert") || strings.Contains(body, auth.ReasonAccountDisabled) {
t.Error("der Grund darf dem Benutzer nicht verraten werden")
}
ev, ok := rec.find(audit.EventLoginFailed)
if !ok || ev.Reason != auth.ReasonAccountDisabled {
t.Errorf("Audit-Reason = %+v", ev)
}
}
func TestExpiredPasswordIsTheOnlySpecificMessage(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonPasswordExpired}}
srv, _ := newTestServer(t, Deps{Auth: fa})
resp := postForm(srv.Handler(), "/login",
url.Values{"username": {"mmueller"}, "password": {"x"}})
if !strings.Contains(resp.Body.String(), S["error_password_expired"]) {
t.Error("bei abgelaufenem Passwort muss die spezifische Meldung erscheinen")
}
}
func TestUnknownUserIsNeverLoggedInClear(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonUserNotFound}}
srv, rec := newTestServer(t, Deps{Auth: fa})
// Klassischer Unfall: Passwort im Benutzernamenfeld.
secret := "MeinGeheimesPasswort123"
postForm(srv.Handler(), "/login", url.Values{"username": {secret}, "password": {"x"}})
ev, ok := rec.find(audit.EventLoginFailed)
if !ok {
t.Fatal("login_failed fehlt")
}
if ev.User != audit.UnknownUser {
t.Errorf("User = %q, want %q", ev.User, audit.UnknownUser)
}
for _, e := range rec.events {
if strings.Contains(e.User+e.Detail+e.Reason, secret) {
t.Fatalf("Eingabe aus dem Benutzerfeld ist im Log gelandet: %+v", e)
}
}
}
func TestPasswordNeverAppearsInAnyEvent(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonInvalidCredentials}}
srv, rec := newTestServer(t, Deps{Auth: fa})
const pw = "Sup3rGeheim!"
postForm(srv.Handler(), "/login", url.Values{"username": {"mmueller"}, "password": {pw}})
for _, e := range rec.events {
if strings.Contains(e.User+e.Detail+e.Reason+e.Format+e.Pattern, pw) {
t.Fatalf("Passwort im Audit-Log: %+v", e)
}
}
}
func TestRateLimitBlocksAndLogs(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonInvalidCredentials}}
srv, rec := newTestServer(t, Deps{Auth: fa})
form := url.Values{"username": {"mmueller"}, "password": {"falsch"}}
for i := 0; i < 4; i++ {
postForm(srv.Handler(), "/login", form)
}
before := fa.calls
resp := postForm(srv.Handler(), "/login", form)
if resp.Code != http.StatusTooManyRequests {
t.Fatalf("Code = %d, want 429", resp.Code)
}
if fa.calls != before {
t.Error("bei Rate-Limit darf das Verzeichnis nicht mehr befragt werden")
}
if resp.Header().Get("Retry-After") == "" {
t.Error("Retry-After-Header fehlt")
}
if _, ok := rec.find(audit.EventRateLimited); !ok {
t.Error("rate_limited fehlt im Audit-Log")
}
}
func TestLogoutDestroysSession(t *testing.T) {
fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}}
store := NewSessionStore(10*time.Minute, nil)
srv, rec := newTestServer(t, Deps{Auth: fa, Sessions: store})
login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}})
cookie := sessionCookie(login)
sess, ok := store.Get(cookie.Value)
if !ok {
t.Fatal("Session muss existieren")
}
out := postForm(srv.Handler(), "/logout", url.Values{"csrf_token": {sess.CSRF}}, cookie)
if out.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", out.Code)
}
if _, ok := store.Get(cookie.Value); ok {
t.Fatal("Session muss zerstört sein")
}
if _, ok := rec.find(audit.EventLogout); !ok {
t.Error("logout fehlt im Audit-Log")
}
}
func TestLogoutRequiresCSRF(t *testing.T) {
fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}}
store := NewSessionStore(10*time.Minute, nil)
srv, _ := newTestServer(t, Deps{Auth: fa, Sessions: store})
login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}})
cookie := sessionCookie(login)
out := postForm(srv.Handler(), "/logout", url.Values{"csrf_token": {"falsch"}}, cookie)
if out.Code != http.StatusForbidden {
t.Fatalf("Code = %d, want 403", out.Code)
}
if _, ok := store.Get(cookie.Value); !ok {
t.Error("bei CSRF-Fehler darf die Session nicht zerstört werden")
}
}
func TestProtectedPageRedirectsWithoutSession(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", rec.Code)
}
if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/login") {
t.Errorf("Location = %q", loc)
}
}
func TestExpiredSessionRedirectsWithHint(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
store := NewSessionStore(10*time.Minute, clock)
fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}}
srv, rec := newTestServer(t, Deps{Auth: fa, Sessions: store, Clock: clock})
login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}})
cookie := sessionCookie(login)
now = now.Add(11 * time.Minute)
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(cookie)
out := httptest.NewRecorder()
srv.Handler().ServeHTTP(out, r)
if out.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", out.Code)
}
if loc := out.Header().Get("Location"); !strings.Contains(loc, "expired") {
t.Errorf("Location = %q, muss den Ablaufhinweis tragen", loc)
}
if _, ok := rec.find(audit.EventSessionExpired); !ok {
t.Error("session_expired fehlt im Audit-Log")
}
}
func TestLoginPageShowsExpiredHint(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login?expired=1", nil))
if !strings.Contains(rec.Body.String(), S["error_session_expired"]) {
t.Error("Ablaufhinweis fehlt auf der Anmeldeseite")
}
}
func TestLoginTimingIsEqualisedAcrossReasons(t *testing.T) {
// "Benutzer existiert nicht" und "Passwort falsch" müssen gleich lange
// dauern, sonst verrät die Laufzeit die Existenz eines Kontos.
const floor = 60 * time.Millisecond
measure := func(reason string) time.Duration {
srv, _ := newTestServer(t, Deps{
Auth: &fakeAuth{err: &auth.Error{Reason: reason}},
MinLoginDuration: floor,
})
start := time.Now()
postForm(srv.Handler(), "/login", url.Values{"username": {"x"}, "password": {"y"}})
return time.Since(start)
}
for _, reason := range []string{auth.ReasonUserNotFound, auth.ReasonInvalidCredentials} {
if d := measure(reason); d < floor {
t.Errorf("Reason %q antwortete in %v, Mindestdauer ist %v", reason, d, floor)
}
}
}