303 lines
10 KiB
Go
303 lines
10 KiB
Go
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)
|
||
}
|