diff --git a/services/room-service/internal/room/service/pin_test.go b/services/room-service/internal/room/service/pin_test.go index 696830ca..ef761441 100644 --- a/services/room-service/internal/room/service/pin_test.go +++ b/services/room-service/internal/room/service/pin_test.go @@ -155,6 +155,47 @@ func TestPinnedRoomListOrdersRegionCountryThenCountryBucketsForNew(t *testing.T) } } +func TestCountryPinOnlyAppliesToSelectedCountryTab(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + svc := roomservice.New(roomservice.Config{ + NodeID: "node-pin-country-tab-test", + LeaseTTL: 10 * time.Second, + RankLimit: 20, + SnapshotEveryN: 1, + }, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) + + createPinnedListRoom(t, ctx, svc, "room-us-country-pin", "us-country-pin", 3251, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-us-hot", "us-hot", 3252, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-ae-country-pin", "ae-country-pin", 3253, 9001, "AE") + createPinnedListRoom(t, ctx, svc, "room-ae-hot", "ae-hot", 3254, 9001, "AE") + repository.SetRoomSortScore("room-us-country-pin", 1) + repository.SetRoomSortScore("room-us-hot", 10000) + repository.SetRoomSortScore("room-ae-country-pin", 2) + repository.SetRoomSortScore("room-ae-hot", 20000) + repository.SetRoomPresence("room-us-hot", 1, 0) + repository.SetRoomPresence("room-ae-hot", 1, 0) + expiresAtMS := time.Now().UTC().Add(24 * time.Hour).UnixMilli() + repository.PinRoomWithType("room-us-country-pin", "country", 9001, 99, expiresAtMS) + repository.PinRoomWithType("room-ae-country-pin", "country", 9001, 88, expiresAtMS) + + page, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ + Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, + ViewerUserId: 4001, + VisibleRegionId: 9001, + ViewerCountryCode: "US", + CountryCode: "AE", + Tab: "hot", + Limit: 4, + }) + if err != nil { + t.Fatalf("list AE country tab failed: %v", err) + } + if got := roomIDs(page.GetRooms()); len(got) != 2 || got[0] != "room-ae-country-pin" || got[1] != "room-ae-hot" { + t.Fatalf("AE country tab must only apply AE country pin, got %+v", got) + } +} + func TestRoomListFiltersByOwnerCountry(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) diff --git a/services/user-service/configs/config.docker.yaml b/services/user-service/configs/config.docker.yaml index 0449e343..1e429057 100644 --- a/services/user-service/configs/config.docker.yaml +++ b/services/user-service/configs/config.docker.yaml @@ -23,6 +23,11 @@ third_party: project_id: "lalu-party" allowed_sign_in_providers: - "google.com" + projects: + lalu: + project_id: "lalu-party" + huwaa: + project_id: "huwaa-6f3aa" invite_recharge_worker: enabled: true mic_time_worker: diff --git a/services/user-service/configs/config.tencent.example.yaml b/services/user-service/configs/config.tencent.example.yaml index 37a59282..b4c6946f 100644 --- a/services/user-service/configs/config.tencent.example.yaml +++ b/services/user-service/configs/config.tencent.example.yaml @@ -23,6 +23,11 @@ third_party: project_id: "lalu-party" allowed_sign_in_providers: - "google.com" + projects: + lalu: + project_id: "lalu-party" + huwaa: + project_id: "huwaa-6f3aa" invite_recharge_worker: enabled: true mic_time_worker: diff --git a/services/user-service/configs/config.yaml b/services/user-service/configs/config.yaml index 10c4d747..68261c82 100644 --- a/services/user-service/configs/config.yaml +++ b/services/user-service/configs/config.yaml @@ -23,6 +23,11 @@ third_party: project_id: "lalu-party" allowed_sign_in_providers: - "google.com" + projects: + lalu: + project_id: "lalu-party" + huwaa: + project_id: "huwaa-6f3aa" invite_recharge_worker: enabled: true mic_time_worker: diff --git a/services/user-service/internal/app/app.go b/services/user-service/internal/app/app.go index 265ccc1b..38e664f5 100644 --- a/services/user-service/internal/app/app.go +++ b/services/user-service/internal/app/app.go @@ -114,10 +114,13 @@ func New(cfg config.Config) (*App, error) { server := servicegrpc.New("user-service") thirdPartyVerifier := authservice.NewRoutedThirdPartyVerifier( - authservice.NewFirebaseThirdPartyVerifier(authservice.FirebaseVerifierConfig{ - ProjectID: cfg.ThirdParty.Firebase.ProjectID, - AllowedSignInProviders: cfg.ThirdParty.Firebase.AllowedSignInProviders, - CertsURL: cfg.ThirdParty.Firebase.CertsURL, + authservice.NewAppFirebaseThirdPartyVerifier(authservice.AppFirebaseVerifierConfig{ + Default: authservice.FirebaseVerifierConfig{ + ProjectID: cfg.ThirdParty.Firebase.ProjectID, + AllowedSignInProviders: cfg.ThirdParty.Firebase.AllowedSignInProviders, + CertsURL: cfg.ThirdParty.Firebase.CertsURL, + }, + Projects: firebaseVerifierProjects(cfg.ThirdParty.Firebase), }), authservice.NewStaticThirdPartyVerifier(cfg.ThirdParty.AllowedProviders), ) @@ -598,6 +601,19 @@ func (a *App) closeHealthHTTP() { _ = a.healthHTTP.Close(ctx) } +func firebaseVerifierProjects(cfg config.FirebaseConfig) map[string]authservice.FirebaseVerifierConfig { + projects := make(map[string]authservice.FirebaseVerifierConfig, len(cfg.Projects)) + for appCode, project := range cfg.Projects { + // app_code 已在配置加载阶段归一化;这里仍保留 Normalize,避免测试或手动构造配置绕过 Load。 + projects[appcode.Normalize(appCode)] = authservice.FirebaseVerifierConfig{ + ProjectID: project.ProjectID, + AllowedSignInProviders: project.AllowedSignInProviders, + CertsURL: project.CertsURL, + } + } + return projects +} + type tencentIMAccountImporter struct { client *tencentim.RESTClient } diff --git a/services/user-service/internal/config/config.go b/services/user-service/internal/config/config.go index 6b0f5698..f63c6668 100644 --- a/services/user-service/internal/config/config.go +++ b/services/user-service/internal/config/config.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "hyapp/pkg/appcode" "hyapp/pkg/configx" "hyapp/pkg/logx" "hyapp/pkg/tencentim" @@ -159,6 +160,18 @@ type FirebaseConfig struct { AllowedSignInProviders []string `yaml:"allowed_sign_in_providers"` // CertsURL 只用于测试或私有代理;生产为空时使用 Firebase 官方公钥端点。 CertsURL string `yaml:"certs_url"` + // Projects 按 app_code 配置 Firebase 项目;Huwaa/Lalu 必须各自校验自己的 aud/iss。 + Projects map[string]FirebaseProjectConfig `yaml:"projects"` +} + +// FirebaseProjectConfig 保存单个 App 的 Firebase 项目配置。 +type FirebaseProjectConfig struct { + // ProjectID 必须匹配该 App Firebase token 的 aud 和 issuer。 + ProjectID string `yaml:"project_id"` + // AllowedSignInProviders 为空时继承 firebase.allowed_sign_in_providers。 + AllowedSignInProviders []string `yaml:"allowed_sign_in_providers"` + // CertsURL 为空时继承 firebase.certs_url,生产通常不需要配置。 + CertsURL string `yaml:"certs_url"` } // InviteRechargeWorkerConfig 保存钱包充值事实消费策略。 @@ -378,6 +391,8 @@ func Load(path string) (Config, error) { if cfg.CPIntimacyLeaderboard.KeyPrefix == "" { cfg.CPIntimacyLeaderboard.KeyPrefix = "user:cp_intimacy_leaderboard" } + cfg.ThirdParty.AllowedProviders = normalizeStringSlice(cfg.ThirdParty.AllowedProviders) + cfg.ThirdParty.Firebase = normalizeFirebaseConfig(cfg.ThirdParty.Firebase) cfg.RoomService.Addr = strings.TrimSpace(cfg.RoomService.Addr) if cfg.RoomService.Addr == "" { cfg.RoomService.Addr = "127.0.0.1:13001" @@ -531,6 +546,34 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) { return cfg, nil } +func normalizeFirebaseConfig(cfg FirebaseConfig) FirebaseConfig { + cfg.ProjectID = strings.TrimSpace(cfg.ProjectID) + cfg.AllowedSignInProviders = normalizeStringSlice(cfg.AllowedSignInProviders) + cfg.CertsURL = strings.TrimSpace(cfg.CertsURL) + if len(cfg.Projects) == 0 { + return cfg + } + + projects := make(map[string]FirebaseProjectConfig, len(cfg.Projects)) + for rawAppCode, project := range cfg.Projects { + app := appcode.Normalize(rawAppCode) + project.ProjectID = strings.TrimSpace(project.ProjectID) + project.CertsURL = strings.TrimSpace(project.CertsURL) + project.AllowedSignInProviders = normalizeStringSlice(project.AllowedSignInProviders) + if len(project.AllowedSignInProviders) == 0 { + // 单 App 没写登录方式时继承全局配置;全局为空时 auth verifier 仍会默认 google.com。 + project.AllowedSignInProviders = cfg.AllowedSignInProviders + } + if project.CertsURL == "" { + // Firebase 公钥端点对所有项目相同;测试代理或私有代理可以只写一次。 + project.CertsURL = cfg.CertsURL + } + projects[app] = project + } + cfg.Projects = projects + return cfg +} + func normalizeStringSlice(values []string) []string { normalized := make([]string, 0, len(values)) for _, value := range values { diff --git a/services/user-service/internal/config/firebase_test.go b/services/user-service/internal/config/firebase_test.go new file mode 100644 index 00000000..e741093e --- /dev/null +++ b/services/user-service/internal/config/firebase_test.go @@ -0,0 +1,47 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadNormalizesFirebaseProjects(t *testing.T) { + // 多 App Firebase 配置必须在加载阶段归一化,避免线上 YAML 的空格或大小写让 app 路由失效。 + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(` +third_party: + allowed_providers: + - " firebase " + firebase: + project_id: " lalu-party " + allowed_sign_in_providers: + - " google.com " + certs_url: " https://certs.example.test " + projects: + " Huwaa ": + project_id: " huwaa-6f3aa " + lalu: + project_id: " lalu-party " +`), 0o600); err != nil { + t.Fatalf("write config failed: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + huwaa, ok := cfg.ThirdParty.Firebase.Projects["huwaa"] + if !ok { + t.Fatalf("huwaa Firebase project must be keyed by normalized app_code: %+v", cfg.ThirdParty.Firebase.Projects) + } + if huwaa.ProjectID != "huwaa-6f3aa" { + t.Fatalf("huwaa project_id mismatch: %+v", huwaa) + } + if len(huwaa.AllowedSignInProviders) != 1 || huwaa.AllowedSignInProviders[0] != "google.com" { + t.Fatalf("huwaa sign-in providers should inherit normalized global allowlist: %+v", huwaa.AllowedSignInProviders) + } + if huwaa.CertsURL != "https://certs.example.test" { + t.Fatalf("huwaa certs_url should inherit global certs_url: %q", huwaa.CertsURL) + } +} diff --git a/services/user-service/internal/service/auth/firebase_verifier.go b/services/user-service/internal/service/auth/firebase_verifier.go index c7c44817..10be3a96 100644 --- a/services/user-service/internal/service/auth/firebase_verifier.go +++ b/services/user-service/internal/service/auth/firebase_verifier.go @@ -15,6 +15,7 @@ import ( "time" jwt "github.com/golang-jwt/jwt/v5" + "hyapp/pkg/appcode" authdomain "hyapp/services/user-service/internal/domain/auth" ) @@ -43,6 +44,14 @@ type FirebaseVerifierConfig struct { Now func() time.Time } +// AppFirebaseVerifierConfig 保存多 App Firebase 项目的路由配置。 +type AppFirebaseVerifierConfig struct { + // Default 是旧版单 project_id 配置的兼容入口;未配置 app 专属项目时才使用。 + Default FirebaseVerifierConfig + // Projects 按 app_code 持有 Firebase verifier 配置,避免 Huwaa token 被 Lalu 项目规则误拒。 + Projects map[string]FirebaseVerifierConfig +} + // FirebaseThirdPartyVerifier 校验 Firebase Auth ID token 并抽取稳定 Firebase uid。 type FirebaseThirdPartyVerifier struct { // projectID 是 Firebase project id,参与 aud/iss 双重校验。 @@ -65,6 +74,14 @@ type FirebaseThirdPartyVerifier struct { cacheExpiresAt time.Time } +// AppFirebaseThirdPartyVerifier 按当前请求的 app_code 选择 Firebase 项目。 +type AppFirebaseThirdPartyVerifier struct { + // defaultVerifier 兼容历史单项目部署;有明确 app 项目配置时不会走到这里。 + defaultVerifier *FirebaseThirdPartyVerifier + // verifiers 用归一化 app_code 路由到各自 Firebase verifier。 + verifiers map[string]*FirebaseThirdPartyVerifier +} + // firebaseIDTokenClaims 是 Firebase ID token 中本服务关心的字段集合。 type firebaseIDTokenClaims struct { jwt.RegisteredClaims @@ -76,6 +93,65 @@ type firebaseIDTokenClaims struct { } `json:"firebase"` } +// NewAppFirebaseThirdPartyVerifier 创建按 App 隔离的 Firebase verifier。 +func NewAppFirebaseThirdPartyVerifier(config AppFirebaseVerifierConfig) *AppFirebaseThirdPartyVerifier { + var defaultVerifier *FirebaseThirdPartyVerifier + if strings.TrimSpace(config.Default.ProjectID) != "" { + // 旧配置只有一个 project_id 时仍可启动;新 App 应优先写入 Projects 显式隔离。 + defaultVerifier = NewFirebaseThirdPartyVerifier(config.Default) + } + + verifiers := make(map[string]*FirebaseThirdPartyVerifier, len(config.Projects)) + for rawAppCode, projectConfig := range config.Projects { + app := appcode.Normalize(rawAppCode) + projectConfig = inheritFirebaseVerifierConfig(config.Default, projectConfig) + if strings.TrimSpace(projectConfig.ProjectID) == "" { + // 空 project_id 不能生成有效 aud/iss 校验器;保留 fail-closed 行为。 + continue + } + verifiers[app] = NewFirebaseThirdPartyVerifier(projectConfig) + } + + return &AppFirebaseThirdPartyVerifier{ + defaultVerifier: defaultVerifier, + verifiers: verifiers, + } +} + +// Verify 根据 context 中的 app_code 校验对应 Firebase ID token。 +func (v *AppFirebaseThirdPartyVerifier) Verify(ctx context.Context, provider string, credential string) (authdomain.ThirdPartyProfile, error) { + if v == nil { + return authdomain.ThirdPartyProfile{}, authFailed() + } + if verifier := v.verifiers[appcode.FromContext(ctx)]; verifier != nil { + // app 专属配置是强隔离边界;Huwaa token 只能命中 Huwaa project_id。 + return verifier.Verify(ctx, provider, credential) + } + if v.defaultVerifier != nil { + // 兼容旧部署的单 project_id 配置,避免只配置 Lalu 的环境立即无法登录。 + return v.defaultVerifier.Verify(ctx, provider, credential) + } + + return authdomain.ThirdPartyProfile{}, authFailed() +} + +func inheritFirebaseVerifierConfig(defaultConfig FirebaseVerifierConfig, projectConfig FirebaseVerifierConfig) FirebaseVerifierConfig { + if len(projectConfig.AllowedSignInProviders) == 0 { + // 登录方式默认继承全局 allowlist,减少每个 App 重复写 google.com 的配置噪音。 + projectConfig.AllowedSignInProviders = defaultConfig.AllowedSignInProviders + } + if strings.TrimSpace(projectConfig.CertsURL) == "" { + projectConfig.CertsURL = defaultConfig.CertsURL + } + if projectConfig.HTTPClient == nil { + projectConfig.HTTPClient = defaultConfig.HTTPClient + } + if projectConfig.Now == nil { + projectConfig.Now = defaultConfig.Now + } + return projectConfig +} + // NewFirebaseThirdPartyVerifier 创建 Firebase ID token verifier。 func NewFirebaseThirdPartyVerifier(config FirebaseVerifierConfig) *FirebaseThirdPartyVerifier { projectID := strings.TrimSpace(config.ProjectID) diff --git a/services/user-service/internal/service/auth/firebase_verifier_test.go b/services/user-service/internal/service/auth/firebase_verifier_test.go index 517959d8..e0f39cab 100644 --- a/services/user-service/internal/service/auth/firebase_verifier_test.go +++ b/services/user-service/internal/service/auth/firebase_verifier_test.go @@ -16,6 +16,7 @@ import ( "time" jwt "github.com/golang-jwt/jwt/v5" + "hyapp/pkg/appcode" "hyapp/pkg/xerr" authservice "hyapp/services/user-service/internal/service/auth" ) @@ -50,6 +51,45 @@ func TestFirebaseThirdPartyVerifierAcceptsGoogleFirebaseIDToken(t *testing.T) { } } +func TestAppFirebaseThirdPartyVerifierRoutesByAppCode(t *testing.T) { + // Firebase ID token 的 aud/iss 必须跟当前 app_code 对应;Huwaa 不能再被 Lalu project_id 误拒。 + now := time.Unix(2000, 0) + privateKey, certPEM := firebaseTestKeyPair(t, now) + certsServer := firebaseCertsServer(t, "kid-1", certPEM) + verifier := authservice.NewAppFirebaseThirdPartyVerifier(authservice.AppFirebaseVerifierConfig{ + Default: authservice.FirebaseVerifierConfig{ + ProjectID: "lalu-party", + AllowedSignInProviders: []string{"google.com"}, + CertsURL: certsServer.URL, + Now: func() time.Time { return now }, + }, + Projects: map[string]authservice.FirebaseVerifierConfig{ + "lalu": { + ProjectID: "lalu-party", + }, + "huwaa": { + ProjectID: "huwaa-6f3aa", + }, + }, + }) + huwaaToken := signFirebaseProjectToken(t, privateKey, "kid-1", "huwaa-6f3aa", "huwaa-firebase-uid", now) + laluToken := signFirebaseProjectToken(t, privateKey, "kid-1", "lalu-party", "lalu-firebase-uid", now) + + profile, err := verifier.Verify(appcode.WithContext(context.Background(), "huwaa"), "firebase", huwaaToken) + if err != nil { + t.Fatalf("huwaa token should verify against huwaa project: %v", err) + } + if profile.ProviderSubject != "huwaa-firebase-uid" { + t.Fatalf("huwaa provider subject mismatch: %+v", profile) + } + if _, err := verifier.Verify(appcode.WithContext(context.Background(), "huwaa"), "firebase", laluToken); !xerr.IsCode(err, xerr.AuthFailed) { + t.Fatalf("huwaa app must reject lalu Firebase token, got %v", err) + } + if _, err := verifier.Verify(appcode.WithContext(context.Background(), "lalu"), "firebase", huwaaToken); !xerr.IsCode(err, xerr.AuthFailed) { + t.Fatalf("lalu app must reject huwaa Firebase token, got %v", err) + } +} + func TestFirebaseThirdPartyVerifierRejectsInvalidClaims(t *testing.T) { // AUTH_FAILED 分支必须覆盖 provider、audience、登录方式和过期时间,避免身份枚举和配置泄漏。 now := time.Unix(2000, 0) @@ -221,6 +261,19 @@ func signFirebaseTestToken(t *testing.T, privateKey *rsa.PrivateKey, kid string, return signed } +func signFirebaseProjectToken(t *testing.T, privateKey *rsa.PrivateKey, kid string, projectID string, subject string, now time.Time) string { + t.Helper() + return signFirebaseTestToken(t, privateKey, kid, map[string]any{ + "iss": "https://securetoken.google.com/" + projectID, + "aud": projectID, + "sub": subject, + "iat": now.Unix(), + "exp": now.Add(time.Hour).Unix(), + "auth_time": now.Add(-time.Minute).Unix(), + "firebase": map[string]any{"sign_in_provider": "google.com"}, + }) +} + func cloneFirebaseClaims(input map[string]any) map[string]any { result := make(map[string]any, len(input)) maps.Copy(result, input)