diff --git a/.env.example b/.env.example index 4d8fd85..b80c0d4 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,10 @@ ADMIN_PASSWORD=change-me SESSION_SECRET=generate-with-openssl-rand-hex-32 BASE_URL=https://artifacto.hubris.network + +# Optional: when set, a reverse proxy forwarding Authentik headers can auto-login +# without the admin password. The proxy must inject `X-Artifacto-Gateway: ` on every request it proxies; Artifacto rejects SSO headers from +# requests missing that header so peers that can reach the container directly +# can't spoof Authentik identities. +# SSO_GATEWAY_SECRET=generate-with-openssl-rand-hex-32 diff --git a/README.md b/README.md index ce71df4..db519a4 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Then put a reverse proxy (Caddy, nginx, Traefik) in front for HTTPS. | `BIND_ADDR` | `:3000` | Listen address | | `MAX_UPLOAD_MB` | `5` | Per-artifact upload cap | | `LOG_LEVEL` | `info` | `info` or `debug` | +| `SSO_GATEWAY_SECRET` | — | Optional: enables auto-login from a trusted reverse proxy forwarding Authentik headers plus a matching `X-Artifacto-Gateway` header | ## License diff --git a/cmd/artifacto/main.go b/cmd/artifacto/main.go index 4ea7aa4..ed86000 100644 --- a/cmd/artifacto/main.go +++ b/cmd/artifacto/main.go @@ -28,6 +28,7 @@ func main() { baseURL := envOr("BASE_URL", "http://localhost:3000") adminPw := os.Getenv("ADMIN_PASSWORD") sessionSecret := os.Getenv("SESSION_SECRET") + ssoSecret := os.Getenv("SSO_GATEWAY_SECRET") maxMB, _ := strconv.ParseInt(envOr("MAX_UPLOAD_MB", "5"), 10, 64) if sessionSecret == "" { @@ -46,7 +47,7 @@ func main() { defer s.Close() secure := strings.HasPrefix(baseURL, "https://") - admin, err := auth.NewAdmin(adminPw, sessionSecret, secure) + admin, err := auth.NewAdmin(adminPw, sessionSecret, ssoSecret, secure) if err != nil { logger.Error("init admin auth", "err", err) os.Exit(1) diff --git a/docker-compose.yml b/docker-compose.yml index fd66a36..c4284a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: SESSION_SECRET: ${SESSION_SECRET:?set in .env} BASE_URL: ${BASE_URL:-https://artifacto.hubris.network} MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-5} + SSO_GATEWAY_SECRET: ${SSO_GATEWAY_SECRET:-} volumes: - ./data:/data ports: diff --git a/internal/auth/admin.go b/internal/auth/admin.go index fa42d31..ccdb419 100644 --- a/internal/auth/admin.go +++ b/internal/auth/admin.go @@ -15,8 +15,10 @@ import ( ) const ( - AdminCookie = "artifacto_admin" - adminLifetime = 30 * 24 * time.Hour + AdminCookie = "artifacto_admin" + adminLifetime = 30 * 24 * time.Hour + ssoGatewayHeader = "X-Artifacto-Gateway" + ssoUsernameHeader = "X-Authentik-Username" ) type ctxKey int @@ -26,10 +28,11 @@ const ctxAdmin ctxKey = 1 type Admin struct { passwordHash []byte secret []byte + ssoSecret []byte secure bool } -func NewAdmin(password, secretHex string, secure bool) (*Admin, error) { +func NewAdmin(password, secretHex, ssoSecret string, secure bool) (*Admin, error) { if password == "" { return nil, errors.New("ADMIN_PASSWORD required") } @@ -40,7 +43,11 @@ func NewAdmin(password, secretHex string, secure bool) (*Admin, error) { if err != nil { return nil, err } - return &Admin{passwordHash: h, secret: []byte(secretHex), secure: secure}, nil + a := &Admin{passwordHash: h, secret: []byte(secretHex), secure: secure} + if ssoSecret != "" { + a.ssoSecret = []byte(ssoSecret) + } + return a, nil } func (a *Admin) VerifyPassword(p string) bool { @@ -99,21 +106,41 @@ func (a *Admin) ClearCookie(w http.ResponseWriter) { }) } -// Middleware allows the request through if the admin cookie is valid; otherwise -// redirects to /login (for HTML nav) or returns 401 (for API/HTMX). +// HasValidSSO reports whether the request carries an Authentik-forwarded identity +// from a trusted gateway. The shared gateway-secret header prevents spoofing by +// peers that can reach the container directly (bypassing the reverse proxy). +func (a *Admin) HasValidSSO(r *http.Request) bool { + if len(a.ssoSecret) == 0 { + return false + } + gw := r.Header.Get(ssoGatewayHeader) + if gw == "" || !hmac.Equal([]byte(gw), a.ssoSecret) { + return false + } + return r.Header.Get(ssoUsernameHeader) != "" +} + +// Middleware allows the request through if the admin cookie is valid or a +// trusted Authentik SSO header is present; otherwise redirects to /login +// (for HTML nav) or returns 401 (for API/HTMX). func (a *Admin) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - c, err := r.Cookie(AdminCookie) - if err != nil || !a.Verify(c.Value) { - if isAPI(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - http.Redirect(w, r, "/login?next="+r.URL.RequestURI(), http.StatusSeeOther) + if c, err := r.Cookie(AdminCookie); err == nil && a.Verify(c.Value) { + ctx := context.WithValue(r.Context(), ctxAdmin, true) + next.ServeHTTP(w, r.WithContext(ctx)) return } - ctx := context.WithValue(r.Context(), ctxAdmin, true) - next.ServeHTTP(w, r.WithContext(ctx)) + if a.HasValidSSO(r) { + a.SetCookie(w) + ctx := context.WithValue(r.Context(), ctxAdmin, true) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + if isAPI(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + http.Redirect(w, r, "/login?next="+r.URL.RequestURI(), http.StatusSeeOther) }) } diff --git a/internal/server/admin.go b/internal/server/admin.go index 418099e..38a832e 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -19,6 +19,16 @@ func (s *Server) getLogin(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) return } + // SSO: trusted gateway has proven identity — mint a session and skip the form. + if s.admin.HasValidSSO(r) { + s.admin.SetCookie(w) + next := r.URL.Query().Get("next") + if next == "" { + next = "/" + } + http.Redirect(w, r, next, http.StatusSeeOther) + return + } s.render(w, "login", loginData{Next: r.URL.Query().Get("next")}) }