2026-05-12 09:53:20 +08:00

303 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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