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

283 lines
8.5 KiB
Go

package http
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"log/slog"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
)
const (
loginRiskBlockLanguage = "language"
loginRiskBlockTimezone = "timezone"
loginRiskBlockIPCache = "ip_cache"
)
var languageTagPattern = regexp.MustCompile(`^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$`)
// LoginRiskConfig 描述 gateway 登录入口快拦策略。
type LoginRiskConfig struct {
Enabled bool
KeyPrefix string
FastGuard LoginRiskFastGuardConfig
}
// LoginRiskFastGuardConfig 保存弱信号阻断列表;命中才拒绝,缺失或非法只审计不推断。
type LoginRiskFastGuardConfig struct {
BlockedLanguages []string
BlockedLanguagePrimaryTags []string
BlockedTimezones []string
BlockedTimezonePrefixes []string
}
type loginRiskInput struct {
Channel string
Platform string
Language string
Timezone string
}
type loginRiskDecision struct {
Decision string `json:"decision"`
Country string `json:"country"`
Source string `json:"source"`
CheckedAtMs int64 `json:"checked_at_ms"`
ExpiresAtMs int64 `json:"expires_at_ms"`
}
type loginRiskCache interface {
GetIPDecision(ctx context.Context, appCode string, clientIPHash string) (loginRiskDecision, bool, error)
RevokedSessionExists(ctx context.Context, appCode string, sessionID string) (bool, error)
}
type redisLoginRiskCache struct {
client *redis.Client
keyPrefix string
}
type memoryLoginRiskCache struct {
mu sync.Mutex
ip map[string]loginRiskDecision
revoked map[string]bool
}
func defaultLoginRiskConfig() LoginRiskConfig {
return LoginRiskConfig{
Enabled: true,
KeyPrefix: "login_risk:ip:",
}
}
func normalizeLoginRiskConfig(config LoginRiskConfig) LoginRiskConfig {
defaults := defaultLoginRiskConfig()
if strings.TrimSpace(config.KeyPrefix) == "" {
config.KeyPrefix = defaults.KeyPrefix
}
return config
}
func NewLoginRiskRedisClient(ctx context.Context, addr string, password string, db int) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: strings.TrimSpace(addr),
Password: password,
DB: db,
})
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return nil, err
}
return client, nil
}
func NewRedisLoginRiskCache(client *redis.Client, config LoginRiskConfig) loginRiskCache {
config = normalizeLoginRiskConfig(config)
return &redisLoginRiskCache{client: client, keyPrefix: config.KeyPrefix}
}
func newMemoryLoginRiskCache() *memoryLoginRiskCache {
return &memoryLoginRiskCache{
ip: make(map[string]loginRiskDecision),
revoked: make(map[string]bool),
}
}
func (c *redisLoginRiskCache) GetIPDecision(ctx context.Context, appCode string, clientIPHash string) (loginRiskDecision, bool, error) {
raw, err := c.client.Get(ctx, ipDecisionKey(c.keyPrefix, appCode, clientIPHash)).Result()
if errors.Is(err, redis.Nil) {
return loginRiskDecision{}, false, nil
}
if err != nil {
return loginRiskDecision{}, false, err
}
var decision loginRiskDecision
if err := json.Unmarshal([]byte(raw), &decision); err != nil {
return loginRiskDecision{}, false, err
}
return decision, true, nil
}
func (c *redisLoginRiskCache) RevokedSessionExists(ctx context.Context, appCode string, sessionID string) (bool, error) {
result, err := c.client.Exists(ctx, revokedSessionKey(appCode, sessionID)).Result()
if err != nil {
return false, err
}
return result > 0, nil
}
func (c *memoryLoginRiskCache) GetIPDecision(_ context.Context, appCode string, clientIPHash string) (loginRiskDecision, bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
decision, ok := c.ip[ipDecisionKey(defaultLoginRiskConfig().KeyPrefix, appCode, clientIPHash)]
return decision, ok, nil
}
func (c *memoryLoginRiskCache) RevokedSessionExists(_ context.Context, appCode string, sessionID string) (bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.revoked[revokedSessionKey(appCode, sessionID)], nil
}
func (c *memoryLoginRiskCache) setIPDecision(appCode string, clientIP string, decision loginRiskDecision) {
c.mu.Lock()
defer c.mu.Unlock()
c.ip[ipDecisionKey(defaultLoginRiskConfig().KeyPrefix, appCode, hashLoginRiskIP(clientIP))] = decision
}
func (c *memoryLoginRiskCache) setRevokedSession(appCode string, sessionID string) {
c.mu.Lock()
defer c.mu.Unlock()
c.revoked[revokedSessionKey(appCode, sessionID)] = true
}
func ipDecisionKey(prefix string, appCode string, clientIPHash string) string {
return strings.TrimSpace(prefix) + appcode.Normalize(appCode) + ":" + strings.TrimSpace(clientIPHash)
}
func revokedSessionKey(appCode string, sessionID string) string {
return "auth:revoked_session:" + appcode.Normalize(appCode) + ":" + strings.TrimSpace(sessionID)
}
func hashLoginRiskIP(ip string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(ip)))
return hex.EncodeToString(sum[:])
}
func (h *Handler) SetLoginRisk(config LoginRiskConfig) {
h.loginRiskConfig = normalizeLoginRiskConfig(config)
}
func (h *Handler) SetLoginRiskCache(config LoginRiskConfig, cache loginRiskCache) {
h.loginRiskConfig = normalizeLoginRiskConfig(config)
h.loginRiskCache = cache
}
func (h *Handler) allowLoginRisk(writer http.ResponseWriter, request *http.Request, input loginRiskInput) bool {
config := normalizeLoginRiskConfig(h.loginRiskConfig)
if !config.Enabled {
return true
}
if reason := config.FastGuard.blockReason(input.Language, input.Timezone); reason != "" {
h.recordLoginBlocked(request, input, reason)
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodeAuthLoginBlocked, "login blocked")
return false
}
if h.loginRiskCache == nil {
return true
}
clientIPHash := hashLoginRiskIP(clientIP(request))
decision, ok, err := h.loginRiskCache.GetIPDecision(request.Context(), appcode.FromContext(request.Context()), clientIPHash)
if err != nil {
logx.Warn(request.Context(), "login_ip_decision_cache_read_failed", slog.String("component", "gateway_login_risk"), slog.String("client_ip_hash", clientIPHash), slog.String("error", err.Error()))
return true
}
if ok && strings.EqualFold(decision.Decision, "blocked") {
h.recordLoginBlocked(request, input, loginRiskBlockIPCache)
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodeAuthLoginBlocked, "login blocked")
return false
}
return true
}
func (h *Handler) recordLoginBlocked(request *http.Request, input loginRiskInput, reason string) {
if h.userClient == nil {
return
}
ctx, cancel := context.WithTimeout(request.Context(), 500*time.Millisecond)
defer cancel()
_, err := h.userClient.RecordLoginBlocked(ctx, &userv1.RecordLoginBlockedRequest{
Meta: authRequestMetaWithLoginContext(request, "", input.Platform, input.Language, input.Timezone),
Channel: input.Channel,
Platform: input.Platform,
Language: input.Language,
Timezone: input.Timezone,
BlockReason: reason,
})
if err != nil {
logx.Warn(request.Context(), "login_fast_guard_audit_failed", slog.String("component", "gateway_login_risk"), slog.String("block_reason", reason), slog.String("error", err.Error()))
}
}
func (g LoginRiskFastGuardConfig) blockReason(language string, timezone string) string {
if blockedLanguage(language, g.BlockedLanguages, g.BlockedLanguagePrimaryTags) {
return loginRiskBlockLanguage
}
if blockedTimezone(timezone, g.BlockedTimezones, g.BlockedTimezonePrefixes) {
return loginRiskBlockTimezone
}
return ""
}
func blockedLanguage(language string, exact []string, primaryTags []string) bool {
language = strings.TrimSpace(language)
if language == "" || !languageTagPattern.MatchString(language) {
return false
}
for _, blocked := range exact {
if strings.EqualFold(strings.TrimSpace(blocked), language) {
return true
}
}
primary := strings.ToLower(strings.Split(language, "-")[0])
for _, blocked := range primaryTags {
if primary == strings.ToLower(strings.TrimSpace(blocked)) {
return true
}
}
return false
}
func blockedTimezone(timezoneName string, exact []string, prefixes []string) bool {
timezoneName = strings.TrimSpace(timezoneName)
if timezoneName == "" {
return false
}
if _, err := time.LoadLocation(timezoneName); err != nil {
return false
}
for _, blocked := range exact {
if strings.EqualFold(strings.TrimSpace(blocked), timezoneName) {
return true
}
}
for _, prefix := range prefixes {
if strings.HasPrefix(timezoneName, strings.TrimSpace(prefix)) {
return true
}
}
return false
}