2026-06-12 19:40:21 +08:00

264 lines
7.4 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"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"hyapp/pkg/xerr"
authdomain "hyapp/services/user-service/internal/domain/auth"
)
const maxHTTPGeoResponseBytes = 64 * 1024
// HTTPIPGeoProviderConfig 描述一个外部 HTTP IP Geo 源URL 和 header 支持用 {ip} 注入入口 IP。
type HTTPIPGeoProviderConfig struct {
URL string
Method string
CountryJSONPath string
Headers map[string]string
HTTPClient *http.Client
}
type edgeCountryProvider struct{}
type staticCountryProvider struct {
name string
countryCode string
}
type httpIPGeoProvider struct {
name string
urlTemplate string
method string
countryJSONPath string
headers map[string]string
client *http.Client
}
type unavailableIPGeoProvider struct {
name string
}
// defaultHTTPIPGeoProviders 固定复用 chatapp3 当前线上使用的四个公开 GeoIP 源;配置只保留顺序,不把 URL 下放到 YAML。
var defaultHTTPIPGeoProviders = map[string]HTTPIPGeoProviderConfig{
"primary_geo": {
URL: "https://api.ip.sb/geoip/{ip}",
Method: http.MethodGet,
CountryJSONPath: "country_code",
},
"fallback_geo_a": {
URL: "https://freeipapi.com/api/json/{ip}",
Method: http.MethodGet,
CountryJSONPath: "countryCode",
},
"fallback_geo_b": {
URL: "https://ipwhois.app/json/{ip}?format=json",
Method: http.MethodGet,
CountryJSONPath: "country_code",
},
"fallback_geo_c": {
URL: "https://ip.nc.gy/json?ip={ip}",
Method: http.MethodGet,
CountryJSONPath: "country.iso_code",
},
}
func BuildIPGeoProviders(names []string) []IPGeoProvider {
return BuildIPGeoProvidersWithHTTP(names, defaultHTTPIPGeoProviders)
}
func BuildIPGeoProvidersWithHTTP(names []string, httpConfigs map[string]HTTPIPGeoProviderConfig) []IPGeoProvider {
providers := make([]IPGeoProvider, 0, len(names))
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
switch {
case strings.EqualFold(name, "edge_country"):
providers = append(providers, edgeCountryProvider{})
case strings.HasPrefix(strings.ToLower(name), "static:"):
country := normalizeCountryCode(strings.TrimPrefix(name, "static:"))
providers = append(providers, staticCountryProvider{name: name, countryCode: country})
case httpConfigs != nil:
if config, ok := httpConfigs[name]; ok {
providers = append(providers, newHTTPIPGeoProvider(name, config))
} else {
providers = append(providers, unavailableIPGeoProvider{name: name})
}
default:
// 未配置具体 provider 适配器时显式降级为 unavailableworker 会按顺序继续尝试下一个。
providers = append(providers, unavailableIPGeoProvider{name: name})
}
}
return providers
}
func newHTTPIPGeoProvider(name string, config HTTPIPGeoProviderConfig) IPGeoProvider {
method := strings.ToUpper(strings.TrimSpace(config.Method))
if method == "" {
method = http.MethodGet
}
client := config.HTTPClient
if client == nil {
client = http.DefaultClient
}
headers := make(map[string]string, len(config.Headers))
for key, value := range config.Headers {
key = strings.TrimSpace(key)
if key == "" {
continue
}
headers[key] = strings.TrimSpace(value)
}
return httpIPGeoProvider{
name: name,
urlTemplate: strings.TrimSpace(config.URL),
method: method,
countryJSONPath: strings.TrimSpace(config.CountryJSONPath),
headers: headers,
client: client,
}
}
func (p edgeCountryProvider) Name() string {
return "edge_country"
}
func (p edgeCountryProvider) ResolveCountry(_ context.Context, job authdomain.LoginIPRiskJob) (IPGeoResult, error) {
country := normalizeCountryCode(job.EdgeCountryCode)
if country == "" {
return IPGeoResult{}, xerr.New(xerr.Unavailable, "edge country is empty")
}
return IPGeoResult{CountryCode: country, Confidence: 60}, nil
}
func (p staticCountryProvider) Name() string {
return p.name
}
func (p staticCountryProvider) ResolveCountry(_ context.Context, _ authdomain.LoginIPRiskJob) (IPGeoResult, error) {
if p.countryCode == "" {
return IPGeoResult{}, xerr.New(xerr.Unavailable, "static country is empty")
}
return IPGeoResult{CountryCode: p.countryCode, Confidence: 100}, nil
}
func (p httpIPGeoProvider) Name() string {
return p.name
}
func (p httpIPGeoProvider) ResolveCountry(ctx context.Context, job authdomain.LoginIPRiskJob) (IPGeoResult, error) {
if strings.TrimSpace(p.urlTemplate) == "" {
return IPGeoResult{}, xerr.New(xerr.Unavailable, "http geo provider url is empty")
}
clientIP := strings.TrimSpace(job.ClientIP)
if clientIP == "" {
return IPGeoResult{}, xerr.New(xerr.Unavailable, "client ip is empty")
}
endpoint := renderIPGeoTemplate(p.urlTemplate, clientIP)
request, err := http.NewRequestWithContext(ctx, p.method, endpoint, nil)
if err != nil {
return IPGeoResult{}, err
}
for key, value := range p.headers {
request.Header.Set(key, renderIPGeoTemplate(value, clientIP))
}
response, err := p.client.Do(request)
if err != nil {
return IPGeoResult{}, err
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return IPGeoResult{}, fmt.Errorf("http geo provider status %d", response.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(response.Body, maxHTTPGeoResponseBytes+1))
if err != nil {
return IPGeoResult{}, err
}
if len(body) > maxHTTPGeoResponseBytes {
return IPGeoResult{}, xerr.New(xerr.Unavailable, "http geo provider response too large")
}
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
return IPGeoResult{}, err
}
country := countryFromHTTPGeoPayload(payload, p.countryJSONPath)
if country == "" {
return IPGeoResult{}, xerr.New(xerr.Unavailable, "http geo provider country is empty")
}
return IPGeoResult{CountryCode: country, Confidence: 80}, nil
}
func renderIPGeoTemplate(template string, clientIP string) string {
return strings.ReplaceAll(template, "{ip}", url.QueryEscape(clientIP))
}
func countryFromHTTPGeoPayload(payload any, configuredPath string) string {
if country := countryAtJSONPath(payload, configuredPath); country != "" {
return country
}
for _, path := range []string{
"country_code",
"countryCode",
"iso_code",
"isoCode",
"country.iso_code",
"country.country_code",
"data.country_code",
"data.countryCode",
"result.country_code",
"result.countryCode",
"registered_country.iso_code",
} {
if country := countryAtJSONPath(payload, path); country != "" {
return country
}
}
if text, ok := payload.(string); ok {
return normalizeCountryCode(text)
}
return ""
}
func countryAtJSONPath(payload any, path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
current := payload
for _, part := range strings.Split(path, ".") {
part = strings.TrimSpace(part)
if part == "" {
return ""
}
object, ok := current.(map[string]any)
if !ok {
return ""
}
next, ok := object[part]
if !ok {
return ""
}
current = next
}
text, ok := current.(string)
if !ok {
return ""
}
return normalizeCountryCode(text)
}
func (p unavailableIPGeoProvider) Name() string {
return p.name
}
func (p unavailableIPGeoProvider) ResolveCountry(_ context.Context, _ authdomain.LoginIPRiskJob) (IPGeoResult, error) {
return IPGeoResult{}, fmt.Errorf("ip geo provider %s is not configured", p.name)
}