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
@@ -91,9 +91,15 @@ func (s *Store) resolveOrProvisionOIDCMultiTenantUser(ctx context.Context, input
}
rolesJSON, _ := json.Marshal(input.Roles)
user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET
username=COALESCE(NULLIF($2,''),username),roles=$3::jsonb,last_login_at=now(),synced_at=now(),source_updated_at=now(),updated_at=now()
username=COALESCE(NULLIF($2,''),username),
display_name=CASE WHEN metadata->>'manualProfile'='true' THEN display_name ELSE COALESCE(NULLIF($3,''),display_name) END,
email=CASE WHEN metadata->>'manualProfile'='true' THEN email ELSE COALESCE(NULLIF($4,''),email) END,
phone=CASE WHEN metadata->>'manualProfile'='true' THEN phone ELSE COALESCE(NULLIF($5,''),phone) END,
avatar_url=CASE WHEN metadata->>'manualProfile'='true' THEN avatar_url ELSE COALESCE(NULLIF($6,''),avatar_url) END,
roles=$7::jsonb,last_login_at=now(),synced_at=now(),source_updated_at=now(),updated_at=now()
WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL
RETURNING `+userColumns, user.ID, input.Username, string(rolesJSON)))
RETURNING `+userColumns, user.ID, input.Username, input.DisplayName, input.Email,
input.Phone, input.AvatarURL, string(rolesJSON)))
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
@@ -203,13 +209,16 @@ func (s *Store) createOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindin
rolesJSON, _ := json.Marshal(input.Roles)
metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit"})
user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users(
user_key,source,external_user_id,username,gateway_tenant_id,tenant_id,tenant_key,
user_key,source,external_user_id,username,display_name,email,phone,avatar_url,
gateway_tenant_id,tenant_id,tenant_key,
default_user_group_id,roles,auth_profile,metadata,status,last_login_at,synced_at,source_updated_at
) VALUES($1,'oidc_v2',NULL,$2,$3::uuid,$4,$5,$6::uuid,$7::jsonb,'{}'::jsonb,$8::jsonb,
) VALUES($1,'oidc_v2',NULL,$2,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),NULLIF($6,''),
$7::uuid,$8,$9,$10::uuid,$11::jsonb,'{}'::jsonb,$12::jsonb,
'active',now(),now(),now())
ON CONFLICT(user_key) DO UPDATE SET updated_at=gateway_users.updated_at
RETURNING `+userColumns,
userKey, username, gatewayTenantID, input.TenantID, tenantKey, groupID, string(rolesJSON), string(metadata),
userKey, username, input.DisplayName, input.Email, input.Phone, input.AvatarURL,
gatewayTenantID, input.TenantID, tenantKey, groupID, string(rolesJSON), string(metadata),
))
if err != nil {
return GatewayUser{}, "", false, err
+17 -4
View File
@@ -64,7 +64,12 @@ func (s *Store) resolveOrProvisionOIDCPlatformUser(
return ResolveOrProvisionOIDCUserResult{}, err
}
user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET
username=COALESCE(NULLIF($2,''),username),roles=$3::jsonb,
username=COALESCE(NULLIF($2,''),username),
display_name=CASE WHEN metadata->>'manualProfile'='true' THEN display_name ELSE COALESCE(NULLIF($3,''),display_name) END,
email=CASE WHEN metadata->>'manualProfile'='true' THEN email ELSE COALESCE(NULLIF($4,''),email) END,
phone=CASE WHEN metadata->>'manualProfile'='true' THEN phone ELSE COALESCE(NULLIF($5,''),phone) END,
avatar_url=CASE WHEN metadata->>'manualProfile'='true' THEN avatar_url ELSE COALESCE(NULLIF($6,''),avatar_url) END,
roles=$7::jsonb,
last_login_at=now(),synced_at=now(),source_updated_at=now(),
updated_at=now()
WHERE id=$1::uuid AND source='oidc_v2_platform'
@@ -72,6 +77,10 @@ func (s *Store) resolveOrProvisionOIDCPlatformUser(
RETURNING `+userColumns,
user.ID,
input.Username,
input.DisplayName,
input.Email,
input.Phone,
input.AvatarURL,
string(rolesJSON),
))
if err != nil {
@@ -189,17 +198,21 @@ func (s *Store) createOIDCPlatformUser(
strings.TrimPrefix(userKey, "oidc2-platform:")[:12]
}
user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users(
user_key,source,external_user_id,username,gateway_tenant_id,
user_key,source,external_user_id,username,display_name,email,phone,avatar_url,gateway_tenant_id,
tenant_id,tenant_key,default_user_group_id,roles,auth_profile,
metadata,status,last_login_at,synced_at,source_updated_at
) VALUES(
$1,'oidc_v2_platform',NULL,$2,$3::uuid,NULL,$4,$5::uuid,
$6::jsonb,'{}'::jsonb,$7::jsonb,'active',now(),now(),now()
$1,'oidc_v2_platform',NULL,$2,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),NULLIF($6,''),
$7::uuid,NULL,$8,$9::uuid,$10::jsonb,'{}'::jsonb,$11::jsonb,'active',now(),now(),now()
)
ON CONFLICT(user_key) DO NOTHING
RETURNING `+userColumns,
userKey,
username,
input.DisplayName,
input.Email,
input.Phone,
input.AvatarURL,
gatewayTenantID,
oidcPlatformTenantKey,
groupID,
@@ -2,6 +2,31 @@ package store
import "testing"
func TestAuthUserFromOIDCProjectionIncludesProfile(t *testing.T) {
projected := authUserFromOIDCProjection(GatewayUser{
ID: "gateway-user", ExternalUserID: "external-user", Username: "alice",
DisplayName: "王小明", Email: "alice@example.test", Phone: "+8613800000000",
AvatarURL: "https://static.example.test/avatar.png",
}, "default")
if projected.DisplayName != "王小明" || projected.Email != "alice@example.test" ||
projected.Phone != "+8613800000000" || projected.AvatarURL != "https://static.example.test/avatar.png" {
t.Fatalf("OIDC profile was not projected: %#v", projected)
}
}
func TestNormalizeOIDCUserInputBoundsProfileClaims(t *testing.T) {
input := normalizeOIDCUserInput(ResolveOrProvisionOIDCUserInput{
DisplayName: " 王小明 ",
Email: " alice@example.test ",
Phone: " +8613800000000 ",
AvatarURL: " https://static.example.test/avatar.png ",
})
if input.DisplayName != "王小明" || input.Email != "alice@example.test" ||
input.Phone != "+8613800000000" || input.AvatarURL != "https://static.example.test/avatar.png" {
t.Fatalf("OIDC profile was not normalized: %#v", input)
}
}
func TestOIDCProjectionKindRequiresExplicitCompatibleContext(t *testing.T) {
applicationID := "11111111-1111-4111-8111-111111111111"
tests := []struct {
+38 -3
View File
@@ -26,6 +26,10 @@ type ResolveOrProvisionOIDCUserInput struct {
ApplicationID string
Subject string
Username string
DisplayName string
Email string
Phone string
AvatarURL string
Roles []string
ContextType string
TenantID string
@@ -122,16 +126,22 @@ func (s *Store) ResolveOrProvisionOIDCUser(ctx context.Context, input ResolveOrP
createdUser, err := scanUser(tx.QueryRow(ctx, `
INSERT INTO gateway_users (
user_key, source, external_user_id, username, gateway_tenant_id, tenant_id, tenant_key,
user_key, source, external_user_id, username, display_name, email, phone, avatar_url,
gateway_tenant_id, tenant_id, tenant_key,
default_user_group_id, roles, auth_profile, metadata, status, last_login_at, synced_at, source_updated_at
)
VALUES ($1, 'oidc', $2, $3, $4::uuid, $5, $6, $7::uuid, $8::jsonb, '{}'::jsonb, $9::jsonb,
VALUES ($1, 'oidc', $2, $3, NULLIF($4, ''), NULLIF($5, ''), NULLIF($6, ''), NULLIF($7, ''),
$8::uuid, $9, $10, $11::uuid, $12::jsonb, '{}'::jsonb, $13::jsonb,
'active', now(), now(), now())
ON CONFLICT DO NOTHING
RETURNING `+userColumns,
userKey,
input.Subject,
username,
input.DisplayName,
input.Email,
input.Phone,
input.AvatarURL,
tenantID,
input.TenantID,
input.GatewayTenantKey,
@@ -244,7 +254,11 @@ func (s *Store) syncExistingOIDCUser(ctx context.Context, tx pgx.Tx, projection
updated, err := scanUser(tx.QueryRow(ctx, `
UPDATE gateway_users
SET username = COALESCE(NULLIF($2, ''), username),
roles = $3::jsonb,
display_name = CASE WHEN metadata->>'manualProfile' = 'true' THEN display_name ELSE COALESCE(NULLIF($3, ''), display_name) END,
email = CASE WHEN metadata->>'manualProfile' = 'true' THEN email ELSE COALESCE(NULLIF($4, ''), email) END,
phone = CASE WHEN metadata->>'manualProfile' = 'true' THEN phone ELSE COALESCE(NULLIF($5, ''), phone) END,
avatar_url = CASE WHEN metadata->>'manualProfile' = 'true' THEN avatar_url ELSE COALESCE(NULLIF($6, ''), avatar_url) END,
roles = $7::jsonb,
last_login_at = now(),
synced_at = now(),
source_updated_at = now(),
@@ -256,6 +270,10 @@ WHERE id = $1::uuid
RETURNING `+userColumns,
projection.user.ID,
input.Username,
input.DisplayName,
input.Email,
input.Phone,
input.AvatarURL,
string(rolesJSON),
))
if err != nil {
@@ -343,6 +361,10 @@ func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProv
input.Issuer = strings.TrimRight(strings.TrimSpace(input.Issuer), "/")
input.Subject = strings.TrimSpace(input.Subject)
input.Username = strings.TrimSpace(input.Username)
input.DisplayName = limitOIDCProfileText(input.DisplayName, 200)
input.Email = limitOIDCProfileText(input.Email, 320)
input.Phone = limitOIDCProfileText(input.Phone, 64)
input.AvatarURL = limitOIDCProfileText(input.AvatarURL, 2048)
input.ContextType = strings.TrimSpace(input.ContextType)
input.TenantID = strings.TrimSpace(input.TenantID)
input.ApplicationID = strings.TrimSpace(input.ApplicationID)
@@ -359,6 +381,15 @@ func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProv
return input
}
func limitOIDCProfileText(value string, limit int) string {
value = strings.TrimSpace(value)
runes := []rune(value)
if len(runes) > limit {
return string(runes[:limit])
}
return value
}
func normalizeOIDCRoles(roles []string) []string {
result := make([]string, 0, len(roles))
seen := make(map[string]struct{}, len(roles))
@@ -390,6 +421,10 @@ func authUserFromOIDCProjection(user GatewayUser, userGroupKey string) *auth.Use
return &auth.User{
ID: user.ExternalUserID,
Username: user.Username,
DisplayName: user.DisplayName,
Email: user.Email,
Phone: user.Phone,
AvatarURL: user.AvatarURL,
Roles: user.Roles,
ContextType: "tenant",
TenantID: user.TenantID,
@@ -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)
}