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
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue