fix(identity): 支持平台用户显式登录 AI Gateway
修复多租户身份配置将所有人类登录都强制解释为租户上下文的问题。Web 现在提供平台与租户两个受控入口,API 严格绑定 context_type、tid、issuer、application 和 subject,并为平台用户建立独立本地投影与可刷新会话。\n\n风险:新增会话身份列保持旧会话可读,新会话一律使用严格约束;未改变租户数据隔离和 API Key 行为。\n\n验证:Go 全量测试与 go vet 通过;PostgreSQL 平台投影、会话和安全事件集成测试通过;前端 lint、141 项测试与生产 build 通过;OpenAPI 生成和迁移安全测试通过。
This commit is contained in:
@@ -37,6 +37,7 @@ type User struct {
|
||||
ID string `json:"sub"`
|
||||
Username string `json:"username"`
|
||||
Roles []string `json:"role,omitempty"`
|
||||
ContextType string `json:"contextType,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantName string `json:"tenantName,omitempty"`
|
||||
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||
|
||||
@@ -50,6 +50,7 @@ type OIDCConfig struct {
|
||||
type OIDCSecurityEventIdentity struct {
|
||||
Issuer string
|
||||
ApplicationID string
|
||||
ContextType string
|
||||
TenantID string
|
||||
Subject string
|
||||
IssuedAt time.Time
|
||||
@@ -179,13 +180,21 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||
return nil, oidcUnauthorized(registeredClaimsValidationCategory(err), "signature or registered claims are invalid", err)
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
contextType := stringClaim(claims, "context_type")
|
||||
tenantID := stringClaim(claims, "tid")
|
||||
validTenant := tenantID == v.config.TenantID
|
||||
validContext := contextType == "tenant" && tenantID == v.config.TenantID
|
||||
if v.config.TenantMode == "multi_tenant" {
|
||||
validTenant = uuid.Validate(tenantID) == nil
|
||||
switch contextType {
|
||||
case "platform":
|
||||
validContext = tenantID == ""
|
||||
case "tenant":
|
||||
validContext = uuid.Validate(tenantID) == nil
|
||||
default:
|
||||
validContext = false
|
||||
}
|
||||
}
|
||||
clientID := stringClaim(claims, "client_id")
|
||||
if !ok || stringClaim(claims, "sub") == "" || !validTenant ||
|
||||
if !ok || stringClaim(claims, "sub") == "" || !validContext ||
|
||||
v.config.ClientID != "" && clientID != v.config.ClientID {
|
||||
return nil, oidcUnauthorized("STABLE_IDENTITY_CLAIMS_INVALID", "stable identity claims are invalid", nil)
|
||||
}
|
||||
@@ -211,7 +220,8 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||
if v.config.SecurityEventEvaluator != nil {
|
||||
evaluation, evaluateErr := v.config.SecurityEventEvaluator(ctx, OIDCSecurityEventIdentity{
|
||||
Issuer: v.config.Issuer, ApplicationID: v.config.ApplicationID,
|
||||
TenantID: tenantID, Subject: stringClaim(claims, "sub"), IssuedAt: issuedAt,
|
||||
ContextType: contextType, TenantID: tenantID,
|
||||
Subject: stringClaim(claims, "sub"), IssuedAt: issuedAt,
|
||||
})
|
||||
if evaluateErr != nil {
|
||||
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
|
||||
@@ -248,7 +258,8 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||
}
|
||||
return &User{
|
||||
ID: stringClaim(claims, "sub"), Username: username, Roles: roles,
|
||||
TenantID: tenantID, Source: "oidc", TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer,
|
||||
ContextType: contextType, TenantID: tenantID, Source: "oidc",
|
||||
TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer,
|
||||
ApplicationID: v.config.ApplicationID, OIDCClientID: clientID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -78,18 +78,32 @@ func (c *OIDCPublicClient) ValidateConfiguration(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, pkceVerifier, tenantHint string) (string, error) {
|
||||
func (c *OIDCPublicClient) AuthorizationURL(
|
||||
ctx context.Context,
|
||||
state, nonce, pkceVerifier, contextType, tenantHint string,
|
||||
) (string, error) {
|
||||
if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || !validPKCEVerifier(pkceVerifier) {
|
||||
return "", errors.New("state, nonce and PKCE verifier are required")
|
||||
}
|
||||
contextType = strings.TrimSpace(contextType)
|
||||
if contextType != "platform" && contextType != "tenant" {
|
||||
return "", errors.New("context type must be platform or tenant")
|
||||
}
|
||||
if strings.TrimSpace(tenantHint) != "" && uuid.Validate(strings.TrimSpace(tenantHint)) != nil {
|
||||
return "", errors.New("tenant hint must be a UUID")
|
||||
}
|
||||
if contextType == "platform" && strings.TrimSpace(tenantHint) != "" {
|
||||
return "", errors.New("platform context cannot bind a tenant hint")
|
||||
}
|
||||
config, _, err := c.configuration(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
options := []oauth2.AuthCodeOption{oidc.Nonce(nonce), oauth2.S256ChallengeOption(pkceVerifier)}
|
||||
options := []oauth2.AuthCodeOption{
|
||||
oidc.Nonce(nonce),
|
||||
oauth2.S256ChallengeOption(pkceVerifier),
|
||||
oauth2.SetAuthURLParam("context_type", contextType),
|
||||
}
|
||||
if strings.TrimSpace(tenantHint) != "" {
|
||||
options = append(options, oauth2.SetAuthURLParam("tenant_hint", strings.TrimSpace(tenantHint)))
|
||||
}
|
||||
|
||||
@@ -68,7 +68,9 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
|
||||
t.Fatalf("ValidateConfiguration() error = %v", err)
|
||||
}
|
||||
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier, tenantHint)
|
||||
authorizationURL, err := client.AuthorizationURL(
|
||||
context.Background(), "state", "nonce", pkceVerifier, "tenant", tenantHint,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -82,6 +84,9 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
|
||||
if query.Get("tenant_hint") != tenantHint {
|
||||
t.Fatalf("tenant_hint = %q, want %q", query.Get("tenant_hint"), tenantHint)
|
||||
}
|
||||
if query.Get("context_type") != "tenant" {
|
||||
t.Fatalf("context_type = %q, want tenant", query.Get("context_type"))
|
||||
}
|
||||
if _, err := client.ExchangeCode(context.Background(), "authorization-code", pkceVerifier); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
|
||||
{"missing issuer", "ISSUER_MISSING", func(claims jwt.MapClaims) { delete(claims, "iss") }},
|
||||
{"missing audience", "AUDIENCE_MISSING", func(claims jwt.MapClaims) { delete(claims, "aud") }},
|
||||
{"wrong audience", "AUDIENCE_INVALID", func(claims jwt.MapClaims) { claims["aud"] = "other-api" }},
|
||||
{"missing context type", "STABLE_IDENTITY_CLAIMS_INVALID", func(claims jwt.MapClaims) { delete(claims, "context_type") }},
|
||||
{"unknown context type", "STABLE_IDENTITY_CLAIMS_INVALID", func(claims jwt.MapClaims) { claims["context_type"] = "account" }},
|
||||
{"wrong tenant", "STABLE_IDENTITY_CLAIMS_INVALID", func(claims jwt.MapClaims) { claims["tid"] = "tenant-2" }},
|
||||
{"missing scope", "REQUIRED_SCOPE_MISSING", func(claims jwt.MapClaims) { claims["scope"] = "openid" }},
|
||||
{"unmapped role", "MAPPED_ROLE_MISSING", func(claims jwt.MapClaims) { claims["roles"] = []string{"other.admin"} }},
|
||||
@@ -120,6 +122,90 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCVerifierAcceptsExplicitPlatformAndTenantContextsForMultiTenantApplication(t *testing.T) {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
var issuer string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/.well-known/openid-configuration" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": issuer,
|
||||
"jwks_uri": issuer + "/jwks",
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"keys": []any{ecJWK("ec-key", &key.PublicKey)},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
verifier, err := NewOIDCVerifier(OIDCConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, Audience: "gateway-api",
|
||||
TenantMode: "multi_tenant",
|
||||
ApplicationID: "11111111-1111-4111-8111-111111111111",
|
||||
RolePrefix: "gateway.", HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
platformToken := signedOIDCToken(
|
||||
t,
|
||||
issuer,
|
||||
"ec-key",
|
||||
jwt.SigningMethodES256,
|
||||
key,
|
||||
func(claims jwt.MapClaims) {
|
||||
claims["context_type"] = "platform"
|
||||
delete(claims, "tid")
|
||||
},
|
||||
)
|
||||
platformUser, err := verifier.Verify(context.Background(), platformToken)
|
||||
if err != nil {
|
||||
t.Fatalf("platform context rejected: %v", err)
|
||||
}
|
||||
if platformUser.ContextType != "platform" || platformUser.TenantID != "" {
|
||||
t.Fatalf("platform user=%#v", platformUser)
|
||||
}
|
||||
tenantID := "22222222-2222-4222-8222-222222222222"
|
||||
tenantToken := signedOIDCToken(
|
||||
t,
|
||||
issuer,
|
||||
"ec-key",
|
||||
jwt.SigningMethodES256,
|
||||
key,
|
||||
func(claims jwt.MapClaims) {
|
||||
claims["context_type"] = "tenant"
|
||||
claims["tid"] = tenantID
|
||||
},
|
||||
)
|
||||
tenantUser, err := verifier.Verify(context.Background(), tenantToken)
|
||||
if err != nil {
|
||||
t.Fatalf("tenant context rejected: %v", err)
|
||||
}
|
||||
if tenantUser.ContextType != "tenant" || tenantUser.TenantID != tenantID {
|
||||
t.Fatalf("tenant user=%#v", tenantUser)
|
||||
}
|
||||
for name, mutate := range map[string]func(jwt.MapClaims){
|
||||
"platform with tenant": func(claims jwt.MapClaims) {
|
||||
claims["context_type"] = "platform"
|
||||
},
|
||||
"tenant without tenant": func(claims jwt.MapClaims) {
|
||||
claims["context_type"] = "tenant"
|
||||
delete(claims, "tid")
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
raw := signedOIDCToken(
|
||||
t, issuer, "ec-key", jwt.SigningMethodES256, key, mutate,
|
||||
)
|
||||
if _, err := verifier.Verify(context.Background(), raw); err == nil {
|
||||
t.Fatal("context/tenant mismatch was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing.T) {
|
||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
active := true
|
||||
@@ -312,6 +398,7 @@ func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod,
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"iss": issuer, "aud": "gateway-api", "sub": "platform-subject", "tid": "tenant-1",
|
||||
"context_type": "tenant",
|
||||
"preferred_username": "acceptance", "roles": []string{"gateway.admin"},
|
||||
"scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
|
||||
Reference in New Issue
Block a user