283 lines
8.5 KiB
Go
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
|
|
}
|