Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
363 lines
11 KiB
Go
363 lines
11 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|