Go-Quality-Baseline-Rollout, Security-Teil: - api: http.Server bekommt ReadHeaderTimeout (15s) + IdleTimeout (120s) gegen Slowloris-Header-Stalls (gosec G112). ReadTimeout/WriteTimeout bewusst offen (lang laufende Rolling-Update-/Backup-Endpoints). - fix(waf): PurgeAlerts nutzte NOW() - ($1 || ' days')::interval mit olderThanDays int → pgx-Encode-Error zur Laufzeit → DELETE /waf/alerts war kaputt. Auf make_interval(days => $1) umgestellt (gleiche Bug- Klasse wie audit-Cleanup v1.3.0). Via Lint-Aufräumen entdeckt. - .golangci.yml: 26 gosec-Findings line-by-line auditiert. Alle sind bewusstes Appliance-Verhalten mit Compensating Controls (Subprocess- Args intern/validiert, Config-File-Perms daemon-lesbar, SSH opt-in Fingerprint-Pinning, UI-Server Clean+HasPrefix-Traversal-Guard) oder FPs (G101 Konstanten-Namen, G702/G703/G706 Taint). Dokumentiert exclude't. gosec-Rest = 0. - .golangci.yml: rowserrcheck/sqlclosecheck raus — database/sql-Linter, bei durchgängigem pgx nur FPs. gosec=0, govulncheck=0, race=0. Rest-Backlog: errcheck/noctx/staticcheck (Code-Quality, kein Security) — folgt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
6.8 KiB
Go
203 lines
6.8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
|
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
|
)
|
|
|
|
// mockAuth erfüllt oidcsvc.Authenticator und liefert vorgegebene Claims —
|
|
// kein echter IdP nötig.
|
|
type mockAuth struct {
|
|
claims *oidcsvc.Claims
|
|
err error
|
|
}
|
|
|
|
func (m *mockAuth) AuthCodeURL(_ context.Context, _, state, _, _ string) (string, error) {
|
|
return "https://idp.example/authorize?state=" + state, nil
|
|
}
|
|
func (m *mockAuth) Exchange(_ context.Context, _, _, _ string) (*oidcsvc.Claims, error) {
|
|
return m.claims, m.err
|
|
}
|
|
|
|
func oidcTestSetup(t *testing.T) (*usersvc.Repo, *pgxpool.Pool, *session.Signer) {
|
|
t.Helper()
|
|
dsn := os.Getenv("EG_FWTEST_DSN")
|
|
if dsn == "" {
|
|
t.Skip("set EG_FWTEST_DSN to run the oidc handler test")
|
|
}
|
|
ctx := context.Background()
|
|
// Retry: goose-Erst-Apply ist nicht concurrency-safe, wenn mehrere
|
|
// guarded Test-Pakete dieselbe frische DB parallel migrieren.
|
|
var mErr error
|
|
for i := 0; i < 3; i++ {
|
|
if mErr = database.Migrate(ctx, dsn); mErr == nil {
|
|
break
|
|
}
|
|
time.Sleep(700 * time.Millisecond)
|
|
}
|
|
if mErr != nil {
|
|
t.Fatalf("migrate: %v", mErr)
|
|
}
|
|
pool, err := database.Open(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
return usersvc.New(pool), pool, session.NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, 0)
|
|
}
|
|
|
|
func seedUser(t *testing.T, repo *usersvc.Repo, pool *pgxpool.Pool, email, role string, active bool) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE email=$1`, email)
|
|
if _, err := repo.Create(ctx, email, "Sup3rSecret-pw-123", role, active); err != nil {
|
|
t.Fatalf("seed user: %v", err)
|
|
}
|
|
}
|
|
|
|
func runCallback(t *testing.T, h *OIDCHandler, flow oidcFlow, queryState, code string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
blob, _ := json.Marshal(flow)
|
|
signed, err := h.Signer.SignBlob(blob, oidcFlowTTL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/api/v1/auth/oidc/callback?state="+queryState+"&code="+code, nil)
|
|
req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed}) //nolint:gosec // Test-Cookie — Secure/HttpOnly-Flags für httptest irrelevant
|
|
c.Request = req
|
|
h.Callback(c)
|
|
return rec
|
|
}
|
|
|
|
func sessionCookie(rec *httptest.ResponseRecorder) string {
|
|
for _, ck := range rec.Result().Cookies() {
|
|
if ck.Name == cookieName && ck.Value != "" && ck.MaxAge >= 0 {
|
|
return ck.Value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func TestCallback_KnownActiveUser_RoleFromDB(t *testing.T) {
|
|
users, pool, signer := oidcTestSetup(t)
|
|
seedUser(t, users, pool, "sso-viewer@test.local", "viewer", true)
|
|
|
|
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
|
Email: "sso-viewer@test.local", EmailVerified: true, Subject: "sub-1", Nonce: "N",
|
|
}}, users, signer, nil)
|
|
|
|
rec := runCallback(t, h, oidcFlow{State: "S", Verifier: "v", Nonce: "N"}, "S", "code")
|
|
|
|
if loc := rec.Header().Get("Location"); loc != "/dashboard" {
|
|
t.Fatalf("expected redirect to /dashboard, got %q (body proves failure path)", loc)
|
|
}
|
|
raw := sessionCookie(rec)
|
|
if raw == "" {
|
|
t.Fatal("expected a session cookie to be set")
|
|
}
|
|
tok, err := signer.Verify(raw)
|
|
if err != nil {
|
|
t.Fatalf("session token invalid: %v", err)
|
|
}
|
|
// Kernbeweis: Rolle kommt aus der DB-Row (viewer), nicht aus Claims.
|
|
if tok.Role != "viewer" {
|
|
t.Errorf("token role = %q, want viewer (role must come from DB)", tok.Role)
|
|
}
|
|
if tok.Actor != "sso-viewer@test.local" {
|
|
t.Errorf("token actor = %q", tok.Actor)
|
|
}
|
|
}
|
|
|
|
func TestCallback_UnknownEmail_Rejected(t *testing.T) {
|
|
users, pool, signer := oidcTestSetup(t)
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE email=$1`, "ghost@test.local")
|
|
|
|
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
|
Email: "ghost@test.local", EmailVerified: true, Subject: "x", Nonce: "N",
|
|
}}, users, signer, nil)
|
|
|
|
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
|
|
if !strings.Contains(rec.Header().Get("Location"), "sso_error=no_account") {
|
|
t.Fatalf("expected sso_error=no_account, got %q", rec.Header().Get("Location"))
|
|
}
|
|
if sessionCookie(rec) != "" {
|
|
t.Fatal("no session cookie expected for unknown user")
|
|
}
|
|
}
|
|
|
|
func TestCallback_InactiveUser_Rejected(t *testing.T) {
|
|
users, pool, signer := oidcTestSetup(t)
|
|
seedUser(t, users, pool, "sso-disabled@test.local", "admin", false)
|
|
|
|
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
|
Email: "sso-disabled@test.local", EmailVerified: true, Subject: "x", Nonce: "N",
|
|
}}, users, signer, nil)
|
|
|
|
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
|
|
if !strings.Contains(rec.Header().Get("Location"), "sso_error=disabled") {
|
|
t.Fatalf("expected sso_error=disabled, got %q", rec.Header().Get("Location"))
|
|
}
|
|
if sessionCookie(rec) != "" {
|
|
t.Fatal("no session cookie expected for inactive user")
|
|
}
|
|
}
|
|
|
|
func TestCallback_EmailUnverified_Rejected(t *testing.T) {
|
|
users, pool, signer := oidcTestSetup(t)
|
|
seedUser(t, users, pool, "sso-unverified@test.local", "admin", true)
|
|
|
|
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
|
Email: "sso-unverified@test.local", EmailVerified: false, Subject: "x", Nonce: "N",
|
|
}}, users, signer, nil)
|
|
|
|
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
|
|
if !strings.Contains(rec.Header().Get("Location"), "sso_error=unverified") {
|
|
t.Fatalf("expected sso_error=unverified, got %q", rec.Header().Get("Location"))
|
|
}
|
|
if sessionCookie(rec) != "" {
|
|
t.Fatal("no session cookie expected for unverified email")
|
|
}
|
|
}
|
|
|
|
func TestCallback_NonceMismatch_Rejected(t *testing.T) {
|
|
users, pool, signer := oidcTestSetup(t)
|
|
seedUser(t, users, pool, "sso-nonce@test.local", "admin", true)
|
|
|
|
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
|
Email: "sso-nonce@test.local", EmailVerified: true, Subject: "x", Nonce: "WRONG",
|
|
}}, users, signer, nil)
|
|
|
|
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
|
|
if !strings.Contains(rec.Header().Get("Location"), "sso_error=nonce") {
|
|
t.Fatalf("expected sso_error=nonce, got %q", rec.Header().Get("Location"))
|
|
}
|
|
}
|
|
|
|
func TestCallback_StateMismatch_Rejected(t *testing.T) {
|
|
users, _, signer := oidcTestSetup(t)
|
|
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{}}, users, signer, nil)
|
|
|
|
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "WRONG", "code")
|
|
if !strings.Contains(rec.Header().Get("Location"), "sso_error=state") {
|
|
t.Fatalf("expected sso_error=state, got %q", rec.Header().Get("Location"))
|
|
}
|
|
}
|