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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user