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:
parent
bb2bc223b9
commit
eb89d78ce2
8 changed files with 1618 additions and 0 deletions
363
internal/web/handlers_login_test.go
Normal file
363
internal/web/handlers_login_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue