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 revokedErr error } 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() if c.revokedErr != nil { return false, c.revokedErr } 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 h.loginRiskIPWhitelisted(request, input) { 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) loginRiskIPWhitelisted(request *http.Request, input loginRiskInput) bool { if h.userClient == nil { return false } ip := strings.TrimSpace(clientIP(request)) if ip == "" { return false } ctx, cancel := context.WithTimeout(request.Context(), 500*time.Millisecond) defer cancel() resp, err := h.userClient.CheckLoginRiskIPWhitelist(ctx, &userv1.CheckLoginRiskIPWhitelistRequest{ Meta: authRequestMetaWithLoginContext(request, "", input.Platform, input.Language, input.Timezone, "", ""), ClientIp: ip, }) if err != nil { // 白名单读取失败不能制造额外放行;继续执行原地区屏蔽链路,并保留日志用于排查 Redis/DB 回源异常。 logx.Warn(request.Context(), "login_ip_whitelist_check_failed", slog.String("component", "gateway_login_risk"), slog.String("client_ip_hash", hashLoginRiskIP(ip)), slog.String("error", err.Error())) return false } return resp.GetWhitelisted() } 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 }