fix(oidc): 同步认证中心用户资料到本地用户
从已验证的 OIDC Claim 提取用户名、显示名称、邮箱、手机号和头像,并覆盖单租户、多租户及平台用户的 JIT 创建与重复登录同步。\n\n保留 metadata.manualProfile 标记下的人工资料,限制字段长度且仅接收已验证联系方式与 HTTPS 头像。已通过 auth、httpapi、store 测试及临时 PostgreSQL 集成验证。
This commit is contained in:
@@ -36,6 +36,10 @@ const (
|
||||
type User struct {
|
||||
ID string `json:"sub"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"-"`
|
||||
Email string `json:"-"`
|
||||
Phone string `json:"-"`
|
||||
AvatarURL string `json:"-"`
|
||||
Roles []string `json:"role,omitempty"`
|
||||
ContextType string `json:"contextType,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
|
||||
@@ -252,18 +252,51 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||
return nil, oidcUnauthorized("TOKEN_INACTIVE", "token is inactive", err)
|
||||
}
|
||||
}
|
||||
username := stringClaim(claims, "preferred_username")
|
||||
username := oidcProfileText(claims, "preferred_username", 320)
|
||||
if username == "" {
|
||||
username = stringClaim(claims, "username")
|
||||
username = oidcProfileText(claims, "username", 320)
|
||||
}
|
||||
return &User{
|
||||
ID: stringClaim(claims, "sub"), Username: username, Roles: roles,
|
||||
ID: stringClaim(claims, "sub"), Username: username,
|
||||
DisplayName: oidcProfileText(claims, "name", 200),
|
||||
Email: oidcVerifiedProfileText(claims, "email", "email_verified", 320),
|
||||
Phone: oidcVerifiedProfileText(claims, "phone_number", "phone_number_verified", 64),
|
||||
AvatarURL: safeOIDCProfileURL(oidcProfileText(claims, "picture", 2048)),
|
||||
Roles: roles,
|
||||
ContextType: contextType, TenantID: tenantID, Source: "oidc",
|
||||
TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer,
|
||||
ApplicationID: v.config.ApplicationID, OIDCClientID: clientID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func oidcProfileText(claims jwt.MapClaims, key string, limit int) string {
|
||||
value := strings.TrimSpace(stringClaim(claims, key))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) > limit {
|
||||
return string(runes[:limit])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func oidcVerifiedProfileText(claims jwt.MapClaims, key, verifiedKey string, limit int) string {
|
||||
verified, ok := claims[verifiedKey].(bool)
|
||||
if !ok || !verified {
|
||||
return ""
|
||||
}
|
||||
return oidcProfileText(claims, key, limit)
|
||||
}
|
||||
|
||||
func safeOIDCProfileURL(value string) string {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
type oidcValidationError struct {
|
||||
category string
|
||||
reason string
|
||||
|
||||
@@ -66,11 +66,27 @@ func TestOIDCVerifierAcceptsRS256AndES256StableClaims(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.ID != "platform-subject" || user.TenantID != "tenant-1" || user.Source != "oidc" ||
|
||||
user.Username != "acceptance" || user.DisplayName != "王小明" ||
|
||||
user.Email != "real.user@example.test" || user.Phone != "+8613800000000" ||
|
||||
user.AvatarURL != "https://static.example.test/avatar.png" ||
|
||||
len(user.Roles) != 1 || user.Roles[0] != "admin" {
|
||||
t.Fatalf("unexpected OIDC user: %#v", user)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
unverified := signedOIDCToken(t, issuer, "rsa-key", jwt.SigningMethodRS256, rsaKey, func(claims jwt.MapClaims) {
|
||||
claims["email_verified"] = false
|
||||
claims["phone_number_verified"] = false
|
||||
claims["picture"] = "javascript:alert(1)"
|
||||
})
|
||||
user, err := verifier.Verify(context.Background(), unverified)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.Email != "" || user.Phone != "" || user.AvatarURL != "" {
|
||||
t.Fatalf("untrusted profile claims were accepted: %#v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
|
||||
@@ -400,7 +416,10 @@ func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod,
|
||||
"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(),
|
||||
"name": "王小明", "email": "real.user@example.test", "email_verified": true,
|
||||
"phone_number": "+8613800000000", "phone_number_verified": true,
|
||||
"picture": "https://static.example.test/avatar.png",
|
||||
"scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
}
|
||||
if mutate != nil {
|
||||
|
||||
Reference in New Issue
Block a user