package auth import ( "context" "crypto/rsa" "crypto/x509" "encoding/json" "encoding/pem" "fmt" "io" "net/http" "strconv" "strings" "sync" "time" jwt "github.com/golang-jwt/jwt/v5" authdomain "hyapp/services/user-service/internal/domain/auth" ) const ( // defaultFirebaseCertsURL 是 Firebase Secure Token 服务公开 x509 证书端点。 defaultFirebaseCertsURL = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com" // defaultFirebaseCertTTL 是证书响应缺少 max-age 时的保守缓存时间。 defaultFirebaseCertTTL = time.Hour // firebaseClockLeeway 是 Firebase token 时间校验允许的轻微客户端/服务端时钟偏差。 firebaseClockLeeway = 30 * time.Second // maxFirebaseSubjectLength 跟 Firebase uid 约束保持一致,避免异常 sub 进入身份绑定表。 maxFirebaseSubjectLength = 128 ) // FirebaseVerifierConfig 描述 Firebase ID token verifier 的最小运行配置。 type FirebaseVerifierConfig struct { // ProjectID 必须匹配 Firebase ID token 的 aud 和 issuer 尾部项目 ID。 ProjectID string // AllowedSignInProviders 限制 Firebase token 内 firebase.sign_in_provider,首版只允许 google.com。 AllowedSignInProviders []string // CertsURL 允许测试替换 Firebase 公钥端点;生产为空时使用官方默认端点。 CertsURL string // HTTPClient 允许测试注入短超时 client;生产为空时使用默认超时 client。 HTTPClient *http.Client // Now 允许测试固定 verifier 时钟。 Now func() time.Time } // FirebaseThirdPartyVerifier 校验 Firebase Auth ID token 并抽取稳定 Firebase uid。 type FirebaseThirdPartyVerifier struct { // projectID 是 Firebase project id,参与 aud/iss 双重校验。 projectID string // issuer 是 Firebase Secure Token 固定 issuer 前缀加 project id。 issuer string // allowedSignInProviders 是允许进入当前三方登录链路的 Firebase 登录方式。 allowedSignInProviders map[string]bool // certsURL 是 Firebase Secure Token x509 证书端点。 certsURL string // httpClient 拉取 Firebase 证书。 httpClient *http.Client // now 提供可测试时钟。 now func() time.Time // mu 保护证书缓存。 mu sync.Mutex // cachedKeys 保存 kid 到 RSA 公钥的缓存。 cachedKeys map[string]*rsa.PublicKey // cacheExpiresAt 是证书缓存失效时间。 cacheExpiresAt time.Time } // firebaseIDTokenClaims 是 Firebase ID token 中本服务关心的字段集合。 type firebaseIDTokenClaims struct { jwt.RegisteredClaims // AuthTime 是 Firebase 用户最近完成认证的时间,必须存在。 AuthTime *jwt.NumericDate `json:"auth_time,omitempty"` // Firebase 包含 Firebase 专有登录来源信息。 Firebase struct { SignInProvider string `json:"sign_in_provider"` } `json:"firebase"` } // NewFirebaseThirdPartyVerifier 创建 Firebase ID token verifier。 func NewFirebaseThirdPartyVerifier(config FirebaseVerifierConfig) *FirebaseThirdPartyVerifier { projectID := strings.TrimSpace(config.ProjectID) certsURL := strings.TrimSpace(config.CertsURL) if certsURL == "" { certsURL = defaultFirebaseCertsURL } httpClient := config.HTTPClient if httpClient == nil { httpClient = &http.Client{Timeout: 5 * time.Second} } now := config.Now if now == nil { now = time.Now } allowedSignInProviders := normalizeAllowedSignInProviders(config.AllowedSignInProviders) return &FirebaseThirdPartyVerifier{ projectID: projectID, issuer: "https://securetoken.google.com/" + projectID, allowedSignInProviders: allowedSignInProviders, certsURL: certsURL, httpClient: httpClient, now: now, cachedKeys: map[string]*rsa.PublicKey{}, } } // Verify 校验 Firebase ID token,并把 sub/uid 作为 provider_subject 返回。 func (v *FirebaseThirdPartyVerifier) Verify(ctx context.Context, provider string, credential string) (authdomain.ThirdPartyProfile, error) { provider = strings.ToLower(strings.TrimSpace(provider)) credential = strings.TrimSpace(credential) if provider != "firebase" || credential == "" || v == nil || v.projectID == "" { // provider 不匹配、token 为空或 Firebase 项目未配置都统一认证失败,避免泄漏配置状态。 return authdomain.ThirdPartyProfile{}, authFailed() } claims := &firebaseIDTokenClaims{} parser := jwt.NewParser( jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), jwt.WithIssuer(v.issuer), jwt.WithAudience(v.projectID), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithLeeway(firebaseClockLeeway), jwt.WithTimeFunc(v.now), ) token, err := parser.ParseWithClaims(credential, claims, func(token *jwt.Token) (any, error) { kid, _ := token.Header["kid"].(string) return v.publicKey(ctx, kid) }) if err != nil || token == nil || !token.Valid { return authdomain.ThirdPartyProfile{}, authFailed() } subject := strings.TrimSpace(claims.Subject) if subject == "" || len(subject) > maxFirebaseSubjectLength { // Firebase uid/sub 是唯一登录绑定主键,不能为空或异常超长。 return authdomain.ThirdPartyProfile{}, authFailed() } if claims.AuthTime == nil || claims.AuthTime.Time.After(v.now().Add(firebaseClockLeeway)) { // auth_time 缺失或明显来自未来时拒绝,避免接受非 Firebase Auth 形态的 JWT。 return authdomain.ThirdPartyProfile{}, authFailed() } signInProvider := strings.TrimSpace(claims.Firebase.SignInProvider) if !v.allowedSignInProviders[signInProvider] { // 首版只允许 Firebase Google 登录,其他 sign_in_provider 后续按产品确认再开放。 return authdomain.ThirdPartyProfile{}, authFailed() } return authdomain.ThirdPartyProfile{ProviderSubject: subject}, nil } func (v *FirebaseThirdPartyVerifier) publicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { kid = strings.TrimSpace(kid) if kid == "" { return nil, authFailed() } if key, cacheValid := v.cachedPublicKey(kid); key != nil { return key, nil } else if cacheValid { // Firebase 证书缓存仍有效但 kid 不存在时直接拒绝,避免随机 kid 伪造 token 放大到外部证书端点。 return nil, authFailed() } keys, expiresAt, err := v.fetchPublicKeys(ctx) if err != nil { return nil, err } v.mu.Lock() v.cachedKeys = keys v.cacheExpiresAt = expiresAt key, ok := v.cachedKeys[kid] v.mu.Unlock() if !ok { // 未知 kid 说明 token 不是当前 Firebase Secure Token 证书签发。 return nil, authFailed() } return key, nil } func (v *FirebaseThirdPartyVerifier) cachedPublicKey(kid string) (*rsa.PublicKey, bool) { v.mu.Lock() defer v.mu.Unlock() if v.cacheExpiresAt.IsZero() || !v.now().Before(v.cacheExpiresAt) { // 缓存未初始化或已过期时允许调用方刷新;未知 kid 只有在有效缓存窗口内才被本地拒绝。 return nil, false } key := v.cachedKeys[kid] return key, true } func (v *FirebaseThirdPartyVerifier) fetchPublicKeys(ctx context.Context) (map[string]*rsa.PublicKey, time.Time, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, v.certsURL, nil) if err != nil { return nil, time.Time{}, authFailed() } response, err := v.httpClient.Do(request) if err != nil { return nil, time.Time{}, authFailed() } defer response.Body.Close() if response.StatusCode != http.StatusOK { return nil, time.Time{}, authFailed() } var certs map[string]string if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&certs); err != nil { return nil, time.Time{}, authFailed() } keys := make(map[string]*rsa.PublicKey, len(certs)) for kid, encodedCert := range certs { key, err := parseFirebaseCertPublicKey(encodedCert) if err != nil { return nil, time.Time{}, authFailed() } keys[strings.TrimSpace(kid)] = key } if len(keys) == 0 { return nil, time.Time{}, authFailed() } return keys, firebaseCacheExpiry(v.now, response.Header.Get("Cache-Control")), nil } func parseFirebaseCertPublicKey(encodedCert string) (*rsa.PublicKey, error) { block, _ := pem.Decode([]byte(encodedCert)) if block == nil { return nil, fmt.Errorf("firebase cert pem decode failed") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return nil, err } key, ok := cert.PublicKey.(*rsa.PublicKey) if !ok { return nil, fmt.Errorf("firebase cert public key is not rsa") } return key, nil } func firebaseCacheExpiry(now func() time.Time, cacheControl string) time.Time { for part := range strings.SplitSeq(cacheControl, ",") { part = strings.TrimSpace(part) if !strings.HasPrefix(strings.ToLower(part), "max-age=") { continue } seconds, err := strconv.ParseInt(strings.TrimPrefix(part, "max-age="), 10, 64) if err == nil && seconds > 0 { return now().Add(time.Duration(seconds) * time.Second) } } return now().Add(defaultFirebaseCertTTL) } func normalizeAllowedSignInProviders(providers []string) map[string]bool { if len(providers) == 0 { // 首版 Firebase 只开放 Google 登录。 providers = []string{"google.com"} } allowed := make(map[string]bool, len(providers)) for _, provider := range providers { provider = strings.TrimSpace(provider) if provider != "" { allowed[provider] = true } } return allowed } // RoutedThirdPartyVerifier 把 firebase provider 路由到 Firebase verifier,其余 provider 交给本地/mock verifier。 type RoutedThirdPartyVerifier struct { firebase ThirdPartyVerifier fallback ThirdPartyVerifier } // NewRoutedThirdPartyVerifier 创建按 provider 分发的三方 verifier。 func NewRoutedThirdPartyVerifier(firebase ThirdPartyVerifier, fallback ThirdPartyVerifier) *RoutedThirdPartyVerifier { return &RoutedThirdPartyVerifier{ firebase: firebase, fallback: fallback, } } // Verify 保证 provider=firebase 永远不会落到静态 verifier。 func (v *RoutedThirdPartyVerifier) Verify(ctx context.Context, provider string, credential string) (authdomain.ThirdPartyProfile, error) { provider = strings.ToLower(strings.TrimSpace(provider)) if provider == "firebase" { if v == nil || v.firebase == nil { return authdomain.ThirdPartyProfile{}, authFailed() } return v.firebase.Verify(ctx, provider, credential) } if v == nil || v.fallback == nil { return authdomain.ThirdPartyProfile{}, authFailed() } return v.fallback.Verify(ctx, provider, credential) }