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
97
internal/web/guides.go
Normal file
97
internal/web/guides.go
Normal 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
|
||||
}
|
||||
208
internal/web/handlers_certs.go
Normal file
208
internal/web/handlers_certs.go
Normal 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
|
||||
}
|
||||
305
internal/web/handlers_certs_test.go
Normal file
305
internal/web/handlers_certs_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
183
internal/web/handlers_login.go
Normal file
183
internal/web/handlers_login.go
Normal 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
35
internal/web/handlers_static.go
Normal file
35
internal/web/handlers_static.go
Normal 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)
|
||||
}
|
||||
193
internal/web/handlers_static_test.go
Normal file
193
internal/web/handlers_static_test.go
Normal 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
234
internal/web/server.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue