修复多app登录配置

This commit is contained in:
zhx 2026-06-23 19:52:14 +08:00
parent 395470f3ba
commit 1ac0cecf63
9 changed files with 295 additions and 4 deletions

View File

@ -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)

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)