diff --git a/internal/web/guides.go b/internal/web/guides.go new file mode 100644 index 0000000..50a5fa6 --- /dev/null +++ b/internal/web/guides.go @@ -0,0 +1,97 @@ +package web + +import "strings" + +// Guide ist eine Kurzanleitung für eine Plattform. +type Guide struct { + Platform string + Client string + Steps []string + Suggested bool +} + +// baseGuides ist die feste Reihenfolge aller Anleitungen. +var baseGuides = []Guide{ + { + Platform: "Windows", + Client: "OpenVPN Community Client", + Steps: []string{ + "Laden Sie den OpenVPN Community Client von openvpn.net herunter und installieren Sie ihn.", + "Laden Sie hier im Portal Ihre .ovpn-Datei herunter.", + "Klicken Sie mit der rechten Maustaste auf das OpenVPN-Symbol im Infobereich und wählen Sie „Datei importieren“.", + "Wählen Sie die heruntergeladene .ovpn-Datei aus.", + "Klicken Sie erneut auf das Symbol und wählen Sie „Verbinden“.", + }, + }, + { + Platform: "macOS", + Client: "Tunnelblick oder Viscosity", + Steps: []string{ + "Installieren Sie Tunnelblick (kostenfrei) oder Viscosity.", + "Laden Sie hier im Portal Ihre Konfiguration herunter — für Viscosity das Viscosity-Paket, für Tunnelblick die .ovpn-Datei.", + "Öffnen Sie die heruntergeladene Datei per Doppelklick; die Konfiguration wird importiert.", + "Starten Sie die Verbindung über das Symbol in der Menüleiste.", + }, + }, + { + Platform: "iOS / iPadOS", + Client: "OpenVPN Connect", + Steps: []string{ + "Installieren Sie „OpenVPN Connect“ aus dem App Store.", + "Laden Sie hier im Portal Ihre .ovpn-Datei herunter.", + "Öffnen Sie die Datei aus der Dateien-App heraus und wählen Sie „Teilen“ und dann „OpenVPN Connect“.", + "Bestätigen Sie den Import mit „Add“ und erlauben Sie die VPN-Konfiguration.", + }, + }, + { + Platform: "Android", + Client: "OpenVPN Connect", + Steps: []string{ + "Installieren Sie „OpenVPN Connect“ aus dem Google Play Store.", + "Laden Sie hier im Portal Ihre .ovpn-Datei herunter.", + "Öffnen Sie OpenVPN Connect, wählen Sie „Import“, dann „File“ und dann die heruntergeladene Datei.", + "Bestätigen Sie den Import und erlauben Sie die VPN-Verbindung.", + }, + }, +} + +// detectPlatform rät die Plattform aus dem User-Agent. +// Das Ergebnis ist ausdrücklich nur ein Vorschlag, nie ein Filter. +// iOS wird vor macOS geprüft, weil iPad-User-Agents „Mac OS X“ enthalten können. +func detectPlatform(userAgent string) string { + ua := strings.ToLower(userAgent) + switch { + case strings.Contains(ua, "iphone"), strings.Contains(ua, "ipad"), strings.Contains(ua, "ipod"): + return "iOS / iPadOS" + case strings.Contains(ua, "android"): + return "Android" + case strings.Contains(ua, "windows"): + return "Windows" + case strings.Contains(ua, "mac os x"), strings.Contains(ua, "macintosh"): + return "macOS" + default: + return "" + } +} + +// Guides liefert alle Anleitungen; die erkannte Plattform steht vorn und ist +// als Vorschlag markiert. Es wird niemals etwas ausgeblendet — die +// User-Agent-Erkennung ist zu unzuverlässig, um Inhalte zu verstecken. +func Guides(userAgent string) []Guide { + detected := detectPlatform(userAgent) + out := make([]Guide, 0, len(baseGuides)) + if detected != "" { + for _, g := range baseGuides { + if g.Platform == detected { + g.Suggested = true + out = append(out, g) + } + } + } + for _, g := range baseGuides { + if g.Platform != detected { + out = append(out, g) + } + } + return out +} diff --git a/internal/web/handlers_certs.go b/internal/web/handlers_certs.go new file mode 100644 index 0000000..7969a78 --- /dev/null +++ b/internal/web/handlers_certs.go @@ -0,0 +1,208 @@ +package web + +import ( + "errors" + "fmt" + "io" + "math" + "net/http" + "strings" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/audit" +) + +// expiryWarningDays ist die Schwelle, ab der vor dem Ablauf gewarnt wird. +const expiryWarningDays = 30 + +// downloadFormats sind die in der Oberfläche angebotenen Formate. +// Alle weiteren Exporteinstellungen kommen von der Firewall. +var downloadFormats = []struct { + Format string + LabelKey string +}{ + {"ovpn", "download_ovpn"}, + {"viscosity", "download_viscosity"}, +} + +// overviewData füllt overview.html. +type overviewData struct { + Entries []overviewEntry + SupportHint string +} + +type overviewEntry struct { + InstanceName string + CommonName string + ValidUntil string + ExpiryWarning string + Formats []overviewFormat +} + +type overviewFormat struct { + Token string + Format string + Label string +} + +func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request, sess *Session) { + user := sess.Identity.Username + entries, err := s.d.Certs.EntriesFor(r.Context(), user) + if err != nil { + if errors.Is(err, ErrBackendUnavailable) { + s.log(audit.Event{ + Event: audit.EventOPNsenseUnreachable, + User: user, + SrcIP: ClientIP(r), + Session: audit.ShortSession(sess.Token), + }) + } + // Bewusst eigene Seite: "Firewall weg" ist etwas anderes als + // "kein Zertifikat vorhanden". + s.renderError(w, r, http.StatusServiceUnavailable, sess, + "error_service_unavailable", "error_service_unavailable_body") + return + } + + if len(entries) == 0 { + s.log(audit.Event{ + Event: audit.EventNoCertFound, + User: user, + SrcIP: ClientIP(r), + Session: audit.ShortSession(sess.Token), + Pattern: s.pattern(user), + }) + } + + var data overviewData + if s.d.SupportContact != "" { + data.SupportHint = fmt.Sprintf(T("overview_no_certs_contact"), s.d.SupportContact) + } + now := s.d.Clock() + for _, e := range entries { + oe := overviewEntry{InstanceName: e.InstanceName, CommonName: e.CommonName} + if !e.ValidTo.IsZero() { + oe.ValidUntil = e.ValidTo.Format("02.01.2006") + // Aufrunden: ein Zertifikat mit 11 Tagen und 23 Stunden Restlaufzeit + // als "11 Tage" anzuzeigen wäre für den Benutzer irreführend. + if days := int(math.Ceil(e.ValidTo.Sub(now).Hours() / 24)); days < expiryWarningDays { + if days <= 0 { + oe.ExpiryWarning = T("overview_expired_today") + } else { + oe.ExpiryWarning = fmt.Sprintf(T("overview_expiry_warning"), days) + } + } + } + for _, f := range downloadFormats { + oe.Formats = append(oe.Formats, overviewFormat{ + Token: e.Token, Format: f.Format, Label: T(f.LabelKey)}) + } + data.Entries = append(data.Entries, oe) + } + + s.renderPage(w, r, http.StatusOK, "overview", sess, PageData{Data: data}) +} + +// pattern liefert die angewendete Zuordnungsregel für das Audit-Log, sofern +// die CertSource sie kennt. +func (s *Server) pattern(username string) string { + if p, ok := s.d.Certs.(interface{ Pattern(string) string }); ok { + return p.Pattern(username) + } + return "" +} + +func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request, sess *Session) { + user := sess.Identity.Username + ip := ClientIP(r) + short := audit.ShortSession(sess.Token) + + deny := func(detail string) { + s.log(audit.Event{Event: audit.EventDownloadDenied, User: user, SrcIP: ip, + Session: short, Detail: detail}) + s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_forbidden_body") + } + + if err := r.ParseForm(); err != nil { + deny("formular_unlesbar") + return + } + if !sess.ValidCSRF(r.PostFormValue("csrf_token")) { + s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_csrf") + return + } + + vpnID, refID, ok := parseCertToken(r.PostFormValue("cert")) + if !ok { + deny("token_ungueltig") + return + } + format := r.PostFormValue("format") + if !isKnownFormat(format) { + deny("format_unbekannt") + return + } + + // Serverseitige Autorisierung: die CertSource prüft live, ob das + // Zertifikat dem angemeldeten Benutzer gehört und nutzbar ist. + stream, err := s.d.Certs.Export(r.Context(), user, vpnID, refID, format) + if err != nil { + if errors.Is(err, ErrCertNotOwned) { + deny("nicht_zugeordnet") + return + } + if errors.Is(err, ErrBackendUnavailable) { + s.log(audit.Event{Event: audit.EventOPNsenseUnreachable, User: user, + SrcIP: ip, Session: short}) + } + s.renderError(w, r, http.StatusServiceUnavailable, sess, + "error_service_unavailable", "error_service_unavailable_body") + return + } + defer stream.Body.Close() + + expiry := "" + if !stream.ValidTo.IsZero() { + expiry = stream.ValidTo.Format("2006-01-02") + } + s.log(audit.Event{ + Event: audit.EventConfigDownload, + User: user, + SrcIP: ip, + Session: short, + VPNInstance: stream.InstanceName, + CertCN: stream.CommonName, + CertExpiry: expiry, + Format: format, + }) + + NoStore(w) + w.Header().Set("Content-Type", stream.ContentType) + w.Header().Set("Content-Disposition", + fmt.Sprintf(`attachment; filename=%q`, stream.Filename)) + w.WriteHeader(http.StatusOK) + + // Durchstreamen — nichts auf Platte, nichts vollständig im Speicher. + if _, err := io.Copy(w, stream.Body); err != nil { + // Der Header ist bereits raus; mehr als Protokollieren bleibt nicht. + s.log(audit.Event{Event: audit.EventOPNsenseUnreachable, User: user, + SrcIP: ip, Session: short, Detail: "abbruch_beim_streamen"}) + } +} + +// parseCertToken zerlegt den Auswahl-Token aus dem Formular. +func parseCertToken(s string) (vpnID, refID string, ok bool) { + vpnID, refID, found := strings.Cut(s, ":") + if !found || vpnID == "" || refID == "" || strings.Contains(refID, ":") { + return "", "", false + } + return vpnID, refID, true +} + +func isKnownFormat(f string) bool { + for _, d := range downloadFormats { + if d.Format == f { + return true + } + } + return false +} diff --git a/internal/web/handlers_certs_test.go b/internal/web/handlers_certs_test.go new file mode 100644 index 0000000..0fadc4b --- /dev/null +++ b/internal/web/handlers_certs_test.go @@ -0,0 +1,305 @@ +package web + +import ( + "context" + "io" + "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" +) + +// certsWithExport ist eine CertSource mit funktionierendem Export. +type certsWithExport struct { + entries []CertEntry + listErr error + exportErr error + exportCall int + lastArgs [4]string +} + +func (c *certsWithExport) EntriesFor(ctx context.Context, username string) ([]CertEntry, error) { + return c.entries, c.listErr +} + +func (c *certsWithExport) Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error) { + c.exportCall++ + c.lastArgs = [4]string{username, vpnID, refID, format} + if c.exportErr != nil { + return nil, c.exportErr + } + return &ExportStream{ + Filename: "vpn-homeoffice-mmueller.ovpn", + ContentType: "application/x-openvpn-profile", + Body: io.NopCloser(strings.NewReader("client\nremote fw01 1194\n")), + InstanceName: "VPN Homeoffice", + CommonName: "mmueller", + ValidTo: time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC), + }, nil +} + +// Pattern macht certsWithExport zur Quelle des Audit-Feldes pattern. +func (c *certsWithExport) Pattern(username string) string { return username } + +// loggedInServer liefert Server, Audit-Mitschrift, Cookie und Session eines +// angemeldeten Benutzers. +func loggedInServer(t *testing.T, certs CertSource) (*Server, *recordAudit, *http.Cookie, *Session) { + t.Helper() + store := NewSessionStore(10*time.Minute, nil) + srv, rec := newTestServer(t, Deps{ + Auth: &fakeAuth{id: &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}}, + Certs: certs, + Sessions: store, + SupportContact: "it-helpdesk@firma.de", + }) + login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}}) + cookie := sessionCookie(login) + if cookie == nil { + t.Fatal("Anmeldung lieferte kein Session-Cookie") + } + sess, ok := store.Get(cookie.Value) + if !ok { + t.Fatal("Anmeldung fehlgeschlagen") + } + return srv, rec, cookie, sess +} + +func getWithCookie(h http.Handler, path string, c *http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, path, nil) + r.RemoteAddr = "10.1.20.34:5000" + if c != nil { + r.AddCookie(c) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + return rec +} + +func TestOverviewListsCertificates(t *testing.T) { + certs := &certsWithExport{entries: []CertEntry{ + {Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller", + ValidTo: time.Now().AddDate(1, 0, 0)}, + {Token: "2:b1", InstanceName: "VPN Aussendienst", CommonName: "mmueller", + ValidTo: time.Now().AddDate(1, 0, 0)}, + }} + srv, _, cookie, _ := loggedInServer(t, certs) + rec := getWithCookie(srv.Handler(), "/", cookie) + + if rec.Code != http.StatusOK { + t.Fatalf("Code = %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{"VPN Homeoffice", "VPN Aussendienst", + S["download_ovpn"], S["download_viscosity"], `name="csrf_token"`} { + if !strings.Contains(body, want) { + t.Errorf("Übersicht enthält %q nicht", want) + } + } + if strings.Contains(body, ` %q, want %q zuerst", ua, g[0].Platform, want) + } + } + // Unbekannter User-Agent: Reihenfolge stabil, nichts als Vorschlag markiert. + g := Guides("irgendwas") + if g[0].Suggested { + t.Error("bei unbekanntem User-Agent darf nichts vorgeschlagen werden") + } +} + +func TestGuidesNeverLosesEntries(t *testing.T) { + for _, ua := range []string{"", "Windows", "iPhone", "Android", "Macintosh", "unbekannt"} { + if got, want := len(Guides(ua)), len(baseGuides); got != want { + t.Errorf("UA %q liefert %d Anleitungen, want %d — es darf nie gefiltert werden", ua, got, want) + } + } +} + +func TestGuidesPageRequiresSession(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/anleitungen", nil)) + if rec.Code != http.StatusSeeOther { + t.Fatalf("Code = %d, want 303", rec.Code) + } +} + +func TestGuidesPageRenders(t *testing.T) { + srv, _, cookie, _ := loggedInServer(t, &certsWithExport{}) + rec := getWithCookie(srv.Handler(), "/anleitungen", cookie) + if rec.Code != http.StatusOK { + t.Fatalf("Code = %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{S["guides_title"], "OpenVPN", "Tunnelblick", "OpenVPN Connect"} { + if !strings.Contains(body, want) { + t.Errorf("Anleitungsseite enthält %q nicht", want) + } + } +} + +func TestHealthzHealthy(t *testing.T) { + srv, _ := newTestServer(t, Deps{ + Auth: &fakeAuth{}, + Health: &fakeHealth{rep: HealthReport{OK: true, OPNsense: true, Directory: true}}, + }) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("Code = %d, want 200", rec.Code) + } + var got HealthReport + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("Antwort ist kein JSON: %v (%s)", err, rec.Body.String()) + } + if !got.OK || !got.OPNsense || !got.Directory { + t.Errorf("Report = %+v", got) + } +} + +func TestHealthzUnhealthyReturns503(t *testing.T) { + srv, _ := newTestServer(t, Deps{ + Auth: &fakeAuth{}, + Health: &fakeHealth{rep: HealthReport{OK: false, OPNsense: false, Directory: true}}, + }) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("Code = %d, want 503", rec.Code) + } +} + +func TestHealthzNeedsNoAuthAndLeaksNoDetails(t *testing.T) { + srv, _ := newTestServer(t, Deps{ + Auth: &fakeAuth{}, + Health: &fakeHealth{rep: HealthReport{OK: true, OPNsense: true, Directory: true}}, + }) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("/healthz muss ohne Anmeldung antworten, Code = %d", rec.Code) + } + body := strings.ToLower(rec.Body.String()) + for _, forbidden := range []string{"firma.local", "dc01", "https://", "password", "secret"} { + if strings.Contains(body, forbidden) { + t.Errorf("/healthz verrät %q: %s", forbidden, rec.Body.String()) + } + } +} + +func TestHealthzWithoutCheckerStillAnswers(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("Code = %d — ohne Checker gilt das Portal selbst als gesund", rec.Code) + } +} + +func TestUnknownPathReturns404(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/gibtsnicht", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("Code = %d, want 404", rec.Code) + } +} + +func TestAssetsAreServed(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + for path, wantType := range map[string]string{ + "/assets/style.css": "text/css", + "/assets/app.js": "text/javascript", + } { + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusOK { + t.Errorf("%s: Code = %d", path, rec.Code) + continue + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, wantType) { + t.Errorf("%s: Content-Type = %q, want %q", path, ct, wantType) + } + } +} + +func TestAssetPathTraversalIsRejected(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + for _, p := range []string{"/assets/../server.go", "/assets/%2e%2e%2fserver.go", "/assets/"} { + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil)) + if rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "package web") { + t.Errorf("%s hat Quelltext ausgeliefert", p) + } + } +} + +func TestLogoIsNotServedWhenUnconfigured(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/logo", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("ohne logo_file muss /assets/logo 404 liefern, Code = %d", rec.Code) + } +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..6080006 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,234 @@ +package web + +import ( + "context" + "errors" + "io" + "io/fs" + "net/http" + "path" + "strings" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/audit" + "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" +) + +// AuditSink ist die Log-Senke; als Interface, damit Tests mitschreiben können. +type AuditSink interface { + Log(audit.Event) +} + +// CertEntry ist ein für den Benutzer freigegebenes Zertifikat, aufbereitet +// für die Oberfläche. +type CertEntry struct { + Token string + InstanceName string + CommonName string + ValidTo time.Time +} + +// ExportStream ist eine zum Download bereite Konfiguration. +// Body muss vom Aufrufer geschlossen werden. +type ExportStream struct { + Filename string + ContentType string + Body io.ReadCloser + InstanceName string + CommonName string + ValidTo time.Time +} + +// ErrCertNotOwned signalisiert, dass das angeforderte Zertifikat nicht zum +// angemeldeten Benutzer gehört. +var ErrCertNotOwned = errors.New("Zertifikat gehört nicht zum angemeldeten Benutzer") + +// ErrBackendUnavailable signalisiert eine nicht erreichbare Firewall. +var ErrBackendUnavailable = errors.New("Firewall nicht erreichbar") + +// CertSource liefert die Zertifikate eines Benutzers und exportiert sie. +type CertSource interface { + EntriesFor(ctx context.Context, username string) ([]CertEntry, error) + // Export prüft die Zuordnung serverseitig erneut und liefert sonst + // ErrCertNotOwned. Die Auswahl in der Oberfläche ist keine Autorisierung. + Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error) +} + +// HealthChecker liefert den Zustand der Abhängigkeiten für /healthz. +type HealthChecker interface { + Check(ctx context.Context) HealthReport +} + +// HealthReport ist die Antwort von /healthz — bewusst ohne sensible Details. +type HealthReport struct { + OK bool `json:"ok"` + OPNsense bool `json:"opnsense"` + Directory bool `json:"directory"` + Sessions int `json:"sessions"` +} + +// Deps sind die Abhängigkeiten des Servers. +type Deps struct { + Auth auth.Authenticator + Certs CertSource + Audit AuditSink + Sessions *SessionStore + Limiter *Limiter + Renderer *Renderer + Health HealthChecker + Clock func() time.Time + LogoPath string + SupportContact string + SessionTTL time.Duration + // MinLoginDuration gleicht die Antwortzeit der Anmeldung an. + // 0 = Vorgabewert; Tests setzen sie klein. + MinLoginDuration time.Duration +} + +// Server bündelt Routing und Handler. +type Server struct { + d Deps +} + +// defaultMinLoginDuration verhindert, dass die Laufzeit verrät, ob ein Konto +// existiert. +const defaultMinLoginDuration = 400 * time.Millisecond + +// NewServer prüft die Abhängigkeiten. +func NewServer(d Deps) (*Server, error) { + switch { + case d.Auth == nil: + return nil, errors.New("web: Authenticator fehlt") + case d.Certs == nil: + return nil, errors.New("web: CertSource fehlt") + case d.Audit == nil: + return nil, errors.New("web: Audit-Senke fehlt") + case d.Sessions == nil: + return nil, errors.New("web: SessionStore fehlt") + case d.Limiter == nil: + return nil, errors.New("web: Limiter fehlt") + case d.Renderer == nil: + return nil, errors.New("web: Renderer fehlt") + } + if d.Clock == nil { + d.Clock = time.Now + } + if d.SessionTTL <= 0 { + d.SessionTTL = 10 * time.Minute + } + if d.MinLoginDuration <= 0 { + d.MinLoginDuration = defaultMinLoginDuration + } + return &Server{d: d}, nil +} + +// Handler baut den Router samt Middleware. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /login", s.handleLoginForm) + mux.HandleFunc("POST /login", s.handleLoginSubmit) + mux.HandleFunc("POST /logout", s.requireSession(s.handleLogout)) + mux.HandleFunc("GET /{$}", s.requireSession(s.handleOverview)) + mux.HandleFunc("POST /download", s.requireSession(s.handleDownload)) + mux.HandleFunc("GET /anleitungen", s.requireSession(s.handleGuides)) + mux.HandleFunc("GET /healthz", s.handleHealth) + mux.HandleFunc("GET /assets/{file}", s.handleAsset) + mux.HandleFunc("/", s.handleNotFound) + + return SecurityHeaders(mux) +} + +// log schreibt ein Audit-Ereignis. +func (s *Server) log(e audit.Event) { s.d.Audit.Log(e) } + +// renderPage füllt die Standardfelder und rendert. +func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, status int, + page string, sess *Session, data PageData) { + + NoStore(w) + data.Brand = s.d.Renderer.Brand() + data.Brand.HasLogo = s.d.LogoPath != "" + if sess != nil { + data.CSRF = sess.CSRF + data.User = sess.Identity.Username + } + s.d.Renderer.Render(w, r, status, page, data) +} + +// renderError zeigt die Fehlerseite. +func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, + sess *Session, titleKey, messageKey string) { + + s.renderPage(w, r, status, "error", sess, PageData{ + Data: errorPageData{ + Title: T(titleKey), + Message: T(messageKey), + ShowOverviewLink: sess != nil, + }, + }) +} + +// requireSession schützt eine Route. Ohne gültige Session wird auf /login +// umgeleitet; ein abgelaufenes Cookie erzeugt zusätzlich ein Audit-Ereignis. +func (s *Server) requireSession(next func(http.ResponseWriter, *http.Request, *Session)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + NoStore(w) + cookie, err := r.Cookie(SessionCookieName) + if err != nil || cookie.Value == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + sess, ok := s.d.Sessions.Get(cookie.Value) + if !ok { + s.clearCookie(w) + s.log(audit.Event{ + Event: audit.EventSessionExpired, + SrcIP: ClientIP(r), + Session: audit.ShortSession(cookie.Value), + }) + http.Redirect(w, r, "/login?expired=1", http.StatusSeeOther) + return + } + next(w, r, sess) + } +} + +// handleAsset liefert die eingebetteten Assets und das optionale Logo. +func (s *Server) handleAsset(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("file") + // Verteidigung in der Tiefe: embed.FS erlaubt ohnehin kein Traversal, + // aber ein Pfadtrenner im Namen hat hier nichts zu suchen. + if name == "" || name != path.Base(name) || strings.HasPrefix(name, ".") { + http.NotFound(w, r) + return + } + if name == "logo" { + if s.d.LogoPath == "" { + http.NotFound(w, r) + return + } + // Assets dürfen zwischengespeichert werden; sie enthalten keine + // personenbezogenen Daten. + w.Header().Set("Cache-Control", "public, max-age=3600") + http.ServeFile(w, r, s.d.LogoPath) + return + } + data, err := fs.ReadFile(AssetsFS, "assets/"+name) + if err != nil { + http.NotFound(w, r) + return + } + switch path.Ext(name) { + case ".css": + w.Header().Set("Content-Type", "text/css; charset=utf-8") + case ".js": + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + } + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(data) +} + +// handleNotFound zeigt eine schlichte 404-Seite. +func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { + s.renderError(w, r, http.StatusNotFound, nil, "error_not_found", "error_not_found_body") +}