修复多app登录配置
This commit is contained in:
parent
395470f3ba
commit
1ac0cecf63
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
47
services/user-service/internal/config/firebase_test.go
Normal file
47
services/user-service/internal/config/firebase_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user