fix(api): 删除未接通的 Access JWT 委托,外部面只认会话 cookie,admin 主机的 IP 判定只认安装指定的地址,文档与 OpenAPI 同步
This commit is contained in:
15 files changed
+274
-382
No files matched your search
+19
-101
@@ -6,29 +6,25 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Principal is the authenticated external-face caller (spec §7, §14). The
|
||||
// internal face (service token) never produces a Principal — it is a trusted
|
||||
// machine caller, not a person.
|
||||
type Principal struct {
|
||||
// UserID is the stable web identity (SSO subject → users.id).
|
||||
// UserID is the account's users.id.
|
||||
UserID string
|
||||
// Username is the account's login name; empty for an Access-JWT caller.
|
||||
// Username is the account's login name.
|
||||
Username string
|
||||
// Email is the account's address. Only an Access JWT or EmailVerified vouches
|
||||
// for it: a player can set any address before verifying it (auditActor).
|
||||
// Email is the account's address. Only EmailVerified vouches for it: a player
|
||||
// can set any address before verifying it (auditActor).
|
||||
Email string
|
||||
// Role is "owner", "admin", or "user" (mirrors users.role).
|
||||
Role string
|
||||
// ViaAdminAccess is true only when the request arrived through an admin-graded
|
||||
// path: the admin.* Zero-Trust hostname (Cloudflare Access, the remote face) OR
|
||||
// a local session presented on the op.console host (SessionAuth, the
|
||||
// passwordless face). Admin-tier operations require it in addition to
|
||||
// a staff role (spec §14: ZT is graded by operation). A staff session
|
||||
// arriving on the player console (console.*) never sets it.
|
||||
// ViaAdminAccess is true only when a staff session arrived on the operator
|
||||
// console host (hostIsAdminConsole). Admin-tier operations require it in
|
||||
// addition to a staff role (spec §14: ZT is graded by operation). A staff
|
||||
// session arriving on the player console (console.*) never sets it.
|
||||
ViaAdminAccess bool
|
||||
// EmailVerified mirrors users.email_verified. The lockdown middleware gates
|
||||
// setup-incomplete accounts (EmailVerified=false, e.g. a freshly bootstrapped
|
||||
@@ -36,14 +32,12 @@ type Principal struct {
|
||||
// routes only, so an intercepted setup URL cannot yield full admin access
|
||||
// before the email-OTP verification step completes.
|
||||
EmailVerified bool
|
||||
// ViaSession is true when the principal was authenticated via a local session
|
||||
// cookie (SessionAuth), not a Cloudflare-Access JWT. The setup-lockdown gate
|
||||
// only applies to session-authenticated principals — a JWT caller already
|
||||
// passed Zero Trust at the edge, so the local-email-verification gate is not
|
||||
// the right boundary for them.
|
||||
// ViaSession is true for every principal SessionAuth resolves from a session
|
||||
// cookie. The session-scoped gates (setup lockdown, reauth, the device list)
|
||||
// key on it.
|
||||
ViaSession bool
|
||||
// ReauthAt is when the holder of the session last proved a factor of the
|
||||
// account; zero for a session that never did and for a JWT caller.
|
||||
// account; zero for a session that never did.
|
||||
ReauthAt time.Time
|
||||
}
|
||||
|
||||
@@ -56,8 +50,8 @@ func staffRole(role string) bool {
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the principal may perform admin-tier operations.
|
||||
// Both the role claim and the admin Access path are required: a staff
|
||||
// session arriving on panel.* must not bypass the Zero-Trust boundary.
|
||||
// Both the staff role and arrival on the operator console host are required: a
|
||||
// staff session arriving on the player console must not reach admin routes.
|
||||
// An owner implicitly passes this check (the owner role is a superset of admin).
|
||||
func (p *Principal) IsAdmin() bool {
|
||||
return p != nil && staffRole(p.Role) && p.ViaAdminAccess
|
||||
@@ -66,7 +60,7 @@ func (p *Principal) IsAdmin() bool {
|
||||
// IsOwner reports whether the principal holds the platform-level owner role
|
||||
// — the single identity that may manage users, quotas, and sessions. Only the
|
||||
// first staff account minted by break-glass carries this role; every subsequent
|
||||
// Operator is a plain admin. Like IsAdmin, it requires the admin Access path.
|
||||
// Operator is a plain admin. Like IsAdmin, it requires the operator console host.
|
||||
func (p *Principal) IsOwner() bool {
|
||||
return p != nil && p.Role == "owner" && p.ViaAdminAccess
|
||||
}
|
||||
@@ -101,9 +95,10 @@ type InternalAuth interface {
|
||||
}
|
||||
|
||||
// ExternalAuth authenticates the external face (people / panel) and returns the
|
||||
// resolved Principal. Production verifies a Cloudflare Access JWT and checks its
|
||||
// audience; the verification key source (JWKS) is injected so the audience and
|
||||
// expiry logic stay unit-testable.
|
||||
// resolved Principal. Production is SessionAuth: the local session cookie the
|
||||
// sign-in doors mint. Cloudflare Access, when an install sits behind it, is
|
||||
// enforced at the edge only; felis-api does not read the Access JWT, so the
|
||||
// identity and role always come from the users table.
|
||||
type ExternalAuth interface {
|
||||
Authenticate(r *http.Request) (*Principal, error)
|
||||
}
|
||||
@@ -149,64 +144,6 @@ func (c CallerTokens) Authenticate(r *http.Request) (Caller, error) {
|
||||
return match, nil
|
||||
}
|
||||
|
||||
// AccessVerifier is the production ExternalAuth: it parses a Cloudflare Access
|
||||
// JWT, verifies the signature with the injected key function, and enforces the
|
||||
// configured audience (spec §7 "验 aud"). AdminAudience, when set, marks a token
|
||||
// minted for the admin.* application so admin-tier routes can require it.
|
||||
type AccessVerifier struct {
|
||||
// Audience is the required `aud` claim for any external request.
|
||||
Audience string
|
||||
// AdminAudience, if non-empty and present in the token's aud set, flags the
|
||||
// principal as having passed the admin Zero-Trust path.
|
||||
AdminAudience string
|
||||
// Keyfunc resolves the signing key (production: a JWKS-backed keyfunc).
|
||||
Keyfunc jwt.Keyfunc
|
||||
}
|
||||
|
||||
// accessClaims are the subset of Access JWT claims we consume.
|
||||
type accessClaims struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"felis_role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Authenticate verifies the Access JWT and maps it onto a Principal.
|
||||
func (v AccessVerifier) Authenticate(r *http.Request) (*Principal, error) {
|
||||
if v.Keyfunc == nil {
|
||||
return nil, fmt.Errorf("external auth not configured")
|
||||
}
|
||||
raw := accessToken(r)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("missing access token")
|
||||
}
|
||||
|
||||
var claims accessClaims
|
||||
parser := jwt.NewParser(jwt.WithExpirationRequired())
|
||||
if _, err := parser.ParseWithClaims(raw, &claims, v.Keyfunc); err != nil {
|
||||
return nil, fmt.Errorf("invalid access token: %w", err)
|
||||
}
|
||||
|
||||
// Audience check: the configured app aud must be present. We do not delegate
|
||||
// to jwt.WithAudience so we can additionally detect the admin audience.
|
||||
if !audienceContains(claims.Audience, v.Audience) {
|
||||
return nil, fmt.Errorf("token audience does not include %q", v.Audience)
|
||||
}
|
||||
if claims.Subject == "" {
|
||||
return nil, fmt.Errorf("token missing subject")
|
||||
}
|
||||
|
||||
role := claims.Role
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
return &Principal{
|
||||
UserID: claims.Subject,
|
||||
Email: claims.Email,
|
||||
Role: role,
|
||||
ViaAdminAccess: v.AdminAudience != "" && audienceContains(claims.Audience, v.AdminAudience),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// bearerToken extracts a Bearer credential from the Authorization header.
|
||||
func bearerToken(r *http.Request) string {
|
||||
const prefix = "Bearer "
|
||||
@@ -216,22 +153,3 @@ func bearerToken(r *http.Request) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// accessToken prefers the Cloudflare Access assertion header, falling back to a
|
||||
// Bearer credential so the same verifier works behind a proxy or directly.
|
||||
func accessToken(r *http.Request) string {
|
||||
if h := r.Header.Get("Cf-Access-Jwt-Assertion"); h != "" {
|
||||
return h
|
||||
}
|
||||
return bearerToken(r)
|
||||
}
|
||||
|
||||
// audienceContains reports whether want appears in the aud claim set.
|
||||
func audienceContains(aud jwt.ClaimStrings, want string) bool {
|
||||
for _, a := range aud {
|
||||
if a == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user