fix(oidc): 同步认证中心用户资料到本地用户

从已验证的 OIDC Claim 提取用户名、显示名称、邮箱、手机号和头像,并覆盖单租户、多租户及平台用户的 JIT 创建与重复登录同步。\n\n保留 metadata.manualProfile 标记下的人工资料,限制字段长度且仅接收已验证联系方式与 HTTPS 头像。已通过 auth、httpapi、store 测试及临时 PostgreSQL 集成验证。
This commit is contained in:
2026-08-03 15:55:50 +08:00
parent 3c8d9839a4
commit b125599354
10 changed files with 206 additions and 24 deletions
+4
View File
@@ -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"`
+36 -3
View File
@@ -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
+20 -1
View File
@@ -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 {