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
@@ -39,7 +39,9 @@ func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t
input := func(tenantID, name, slug string) ResolveOrProvisionOIDCUserInput {
return ResolveOrProvisionOIDCUserInput{
Issuer: issuer, ApplicationID: applicationID, Subject: subject, Username: "shared-subject",
Roles: []string{"basic"}, ContextType: "tenant",
DisplayName: "Shared User", Email: "shared@example.test", Phone: "+8613800000000",
AvatarURL: "https://static.example.test/shared.png",
Roles: []string{"basic"}, ContextType: "tenant",
TenantID: tenantID, TenantMode: "multi_tenant",
TenantName: name, TenantSlug: slug, TenantMetadataStatus: "synced",
TenantMetadataVersion: "v1", TenantMetadataETag: `"v1"`,
@@ -69,7 +71,9 @@ func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t
if userA == nil {
userA = result.User
}
if result.User.GatewayUserID != userA.GatewayUserID || result.User.GatewayTenantID != userA.GatewayTenantID {
if result.User.GatewayUserID != userA.GatewayUserID || result.User.GatewayTenantID != userA.GatewayTenantID ||
result.User.DisplayName != "Shared User" || result.User.Email != "shared@example.test" ||
result.User.Phone != "+8613800000000" || result.User.AvatarURL != "https://static.example.test/shared.png" {
t.Fatalf("concurrent tenant A projection diverged: first=%#v current=%#v", userA, result.User)
}
if result.Created {
@@ -177,6 +181,10 @@ func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(t *testing.T) {
Issuer: "https://auth.test.example/issuer/shared",
Subject: subject,
Username: "jit-user-" + suffix,
DisplayName: "JIT User",
Email: "jit-user@example.test",
Phone: "+8613900000000",
AvatarURL: "https://static.example.test/jit-user.png",
Roles: []string{"basic"},
ContextType: "tenant",
TenantID: "auth-center-test-tenant",
@@ -214,7 +222,9 @@ WHERE target_gateway_user_id IN (
t.Fatalf("concurrent resolve %d: %v", index, err)
}
result := results[index]
if result.User == nil || result.User.GatewayUserID == "" {
if result.User == nil || result.User.GatewayUserID == "" ||
result.User.DisplayName != input.DisplayName || result.User.Email != input.Email ||
result.User.Phone != input.Phone || result.User.AvatarURL != input.AvatarURL {
t.Fatalf("concurrent resolve %d returned no local user: %+v", index, result)
}
if firstID == "" {
@@ -262,12 +272,18 @@ WHERE id = $1::uuid`, auditID).Scan(&auditProjection); err != nil {
UPDATE gateway_users
SET display_name = 'Manual Display Name',
email = 'manual-profile@example.test',
phone = '+8613000000000',
avatar_url = 'https://static.example.test/manual-profile.png',
metadata = metadata || '{"manualProfile":true}'::jsonb
WHERE id = $1::uuid`, firstID); err != nil {
t.Fatalf("seed manually managed profile fields: %v", err)
}
input.Username = "jit-user-renamed-" + suffix
input.DisplayName = "Remote Display Name"
input.Email = "remote-profile@example.test"
input.Phone = "+8613700000000"
input.AvatarURL = "https://static.example.test/remote-profile.png"
input.Roles = []string{"basic", "admin"}
input.ProvisioningEnabled = false
repeated, err := db.ResolveOrProvisionOIDCUser(ctx, input)
@@ -280,14 +296,16 @@ WHERE id = $1::uuid`, firstID); err != nil {
if repeated.User.Username != input.Username || !containsOIDCTestRole(repeated.User.Roles, "admin") {
t.Fatalf("repeat resolve did not sync token projection: %+v", repeated.User)
}
var displayName, email string
var displayName, email, phone, avatarURL string
var manualProfile bool
if err := db.pool.QueryRow(ctx, `
SELECT COALESCE(display_name, ''), COALESCE(email, ''), COALESCE((metadata->>'manualProfile')::boolean, false)
FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&displayName, &email, &manualProfile); err != nil {
SELECT COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''),
COALESCE((metadata->>'manualProfile')::boolean, false)
FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&displayName, &email, &phone, &avatarURL, &manualProfile); err != nil {
t.Fatalf("read manually managed profile fields: %v", err)
}
if displayName != "Manual Display Name" || email != "manual-profile@example.test" || !manualProfile {
if displayName != "Manual Display Name" || email != "manual-profile@example.test" ||
phone != "+8613000000000" || avatarURL != "https://static.example.test/manual-profile.png" || !manualProfile {
t.Fatalf("repeat resolve overwrote manually managed profile fields")
}
@@ -352,6 +370,10 @@ WHERE source='oidc_v2_platform'
ApplicationID: applicationID,
Subject: subject,
Username: "platform-user-" + suffix,
DisplayName: "Platform User",
Email: "platform-user@example.test",
Phone: "+8613600000000",
AvatarURL: "https://static.example.test/platform-user.png",
Roles: []string{"admin"},
ContextType: "platform",
TenantMode: "multi_tenant",
@@ -367,6 +389,10 @@ WHERE source='oidc_v2_platform'
result.User.TenantID != "" ||
result.User.TenantKey != oidcPlatformTenantKey ||
result.User.GatewayTenantID == "" ||
result.User.DisplayName != "Platform User" ||
result.User.Email != "platform-user@example.test" ||
result.User.Phone != "+8613600000000" ||
result.User.AvatarURL != "https://static.example.test/platform-user.png" ||
result.AuditID == "" {
t.Fatalf("platform projection=%#v", result)
}
@@ -407,6 +433,10 @@ WHERE source='oidc_v2_platform'
ApplicationID: applicationID,
Subject: subject,
Username: "platform-user-renamed-" + suffix,
DisplayName: "Platform User Renamed",
Email: "platform-user-renamed@example.test",
Phone: "+8613500000000",
AvatarURL: "https://static.example.test/platform-user-renamed.png",
Roles: []string{"viewer"},
ContextType: "platform",
TenantMode: "multi_tenant",
@@ -415,6 +445,10 @@ WHERE source='oidc_v2_platform'
)
if err != nil || repeated.Created ||
repeated.User.GatewayUserID != result.User.GatewayUserID ||
repeated.User.DisplayName != "Platform User Renamed" ||
repeated.User.Email != "platform-user-renamed@example.test" ||
repeated.User.Phone != "+8613500000000" ||
repeated.User.AvatarURL != "https://static.example.test/platform-user-renamed.png" ||
!reflect.DeepEqual(repeated.User.Roles, []string{"viewer"}) {
t.Fatalf("repeated platform projection=%#v err=%v", repeated, err)
}