264 lines
7.4 KiB
Go
264 lines
7.4 KiB
Go
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 适配器时显式降级为 unavailable,worker 会按顺序继续尝试下一个。
|
||
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)
|
||
}
|