diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index a3f8e46..ffbbb1c 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -4,7 +4,9 @@ import ( "context" "net/http" "net/http/httptest" + "net/url" "path/filepath" + "regexp" "strings" "testing" @@ -113,3 +115,220 @@ func TestLoginPageRenders(t *testing.T) { t.Fatalf("body missing
") } } + +// ============ forward-auth (Authentik) integration ============ + +// Authentik header names, matching internal/config defaults. +const ( + hdrEmail = "X-Authentik-Email" + hdrUsername = "X-Authentik-Username" + hdrName = "X-Authentik-Name" + hdrGroups = "X-Authentik-Groups" +) + +// enableForwardAuth turns on forward-auth trust for the 10.0.0.0/8 range with +// admin group "veola-admins", mirroring a Traefik-in-front deployment. +func enableForwardAuth(t *testing.T, app *App) { + t.Helper() + fa, err := auth.NewForwardAuthConfig(hdrUsername, hdrEmail, hdrName, hdrGroups, "veola-admins", + []string{"10.0.0.0/8"}) + if err != nil { + t.Fatalf("NewForwardAuthConfig: %v", err) + } + app.Auth.SetForwardAuth(fa) +} + +// testClient threads cookies across requests so an scs session survives a +// GET-then-POST flow, and applies a fixed peer address + identity headers. +type testClient struct { + h http.Handler + remoteAddr string + headers map[string]string + cookies map[string]*http.Cookie +} + +func newClient(h http.Handler, remoteAddr string, headers map[string]string) *testClient { + return &testClient{h: h, remoteAddr: remoteAddr, headers: headers, cookies: map[string]*http.Cookie{}} +} + +func (c *testClient) do(method, target string, form url.Values) *httptest.ResponseRecorder { + var req *http.Request + if form != nil { + req = httptest.NewRequest(method, target, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } else { + req = httptest.NewRequest(method, target, nil) + } + if c.remoteAddr != "" { + req.RemoteAddr = c.remoteAddr + } + for k, v := range c.headers { + req.Header.Set(k, v) + } + for _, ck := range c.cookies { + req.AddCookie(ck) + } + rec := httptest.NewRecorder() + c.h.ServeHTTP(rec, req) + for _, ck := range rec.Result().Cookies() { + c.cookies[ck.Name] = ck + } + return rec +} + +var csrfRe = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`) + +func extractCSRF(t *testing.T, body string) string { + t.Helper() + m := csrfRe.FindStringSubmatch(body) + if m == nil { + t.Fatal("no csrf_token field in body") + } + return m[1] +} + +func TestForwardAuthProvisionsAdminFromTrustedProxy(t *testing.T) { + app, h := newTestApp(t) + enableForwardAuth(t, app) + + c := newClient(h, "10.0.0.5:5555", map[string]string{ + hdrEmail: "ada@example.com", + hdrUsername: "ada", + hdrGroups: "users|veola-admins", + }) + rec := c.do(http.MethodGet, "/", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (provisioned dashboard); body: %s", rec.Code, rec.Body.String()) + } + u, err := app.Store.GetUserByEmail(context.Background(), "ada@example.com") + if err != nil || u == nil { + t.Fatalf("GetUserByEmail: u=%v err=%v", u, err) + } + if u.Role != models.RoleAdmin { + t.Errorf("role = %q, want admin (from veola-admins group)", u.Role) + } + if u.AuthSource != "forward" { + t.Errorf("auth_source = %q, want forward", u.AuthSource) + } +} + +func TestForwardAuthUntrustedPeerIgnoresSpoofedHeaders(t *testing.T) { + app, h := newTestApp(t) + enableForwardAuth(t, app) + + // Same headers, but the direct peer is NOT in the trusted range: the + // headers must be ignored and no user provisioned. + c := newClient(h, "203.0.113.9:5555", map[string]string{ + hdrEmail: "mallory@example.com", + hdrUsername: "mallory", + hdrGroups: "veola-admins", + }) + rec := c.do(http.MethodGet, "/", nil) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303 (no trust, setup gate)", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/setup" { + t.Fatalf("Location = %q, want /setup", loc) + } + if u, _ := app.Store.GetUserByEmail(context.Background(), "mallory@example.com"); u != nil { + t.Fatalf("spoofed header provisioned a user: %+v", u) + } +} + +func TestForwardAuthReSyncsRoleOnEveryRequest(t *testing.T) { + app, h := newTestApp(t) + enableForwardAuth(t, app) + + admin := newClient(h, "10.0.0.5:5555", map[string]string{ + hdrEmail: "bea@example.com", + hdrGroups: "veola-admins", + }) + if rec := admin.do(http.MethodGet, "/", nil); rec.Code != http.StatusOK { + t.Fatalf("first request status = %d, want 200", rec.Code) + } + u, _ := app.Store.GetUserByEmail(context.Background(), "bea@example.com") + if u == nil || u.Role != models.RoleAdmin { + t.Fatalf("after admin login, role = %v, want admin", u) + } + + // IdP later drops the admin group: the next request must downgrade the role. + demoted := newClient(h, "10.0.0.5:5555", map[string]string{ + hdrEmail: "bea@example.com", + hdrGroups: "users", + }) + if rec := demoted.do(http.MethodGet, "/", nil); rec.Code != http.StatusOK { + t.Fatalf("second request status = %d, want 200", rec.Code) + } + u, _ = app.Store.GetUserByEmail(context.Background(), "bea@example.com") + if u == nil || u.Role != models.RoleUser { + t.Fatalf("after group drop, role = %v, want user", u) + } +} + +func TestForwardAuthEmailPrefsRoundTrip(t *testing.T) { + app, h := newTestApp(t) + enableForwardAuth(t, app) + + // A forward-auth session authenticates without local login; carry its + // cookie + CSRF token into the POST. + c := newClient(h, "10.0.0.5:5555", map[string]string{ + hdrEmail: "cleo@example.com", + hdrGroups: "users", + }) + rec := c.do(http.MethodGet, "/settings", nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET /settings = %d, want 200", rec.Code) + } + token := extractCSRF(t, rec.Body.String()) + + provisioned, _ := app.Store.GetUserByEmail(context.Background(), "cleo@example.com") + if provisioned == nil { + t.Fatal("forward user not provisioned by GET /settings") + } + + // A forward user can toggle opt-ins, but a form that tries to change the + // email must be ignored: the address is the Authentik match key, so + // changing it here would orphan the row on the next sign-in. + form := url.Values{ + "csrf_token": {token}, + "email": {"cleo+spoofed@example.com"}, + "email_deal_alerts": {"1"}, + "email_weekly_digest": {"1"}, + } + rec = c.do(http.MethodPost, "/settings/email", form) + if rec.Code != http.StatusOK { + t.Fatalf("POST /settings/email = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } + + // Email is unchanged (still the IdP address), so the row still re-matches. + u, err := app.Store.GetUserByEmail(context.Background(), "cleo@example.com") + if err != nil || u == nil { + t.Fatalf("forward user orphaned: GetUserByEmail(IdP addr) u=%v err=%v", u, err) + } + if u.ID != provisioned.ID { + t.Errorf("re-matched a different row: id %d != %d", u.ID, provisioned.ID) + } + if u.Email != "cleo@example.com" { + t.Errorf("email = %q, want the unchanged IdP address cleo@example.com", u.Email) + } + if !u.EmailDealAlerts || !u.EmailWeeklyDigest { + t.Errorf("opt-ins = (deal:%v digest:%v), want both true", u.EmailDealAlerts, u.EmailWeeklyDigest) + } + // The user appears in the deal-alert recipient list under their IdP address. + recips, err := app.Store.UsersWithDealAlerts(context.Background()) + if err != nil { + t.Fatalf("UsersWithDealAlerts: %v", err) + } + if !containsEmail(recips, "cleo@example.com") { + t.Errorf("deal-alert recipients %v missing cleo", recips) + } +} + +func containsEmail(us []models.User, email string) bool { + for _, u := range us { + if u.Email == email { + return true + } + } + return false +} diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index 376f3e4..273ad57 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -108,6 +108,13 @@ func (a *App) PostEmailPrefs(w http.ResponseWriter, r *http.Request) { return } email := strings.TrimSpace(r.PostFormValue("email")) + // Forward-auth (Authentik) owns the address: it is the IdP match key, kept + // in sync on every request. Never overwrite it from the form, or the next + // sign-in would no longer match and orphan the row. Users change their + // email in Authentik; only the opt-in toggles are editable here. + if cur.AuthSource == "forward" { + email = cur.Email + } dealAlerts := r.PostFormValue("email_deal_alerts") == "1" weeklyDigest := r.PostFormValue("email_weekly_digest") == "1" diff --git a/templates/settings.templ b/templates/settings.templ index 8cefa87..5e0c80f 100644 --- a/templates/settings.templ +++ b/templates/settings.templ @@ -206,8 +206,13 @@ templ settingsBody(d SettingsData) { @CSRFInput(d.CSRFToken)
- -
Where deal alerts and the weekly digest are sent. Requires Resend to be configured by an admin.
+ if emailManagedExternally(d) { + +
Managed by Authentik. Change your email in your Authentik profile; it syncs here on next sign-in.
+ } else { + +
Where deal alerts and the weekly digest are sent. Requires Resend to be configured by an admin.
+ }