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:
2026-07-31 14:24:40 +08:00
parent 88c971564a
commit c0296dbf06
29 changed files with 1061 additions and 84 deletions
+1
View File
@@ -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"`
+16 -5
View File
@@ -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
}
+16 -2
View File
@@ -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)))
}
+6 -1
View File
@@ -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)
}
+87
View File
@@ -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(),