feat(identity): 接入认证中心多租户登录

支持 Manifest V2 动态 tid 验证、Tenant Context 同步和租户内 JIT 投影,并保留 Manifest V1 与旧 Session 兼容。\n\n增加 tenantHint、租户切换、普通注册关闭及 application/principal/tenant 两级 SSF 撤销;迁移、定向安全测试和本地双租户跨仓 E2E 已通过。\n\nrelease_required=true;未执行 Release、Staging 或真实链路。
This commit is contained in:
2026-07-28 17:28:35 +08:00
parent 0b02e62c72
commit 5c679ff13f
45 changed files with 2986 additions and 139 deletions
+25 -21
View File
@@ -34,27 +34,31 @@ const (
)
type User struct {
ID string `json:"sub"`
Username string `json:"username"`
Roles []string `json:"role,omitempty"`
TenantID string `json:"tenantId,omitempty"`
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
TenantKey string `json:"tenantKey,omitempty"`
SSOID string `json:"sso_id,omitempty"`
Source string `json:"source,omitempty"`
GatewayUserID string `json:"gatewayUserId,omitempty"`
UserGroupID string `json:"userGroupId,omitempty"`
UserGroupKey string `json:"userGroupKey,omitempty"`
UserGroupKeys []string `json:"userGroupKeys,omitempty"`
APIKeyID string `json:"apiKeyId,omitempty"`
APIKeySecret string `json:"apiKeySecret,omitempty"`
APIKeyName string `json:"apiKeyName,omitempty"`
APIKeyPrefix string `json:"apiKeyPrefix,omitempty"`
APIKeyScopes []string `json:"apiKeyScopes,omitempty"`
TokenExpiresAt time.Time `json:"-"`
TokenIssuedAt time.Time `json:"-"`
Issuer string `json:"-"`
TokenPurpose string `json:"-"`
ID string `json:"sub"`
Username string `json:"username"`
Roles []string `json:"role,omitempty"`
TenantID string `json:"tenantId,omitempty"`
TenantName string `json:"tenantName,omitempty"`
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
TenantKey string `json:"tenantKey,omitempty"`
SSOID string `json:"sso_id,omitempty"`
Source string `json:"source,omitempty"`
GatewayUserID string `json:"gatewayUserId,omitempty"`
UserGroupID string `json:"userGroupId,omitempty"`
UserGroupKey string `json:"userGroupKey,omitempty"`
UserGroupKeys []string `json:"userGroupKeys,omitempty"`
APIKeyID string `json:"apiKeyId,omitempty"`
APIKeySecret string `json:"apiKeySecret,omitempty"`
APIKeyName string `json:"apiKeyName,omitempty"`
APIKeyPrefix string `json:"apiKeyPrefix,omitempty"`
APIKeyScopes []string `json:"apiKeyScopes,omitempty"`
TokenExpiresAt time.Time `json:"-"`
TokenIssuedAt time.Time `json:"-"`
Issuer string `json:"-"`
ApplicationID string `json:"-"`
OIDCClientID string `json:"-"`
OIDCUserBindingID string `json:"-"`
TokenPurpose string `json:"-"`
}
type contextKey string
+30 -8
View File
@@ -20,6 +20,7 @@ import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const maxOIDCResponseBytes = 1 << 20
@@ -30,6 +31,9 @@ type OIDCConfig struct {
Issuer string
Audience string
TenantID string
TenantMode string
ApplicationID string
ClientID string
RolePrefix string
RequiredScopes []string
JWKSCacheTTL time.Duration
@@ -44,10 +48,11 @@ type OIDCConfig struct {
}
type OIDCSecurityEventIdentity struct {
Issuer string
TenantID string
Subject string
IssuedAt time.Time
Issuer string
ApplicationID string
TenantID string
Subject string
IssuedAt time.Time
}
type OIDCSecurityEventEvaluation struct {
@@ -91,8 +96,16 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
config.Issuer = strings.TrimRight(strings.TrimSpace(config.Issuer), "/")
config.Audience = strings.TrimSpace(config.Audience)
config.TenantID = strings.TrimSpace(config.TenantID)
config.TenantMode = strings.TrimSpace(config.TenantMode)
config.ApplicationID = strings.TrimSpace(config.ApplicationID)
config.ClientID = strings.TrimSpace(config.ClientID)
config.RolePrefix = strings.TrimSpace(config.RolePrefix)
if err := validatePublicURL(config.Issuer, config.AppEnv); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
if config.TenantMode == "" {
config.TenantMode = "single_tenant"
}
validTenantMode := config.TenantMode == "single_tenant" && config.TenantID != "" ||
config.TenantMode == "multi_tenant" && config.TenantID == "" && config.ApplicationID != ""
if err := validatePublicURL(config.Issuer, config.AppEnv); err != nil || config.Audience == "" || !validTenantMode || config.RolePrefix == "" {
return nil, errors.New("issuer, audience, tenant and role prefix are required")
}
if config.IntrospectionEnabled && config.IntrospectionCredentialProvider == nil &&
@@ -166,7 +179,14 @@ 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)
if !ok || stringClaim(claims, "sub") == "" || stringClaim(claims, "tid") != v.config.TenantID {
tenantID := stringClaim(claims, "tid")
validTenant := tenantID == v.config.TenantID
if v.config.TenantMode == "multi_tenant" {
validTenant = uuid.Validate(tenantID) == nil
}
clientID := stringClaim(claims, "client_id")
if !ok || stringClaim(claims, "sub") == "" || !validTenant ||
v.config.ClientID != "" && clientID != v.config.ClientID {
return nil, oidcUnauthorized("STABLE_IDENTITY_CLAIMS_INVALID", "stable identity claims are invalid", nil)
}
expiresAt, ok := numericDateClaim(claims["exp"])
@@ -190,7 +210,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, TenantID: v.config.TenantID, Subject: stringClaim(claims, "sub"), IssuedAt: issuedAt,
Issuer: v.config.Issuer, ApplicationID: v.config.ApplicationID,
TenantID: tenantID, Subject: stringClaim(claims, "sub"), IssuedAt: issuedAt,
})
if evaluateErr != nil {
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
@@ -227,7 +248,8 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
}
return &User{
ID: stringClaim(claims, "sub"), Username: username, Roles: roles,
TenantID: v.config.TenantID, Source: "oidc", TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer,
TenantID: tenantID, Source: "oidc", TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer,
ApplicationID: v.config.ApplicationID, OIDCClientID: clientID,
}, nil
}
+10 -2
View File
@@ -11,6 +11,7 @@ import (
"sync"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/google/uuid"
"golang.org/x/oauth2"
)
@@ -77,15 +78,22 @@ func (c *OIDCPublicClient) ValidateConfiguration(ctx context.Context) error {
return err
}
func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, pkceVerifier string) (string, error) {
func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, pkceVerifier, tenantHint string) (string, error) {
if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || !validPKCEVerifier(pkceVerifier) {
return "", errors.New("state, nonce and PKCE verifier are required")
}
if strings.TrimSpace(tenantHint) != "" && uuid.Validate(strings.TrimSpace(tenantHint)) != nil {
return "", errors.New("tenant hint must be a UUID")
}
config, _, err := c.configuration(ctx)
if err != nil {
return "", err
}
return config.AuthCodeURL(state, oidc.Nonce(nonce), oauth2.S256ChallengeOption(pkceVerifier)), nil
options := []oauth2.AuthCodeOption{oidc.Nonce(nonce), oauth2.S256ChallengeOption(pkceVerifier)}
if strings.TrimSpace(tenantHint) != "" {
options = append(options, oauth2.SetAuthURLParam("tenant_hint", strings.TrimSpace(tenantHint)))
}
return config.AuthCodeURL(state, options...), nil
}
func (c *OIDCPublicClient) ExchangeCode(ctx context.Context, code, verifier string) (OIDCTokenResponse, error) {
+5 -1
View File
@@ -67,7 +67,8 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
if err := client.ValidateConfiguration(context.Background()); err != nil {
t.Fatalf("ValidateConfiguration() error = %v", err)
}
authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier)
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier, tenantHint)
if err != nil {
t.Fatal(err)
}
@@ -78,6 +79,9 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
if query.Get("response_type") != "code" || query.Get("code_challenge_method") != "S256" || query.Get("code_challenge") != expectedChallenge {
t.Fatalf("authorization request is not PKCE S256: %v", query)
}
if query.Get("tenant_hint") != tenantHint {
t.Fatalf("tenant_hint = %q, want %q", query.Get("tenant_hint"), tenantHint)
}
if _, err := client.ExchangeCode(context.Background(), "authorization-code", pkceVerifier); err != nil {
t.Fatal(err)
}