Unverified Commit 38288e1c authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(api): 删除未接通的 Access JWT 委托,外部面只认会话 cookie,admin 主机的 IP 判定只认安装指定的地址,文档与 OpenAPI 同步

parent a883c1fe
Loading
Loading
Loading
Loading
+5 −13
Changes for cmd/felis/api.go: 5 added lines, 13 removed lines.
Original line number Diff line number Diff line
@@ -60,10 +60,8 @@ func authSourcesFromConfig(configured []config.AuthSourceConfig) []api.AuthSourc
}

// cmdAPI runs felis-api: two listeners, two middleware chains (spec §7). The
// internal face (service token) is fully wired. The external face is wired but
// fails closed until an Access JWKS key function is configured — the verifier's
// audience logic is unit-tested (internal/api), the JWKS source is a deployment
// integration point.
// internal face authenticates per-caller service tokens; the external face
// authenticates the local session cookie.
func cmdAPI(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("api", flag.ContinueOnError)
	fs.SetOutput(stderr)
@@ -317,16 +315,11 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
		Files:         files,
		Submissions:   submissions,
		Mailer:        mailer,
		// The external face is fronted by SessionAuth: it prefers a local session
		// cookie (minted by the passwordless doors) and otherwise delegates to the
		// Cloudflare-Access JWT verifier, so both auth models coexist on one face. The
		// delegate's Keyfunc is intentionally nil — the JWT path fails closed until a
		// JWKS-backed key function is wired (deployment integration point) — while the
		// local session path is live the moment `felis breakGlass` flips
		// local_auth_enabled on.
		// The external face authenticates the local session cookie the sign-in doors
		// mint, live once `felis breakGlass` flips local_auth_enabled on. Cloudflare
		// Access, when the install sits behind it, is enforced at the edge only.
		External: api.SessionAuth{
			Repo:          repo,
			Delegate:      api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
			RootDomain:    cfg.Server.RootDomain,
			AdminHostname: cfg.Auth.AdminHostname,
		},
@@ -355,7 +348,6 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
		ClientIPHeader: cfg.Auth.EffectiveClientIPHeader(),
		MailLimit:      mailLimit(cfg.SMTP.MaxPerHour),
	}
	fmt.Fprintln(stderr, "felis api: external face fails closed (Access JWKS key function not configured)")
	if a.ClientIPHeader != "" {
		fmt.Fprintf(stderr, "felis api: sign-in rate limit keys on the %s header\n", a.ClientIPHeader)
	} else {
+103 −109

File changed.

Preview size limit exceeded, changes collapsed.

+24 −37
Changes for docs/troubleshooting.md: 24 added lines, 37 removed lines.
Original line number Diff line number Diff line
@@ -304,45 +304,32 @@ point.

## 5. Web panel returns 401 / 403 (Zero-Trust / Cloudflare Access)

The external face accepts either a Cloudflare Access JWT
(`Cf-Access-Jwt-Assertion` header) **or** a local session cookie. The error
envelope is always `{"error":{"code","message","request_id"}}`. [GO-TESTED.]

- **`401 unauthorized`** — not authenticated: no/invalid Access JWT and no valid
  session. [GO-TESTED.]
The external face has one credential: the `felis_session` cookie the sign-in
doors mint. Cloudflare Access, when the install sits behind it, is enforced at
the Cloudflare edge only — felis-api does not read the `Cf-Access-Jwt-Assertion`
header, so a request that reaches the origin some other way still has to sign in,
and the account and its role always come from the `users` table. The edge setup
fences the panel NodePort to loopback (the `felis_edge` nftables table), so every
request reaches the API through cloudflared and Access stays in front of the
operator console; check `nft list table inet felis_edge` if you doubt it. The error envelope is always
`{"error":{"code","message","request_id"}}`. [GO-TESTED.]

- **`401 unauthorized`** — no valid session cookie. [GO-TESTED.]
- **`403 forbidden`** — authenticated but not permitted (e.g. a non-admin
  principal hitting an admin route; `IsAdmin()` requires `role=admin` **and**
  arrival via the admin Access audience/host). [GO-TESTED.]

### 5a. Every external request 401s on a fresh deploy

The Access verifier is wired **fail-closed**: `Keyfunc` (the JWKS key function)
is `nil` until deployment wiring supplies it. With a nil Keyfunc, **every** JWT
verification fails, and startup logs:

```
felis api: external face fails closed (Access JWKS key function not configured)
```

[INTEGRATION-ONLY — the live JWKS path is a deployment point.] This is intended:
the panel rejects all callers until JWKS is configured. Fix by wiring the
Access JWKS key function for `cfg.Auth.AccessJWTAud`.

### 5b. Token rejected with audience error

```
token audience does not include "<aud>"
```
  principal hitting an admin route; `IsAdmin()` requires a staff role **and** a
  request on the operator console host). [GO-TESTED.]

The JWT's `aud` claim does not contain the configured `cfg.Auth.AccessJWTAud`
(or the admin audience for admin routes). [GO-TESTED.] Confirm the Access
application audience matches `cfg.Auth.AccessJWTAud`.
### 5a. Staff routes 403 on a local IP URL

**Trust-model note for operators:** verification is **expiration-required +
audience + signing-key (JWKS)**. There is **no `iss` (issuer) check** anywhere in
the verifier. Trust rests entirely on the audience claim plus the JWKS signing
key. When documenting or auditing the trust boundary, do not assume issuer is
validated — it is not.
The operator console is recognised by the request's host: `admin_hostname`
(default `op.console.<root_domain>`). A bare IP counts only when the install
names it — the address a `<ip>.nip.io` / `<ip>.sslip.io` root domain embeds
(the local panel URL `felis setup` prints), or an `admin_hostname` set to that
IP. Any other address, loopback included, is served as the player console, so a
staff account signed in at `https://127.0.0.1:30443` through an SSH tunnel gets
403 on admin routes. Open the console by its hostname instead (an `/etc/hosts`
entry or `curl --resolve` pointing it at the tunnel), or set
`[auth] admin_hostname` to the IP you use. [GO-TESTED]

### 5c. Local-password login fails or is silently rejected

@@ -353,7 +340,7 @@ unparseable → treated as disabled). Symptoms:

- Cookie present but login rejected with `local auth disabled` → the
  `local_auth_enabled` setting is false/absent. A present cookie under disabled
  local-auth is **rejected outright**, not fallen through to the JWT path.
  local-auth is **rejected outright**.
- `invalid session: …` → bad/forged session hash.

Fix: set `local_auth_enabled=true` in `platform_settings` if local password auth
+1 −1
Changes for go.mod: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -11,7 +11,6 @@ require (
	github.com/descope/virtualwebauthn v1.0.5
	github.com/go-logr/logr v1.4.2
	github.com/go-webauthn/webauthn v0.17.4
	github.com/golang-jwt/jwt/v5 v5.3.1
	github.com/google/uuid v1.6.0
	github.com/jackc/pgx/v5 v5.9.2
	github.com/minio/minio-go/v7 v7.2.1
@@ -49,6 +48,7 @@ require (
	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
	github.com/go-webauthn/x v0.2.6 // indirect
	github.com/gogo/protobuf v1.3.2 // indirect
	github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
	github.com/golang/protobuf v1.5.4 // indirect
	github.com/google/gnostic-models v0.6.8 // indirect
+10 −9
Changes for internal/api/api.go: 10 added lines, 9 removed lines.
Original line number Diff line number Diff line
// Package api implements felis-api: one binary serving two faces (spec §7).
//
// The internal face (velocity / backend callbacks) authenticates with a static
// service token and is never wrapped in Zero Trust. The external face (people /
// panel) authenticates with a Cloudflare Access JWT; admin-tier operations
// additionally require the admin Access path (spec §14, graded by operation).
// per-caller service token and is never wrapped in Zero Trust. The external face
// (people / panel) authenticates the local session cookie; admin-tier operations
// additionally require a staff session on the operator console host (spec §14,
// graded by operation).
//
// Handlers depend on the Repo and Cluster interfaces, so the request routing,
// dual-face auth, input validation and authorization are all unit-tested with
@@ -303,7 +304,7 @@ func (a *API) streamGate() *streamLimiter {
}

// streamKey identifies the principal a stream slot is charged to. It prefers the
// stable user id and falls back to the email so a JWT principal without a user id is
// stable user id and falls back to the email so a principal without a user id is
// still bucketed by identity; an empty key (no authenticated identity, which the
// external face's auth guard already precludes) shares one bucket, which is safe
// because it is more restrictive, never less.
@@ -443,8 +444,8 @@ func (a *API) internalAPIRoutes() []apiRoute {
}

// externalAPIRoutes is the external face's served route table (spec §7, §14):
// Cloudflare Access-JWT auth on every /api/v1 route; the Admin entries are
// additionally gated on the admin Zero-Trust path. It exposes liveness only —
// session auth on every non-public /api/v1 route; the Admin entries are
// additionally gated on the operator console host. It exposes liveness only —
// readiness is an internal concern.
func (a *API) externalAPIRoutes() []apiRoute {
	return []apiRoute{
@@ -691,9 +692,9 @@ func (a *API) InternalHandler() http.Handler {
	return a.buildFace("internal", a.internalAPIRoutes(), a.requireInternal)
}

// ExternalHandler builds the external-face http.Handler: Access-JWT auth on every
// /api/v1 route, with admin-tier routes additionally gated by the admin Access
// path inside their handlers.
// ExternalHandler builds the external-face http.Handler: session auth on every
// non-public /api/v1 route, with admin-tier routes additionally gated on the
// operator console host inside their handlers.
func (a *API) ExternalHandler() http.Handler {
	return a.buildFace("external", a.externalAPIRoutes(), a.requireExternal)
}
Loading