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

97
internal/web/guides.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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, `<a href="/download`) {
t.Error("Downloads dürfen keine GET-Links sein")
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q", cc)
}
}
func TestOverviewShowsExpiryWarning(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
ValidTo: time.Now().AddDate(0, 0, 12)},
}}
srv, _, cookie, _ := loggedInServer(t, certs)
body := getWithCookie(srv.Handler(), "/", cookie).Body.String()
if !strings.Contains(body, "12 Tagen") {
t.Errorf("Ablaufwarnung fehlt: %s", body)
}
}
func TestOverviewNoWarningForDistantExpiry(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 0, 0)},
}}
srv, _, cookie, _ := loggedInServer(t, certs)
body := getWithCookie(srv.Handler(), "/", cookie).Body.String()
if strings.Contains(body, "Achtung") {
t.Error("bei ferner Gültigkeit darf keine Warnung erscheinen")
}
}
func TestOverviewNoCertsShowsSupportContact(t *testing.T) {
srv, rec, cookie, _ := loggedInServer(t, &certsWithExport{})
out := getWithCookie(srv.Handler(), "/", cookie)
body := out.Body.String()
if !strings.Contains(body, S["overview_no_certs"]) {
t.Error("Meldung für fehlende Zertifikate fehlt")
}
if !strings.Contains(body, "it-helpdesk@firma.de") {
t.Error("support_contact fehlt")
}
ev, ok := rec.find(audit.EventNoCertFound)
if !ok {
t.Fatal("no_cert_found fehlt im Audit-Log")
}
if ev.User != "mmueller" {
t.Errorf("Audit-Event = %+v", ev)
}
if ev.Pattern == "" {
t.Error("angewendetes Muster muss im Audit-Log stehen")
}
}
func TestOverviewBackendDownShowsServiceError(t *testing.T) {
certs := &certsWithExport{listErr: ErrBackendUnavailable}
srv, rec, cookie, _ := loggedInServer(t, certs)
out := getWithCookie(srv.Handler(), "/", cookie)
if out.Code != http.StatusServiceUnavailable {
t.Fatalf("Code = %d, want 503", out.Code)
}
body := out.Body.String()
if !strings.Contains(body, S["error_service_unavailable"]) {
t.Error("Dienstfehlerseite fehlt")
}
if strings.Contains(body, S["overview_no_certs"]) {
t.Error("Firewall-Ausfall darf nicht wie \"kein Zertifikat\" aussehen")
}
if _, ok := rec.find(audit.EventOPNsenseUnreachable); !ok {
t.Error("opnsense_unreachable fehlt im Audit-Log")
}
if _, ok := rec.find(audit.EventNoCertFound); ok {
t.Error("bei Firewall-Ausfall darf kein no_cert_found geloggt werden")
}
}
func TestDownloadStreamsWithHardenedHeaders(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller"},
}}
srv, rec, cookie, sess := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {"1:a1"}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusOK {
t.Fatalf("Code = %d, body: %s", out.Code, out.Body.String())
}
cd := out.Header().Get("Content-Disposition")
if !strings.Contains(cd, `attachment; filename="vpn-homeoffice-mmueller.ovpn"`) {
t.Errorf("Content-Disposition = %q", cd)
}
if cc := out.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q", cc)
}
if !strings.Contains(out.Body.String(), "remote fw01 1194") {
t.Errorf("Body = %q", out.Body.String())
}
// Der Export muss mit dem kanonischen Benutzernamen aufgerufen werden.
if certs.lastArgs[0] != "mmueller" {
t.Errorf("Export-Benutzername = %q, want mmueller", certs.lastArgs[0])
}
ev, ok := rec.find(audit.EventConfigDownload)
if !ok {
t.Fatal("config_download fehlt im Audit-Log")
}
if ev.User != "mmueller" || ev.VPNInstance != "VPN Homeoffice" ||
ev.Format != "ovpn" || ev.CertCN != "mmueller" || ev.CertExpiry != "2027-03-01" {
t.Errorf("Audit-Event unvollständig: %+v", ev)
}
}
func TestDownloadRequiresCSRF(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1", InstanceName: "X"}}}
srv, _, cookie, _ := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {"falsch"}, "cert": {"1:a1"}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusForbidden {
t.Fatalf("Code = %d, want 403", out.Code)
}
if certs.exportCall != 0 {
t.Error("ohne gültiges CSRF-Token darf kein Export ausgelöst werden")
}
}
func TestDownloadGETIsNotRouted(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
srv, _, cookie, _ := loggedInServer(t, certs)
out := getWithCookie(srv.Handler(), "/download?cert=1:a1&format=ovpn", cookie)
if out.Code == http.StatusOK {
t.Fatal("Downloads per GET dürfen nicht möglich sein")
}
if certs.exportCall != 0 {
t.Error("GET darf keinen Export auslösen")
}
}
func TestForeignCertificateIsDenied(t *testing.T) {
certs := &certsWithExport{
entries: []CertEntry{{Token: "1:a1", InstanceName: "X"}},
exportErr: ErrCertNotOwned,
}
srv, rec, cookie, sess := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {"9:fremd"}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusForbidden {
t.Fatalf("Code = %d, want 403", out.Code)
}
ev, ok := rec.find(audit.EventDownloadDenied)
if !ok {
t.Fatal("download_denied fehlt im Audit-Log")
}
if ev.User != "mmueller" {
t.Errorf("Audit-Event = %+v", ev)
}
}
func TestDownloadRejectsMalformedToken(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
srv, _, cookie, sess := loggedInServer(t, certs)
for _, bad := range []string{"", "keinDoppelpunkt", "a:b:c"} {
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {bad}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusForbidden && out.Code != http.StatusBadRequest {
t.Errorf("Token %q ergab Code %d", bad, out.Code)
}
}
if certs.exportCall != 0 {
t.Error("kaputte Tokens dürfen keinen Export auslösen")
}
}
func TestDownloadRejectsUnknownFormat(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
srv, _, cookie, sess := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {"1:a1"}, "format": {"exe"},
}, cookie)
if out.Code == http.StatusOK {
t.Fatal("unbekanntes Format muss abgelehnt werden")
}
if certs.exportCall != 0 {
t.Error("unbekanntes Format darf keinen Export auslösen")
}
}
func TestDownloadWithoutSessionRedirects(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
out := postForm(srv.Handler(), "/download", url.Values{"cert": {"1:a1"}, "format": {"ovpn"}})
if out.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", out.Code)
}
}

View file

@ -0,0 +1,183 @@
package web
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
NoStore(w)
// Eine bereits gültige Session überspringt das Formular.
if c, err := r.Cookie(SessionCookieName); err == nil {
if _, ok := s.d.Sessions.Get(c.Value); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
var data PageData
switch {
case r.URL.Query().Get("expired") != "":
data.Flash, data.FlashKind = T("error_session_expired"), "info"
case r.URL.Query().Get("abgemeldet") != "":
data.Flash, data.FlashKind = T("logout_done"), "info"
}
s.renderPage(w, r, http.StatusOK, "login", nil, data)
}
// loginFailed rendert die Anmeldeseite mit einer Meldung.
func (s *Server) loginFailed(w http.ResponseWriter, r *http.Request, status int, message string) {
s.renderPage(w, r, status, "login", nil, PageData{Flash: message, FlashKind: "error"})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
NoStore(w)
started := time.Now()
ip := ClientIP(r)
if err := r.ParseForm(); err != nil {
s.loginFailed(w, r, http.StatusBadRequest, T("error_generic_login"))
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
// Rate-Limit vor jedem Verzeichniszugriff prüfen.
if wait, ok := s.d.Limiter.Allow(username, ip); !ok {
s.log(audit.Event{
Event: audit.EventRateLimited,
User: audit.UnknownUser, // an dieser Stelle ist der Name ungeprüft
SrcIP: ip,
Detail: wait.Round(time.Second).String(),
})
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Round(time.Second).Seconds())))
s.loginFailed(w, r, http.StatusTooManyRequests,
fmt.Sprintf(T("error_rate_limited"), humanDuration(wait)))
return
}
id, err := s.d.Auth.Authenticate(r.Context(), username, password)
s.equalizeTiming(started)
if err != nil {
s.d.Limiter.RecordFailure(username, ip)
reason, known := reasonAndKnownUser(err)
s.log(audit.Event{
Event: audit.EventLoginFailed,
User: auditUser(username, known),
SrcIP: ip,
Reason: reason,
})
message := T("error_generic_login")
var ae *auth.Error
if errors.As(err, &ae) && ae.UserVisible() {
message = T("error_password_expired")
}
s.loginFailed(w, r, http.StatusUnauthorized, message)
return
}
s.d.Limiter.RecordSuccess(username, ip)
sess, err := s.d.Sessions.Create(id)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, nil,
"error_internal", "error_internal_body")
return
}
s.setCookie(w, sess)
s.log(audit.Event{
Event: audit.EventLoginSuccess,
User: id.Username,
SrcIP: ip,
Session: audit.ShortSession(sess.Token),
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess *Session) {
if err := r.ParseForm(); err != nil || !sess.ValidCSRF(r.PostFormValue("csrf_token")) {
s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_csrf")
return
}
s.d.Sessions.Destroy(sess.Token)
s.clearCookie(w)
s.log(audit.Event{
Event: audit.EventLogout,
User: sess.Identity.Username,
SrcIP: ClientIP(r),
Session: audit.ShortSession(sess.Token),
})
http.Redirect(w, r, "/login?abgemeldet=1", http.StatusSeeOther)
}
// setCookie setzt das gehärtete Session-Cookie.
func (s *Server) setCookie(w http.ResponseWriter, sess *Session) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sess.Token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(s.d.SessionTTL.Seconds()),
})
}
func (s *Server) clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName, Value: "", Path: "/",
HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: -1,
})
}
// equalizeTiming hält die Antwortzeit auf einem Mindestwert, damit die Dauer
// nicht verrät, ob ein Konto existiert.
func (s *Server) equalizeTiming(started time.Time) {
if elapsed := time.Since(started); elapsed < s.d.MinLoginDuration {
time.Sleep(s.d.MinLoginDuration - elapsed)
}
}
// reasonAndKnownUser liefert den Audit-Reason und ob der Benutzer im
// Verzeichnis existiert. Nur dann darf sein Name im Klartext geloggt werden.
func reasonAndKnownUser(err error) (reason string, known bool) {
var ae *auth.Error
if !errors.As(err, &ae) {
return auth.ReasonBackendUnavailable, false
}
switch ae.Reason {
case auth.ReasonUserNotFound, auth.ReasonBackendUnavailable, "":
return ae.Reason, false
default:
// Der Benutzer wurde im Verzeichnis gefunden; sein Name ist ein
// echter Kontoname und kein versehentlich eingegebenes Passwort.
return ae.Reason, true
}
}
// auditUser entscheidet, ob der eingegebene Name im Log erscheinen darf.
func auditUser(input string, known bool) string {
if !known {
return audit.UnknownUser
}
return strings.ToLower(input)
}
// humanDuration formatiert Wartezeiten deutsch lesbar.
func humanDuration(d time.Duration) string {
d = d.Round(time.Second)
if d < time.Minute {
return fmt.Sprintf("%d Sekunden", int(d.Seconds()))
}
minutes := int(d.Minutes())
if minutes == 1 {
return "einer Minute"
}
return fmt.Sprintf("%d Minuten", minutes)
}

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)
}
}
}

View file

@ -0,0 +1,35 @@
package web
import (
"encoding/json"
"net/http"
)
// guidesData füllt guides.html.
type guidesData struct {
Guides []Guide
}
func (s *Server) handleGuides(w http.ResponseWriter, r *http.Request, sess *Session) {
s.renderPage(w, r, http.StatusOK, "guides", sess,
PageData{Data: guidesData{Guides: Guides(r.UserAgent())}})
}
// handleHealth beantwortet Monitoring-Anfragen ohne Authentifizierung und
// ohne sensible Details — nur boolesche Zustände und die Sessionzahl.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
report := HealthReport{OK: true, OPNsense: true, Directory: true}
if s.d.Health != nil {
report = s.d.Health.Check(r.Context())
}
report.Sessions = s.d.Sessions.Count()
status := http.StatusOK
if !report.OK {
status = http.StatusServiceUnavailable
}
NoStore(w)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(report)
}

View file

@ -0,0 +1,193 @@
package web
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type fakeHealth struct{ rep HealthReport }
func (f *fakeHealth) Check(ctx context.Context) HealthReport { return f.rep }
func TestGuidesOrderPutsDetectedPlatformFirst(t *testing.T) {
const winUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
g := Guides(winUA)
if len(g) < 4 {
t.Fatalf("es müssen Anleitungen für alle Plattformen existieren, got %d", len(g))
}
if !strings.Contains(g[0].Platform, "Windows") || !g[0].Suggested {
t.Errorf("erste Anleitung = %+v, want Windows und Suggested", g[0])
}
// Kein Filter: alle Plattformen bleiben erreichbar.
for _, want := range []string{"Windows", "macOS", "iOS", "Android"} {
var found bool
for _, x := range g {
if strings.Contains(x.Platform, want) {
found = true
}
}
if !found {
t.Errorf("Anleitung für %s fehlt", want)
}
}
}
func TestGuidesDetectsOtherPlatforms(t *testing.T) {
cases := map[string]string{
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)": "macOS",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)": "iOS",
"Mozilla/5.0 (Linux; Android 14; Pixel 8)": "Android",
}
for ua, want := range cases {
g := Guides(ua)
if !strings.Contains(g[0].Platform, want) {
t.Errorf("UA %q -> %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)
}
}

234
internal/web/server.go Normal file
View file

@ -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")
}