2026-06-23 12:11:05 +08:00

291 lines
12 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"
"log/slog"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/logx"
authdomain "hyapp/services/user-service/internal/domain/auth"
)
const revokeReasonGeoPolicyBlocked = "GEO_POLICY_BLOCKED"
// LoginIPRiskWorkerOptions 控制 cron-service 触发的单批 IP 风控处理边界。
type LoginIPRiskWorkerOptions struct {
WorkerID string
LockTTL time.Duration
BatchSize int
}
type providerAttempt struct {
Provider string `json:"provider"`
Order int `json:"order"`
Country string `json:"country"`
Status string `json:"status"`
DurationMs int64 `json:"duration_ms"`
ErrorCode string `json:"error_code"`
}
func (s *Service) ProcessLoginIPRiskBatch(ctx context.Context, options LoginIPRiskWorkerOptions) (int, error) {
if !s.loginRiskPolicy.Enabled || s.authRepository == nil {
return 0, nil
}
options = normalizeLoginIPRiskWorkerOptions(options)
nowMs := s.now().UnixMilli()
jobs, err := s.authRepository.ClaimLoginIPRiskJobs(ctx, options.WorkerID, nowMs, options.LockTTL, options.BatchSize)
if err != nil {
return 0, err
}
for _, job := range jobs {
jobCtx := appcode.WithContext(ctx, job.AppCode)
if err := s.processLoginIPRiskJob(jobCtx, job); err != nil {
logx.Error(jobCtx, "login_ip_risk_job_failed", err, slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.String("client_ip_hash", job.ClientIPHash))
job.Status = authdomain.LoginIPRiskStatusFailed
job.FailureReason = trimRiskFailure(err.Error())
job.UpdatedAtMs = s.now().UnixMilli()
_ = s.authRepository.CompleteLoginIPRiskJob(jobCtx, job)
}
}
return len(jobs), nil
}
func normalizeLoginIPRiskWorkerOptions(options LoginIPRiskWorkerOptions) LoginIPRiskWorkerOptions {
if strings.TrimSpace(options.WorkerID) == "" {
options.WorkerID = "user-service"
}
if options.LockTTL <= 0 {
options.LockTTL = 30 * time.Second
}
if options.BatchSize <= 0 {
options.BatchSize = 100
}
return options
}
func (s *Service) processLoginIPRiskJob(ctx context.Context, job authdomain.LoginIPRiskJob) error {
policy := s.loginRiskPolicy
nowMs := s.now().UnixMilli()
whitelisted, err := s.CheckLoginRiskIPWhitelisted(ctx, job.ClientIP)
if err != nil {
logx.Warn(ctx, "login_ip_whitelist_read_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("client_ip_hash", job.ClientIPHash), slog.String("error", err.Error()))
}
if whitelisted {
// 白名单是运营显式放行,命中后不再访问 GeoIP provider也不写 IP blocked 决策,避免后续登录被旧地区缓存误拦。
return s.applyLoginIPRiskDecision(ctx, job, IPDecision{
Decision: authdomain.LoginIPRiskDecisionAllowed,
Source: "ip_whitelist",
CheckedAtMs: nowMs,
ExpiresAtMs: nowMs + loginRiskIPWhitelistCacheTTL.Milliseconds(),
}, nil, authdomain.LoginIPRiskStatusCompleted)
}
if cached, ok := s.freshCachedIPDecision(ctx, job); ok {
return s.applyLoginIPRiskDecision(ctx, job, cached, nil, authdomain.LoginIPRiskStatusSkipped)
}
result, attempts := s.resolveIPCountry(ctx, job, policy)
decision := s.decisionForCountry(ctx, result.CountryCode)
ttl := s.ttlForDecision(decision)
expiresAtMs := nowMs + ttl.Milliseconds()
ipDecision := IPDecision{
Decision: decision,
Country: result.CountryCode,
Source: "geo_provider_chain",
CheckedAtMs: nowMs,
ExpiresAtMs: expiresAtMs,
}
if s.ipDecisionCache != nil {
if err := s.ipDecisionCache.SetIPDecision(ctx, job.AppCode, job.ClientIPHash, ipDecision, ttl); err != nil {
logx.Warn(ctx, "login_ip_decision_cache_write_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("client_ip_hash", job.ClientIPHash), slog.String("error", err.Error()))
}
}
attemptsJSON, err := json.Marshal(attempts)
if err != nil {
return err
}
if err := s.authRepository.CreateLoginIPRiskDecision(ctx, authdomain.LoginIPRiskDecision{
AppCode: job.AppCode,
DecisionID: idgen.New("ipdecision"),
ClientIP: job.ClientIP,
ClientIPHash: job.ClientIPHash,
Decision: decision,
CountryCode: result.CountryCode,
Confidence: result.Confidence,
ProviderAttemptsJSON: string(attemptsJSON),
DecidedAtMs: nowMs,
ExpiresAtMs: expiresAtMs,
CreatedAtMs: nowMs,
}); err != nil {
return err
}
return s.applyLoginIPRiskDecision(ctx, job, ipDecision, attempts, authdomain.LoginIPRiskStatusCompleted)
}
func (s *Service) freshCachedIPDecision(ctx context.Context, job authdomain.LoginIPRiskJob) (IPDecision, bool) {
if s.ipDecisionCache == nil {
return IPDecision{}, false
}
decision, ok, err := s.ipDecisionCache.GetIPDecision(ctx, job.AppCode, job.ClientIPHash)
if err != nil {
logx.Warn(ctx, "login_ip_decision_cache_read_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("client_ip_hash", job.ClientIPHash), slog.String("error", err.Error()))
return IPDecision{}, false
}
if !ok {
return IPDecision{}, false
}
if decision.ExpiresAtMs > 0 && decision.ExpiresAtMs <= s.now().UnixMilli() {
return IPDecision{}, false
}
if decision.Decision != authdomain.LoginIPRiskDecisionBlocked && decision.Decision != authdomain.LoginIPRiskDecisionAllowed {
return IPDecision{}, false
}
return decision, true
}
func (s *Service) resolveIPCountry(ctx context.Context, job authdomain.LoginIPRiskJob, policy LoginRiskPolicy) (IPGeoResult, []providerAttempt) {
providers := s.ipGeoProviders
if len(providers) == 0 {
providers = BuildIPGeoProviders(policy.ProviderNames)
}
attempts := make([]providerAttempt, 0, len(providers))
for index, provider := range providers {
started := time.Now()
providerCtx, cancel := context.WithTimeout(ctx, policy.ProviderTimeout)
result, err := provider.ResolveCountry(providerCtx, job)
cancel()
attempt := providerAttempt{
Provider: provider.Name(),
Order: index + 1,
Country: normalizeCountryCode(result.CountryCode),
DurationMs: time.Since(started).Milliseconds(),
}
if err != nil {
attempt.Status = "failed"
attempt.ErrorCode = trimRiskFailure(err.Error())
attempts = append(attempts, attempt)
logx.Warn(ctx, "login_ip_geo_provider_attempt", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("provider", provider.Name()), slog.Int("order", index+1), slog.String("status", attempt.Status), slog.String("error_code", attempt.ErrorCode), slog.Int64("duration_ms", attempt.DurationMs))
continue
}
if attempt.Country == "" {
attempt.Status = "failed"
attempt.ErrorCode = "empty_country"
attempts = append(attempts, attempt)
continue
}
attempt.Status = "ok"
attempts = append(attempts, attempt)
logx.Info(ctx, "login_ip_geo_provider_attempt", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("provider", provider.Name()), slog.Int("order", index+1), slog.String("country", attempt.Country), slog.String("status", attempt.Status), slog.Int64("duration_ms", attempt.DurationMs))
return IPGeoResult{CountryCode: attempt.Country, Confidence: result.Confidence}, attempts
}
return IPGeoResult{}, attempts
}
func (s *Service) decisionForCountry(ctx context.Context, countryCode string) string {
countryCode = normalizeCountryCode(countryCode)
if countryCode == "" {
return authdomain.LoginIPRiskDecisionUnknown
}
if s.countryBlockedByStaticPolicy(countryCode) {
return authdomain.LoginIPRiskDecisionBlocked
}
dynamicBlocked, err := s.countryBlockedByDynamicPolicy(ctx, countryCode)
if err != nil {
// 动态配置读取失败不能扩大误伤面;保留静态配置阻断,其余按 fail-open 放行并记录。
logx.Warn(ctx, "login_risk_country_blocks_read_failed", slog.String("component", "user_auth_risk"), slog.String("country", countryCode), slog.String("error", err.Error()))
return authdomain.LoginIPRiskDecisionAllowed
}
if dynamicBlocked {
return authdomain.LoginIPRiskDecisionBlocked
}
return authdomain.LoginIPRiskDecisionAllowed
}
func (s *Service) ttlForDecision(decision string) time.Duration {
switch decision {
case authdomain.LoginIPRiskDecisionBlocked:
return s.loginRiskPolicy.BlockedTTL
case authdomain.LoginIPRiskDecisionAllowed:
return s.loginRiskPolicy.AllowedTTL
default:
return s.loginRiskPolicy.UnknownTTL
}
}
func (s *Service) applyLoginIPRiskDecision(ctx context.Context, job authdomain.LoginIPRiskJob, decision IPDecision, attempts []providerAttempt, status string) error {
nowMs := s.now().UnixMilli()
job.Status = status
job.Decision = decision.Decision
job.CountryCode = normalizeCountryCode(decision.Country)
job.UpdatedAtMs = nowMs
if decision.Decision == authdomain.LoginIPRiskDecisionUnknown {
job.FailureReason = "provider_unavailable"
}
if err := s.authRepository.CompleteLoginIPRiskJob(ctx, job); err != nil {
return err
}
logx.Info(ctx, "login_ip_risk_decision", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.Int64("user_id", job.UserID), slog.String("decision", job.Decision), slog.String("country", job.CountryCode))
if decision.Decision != authdomain.LoginIPRiskDecisionBlocked {
return nil
}
// 风控任务异步执行时,客户端可能已经在任务完成前用 refresh token 轮换出新 session。
// 因此不能只撤 job.session_id必须沿源 session 的 user_id + device_id 撤销同一登录链路后续 active session防止新 access token 继续通过 gateway。
riskSessionIDs, err := s.authRepository.RevokeUserDeviceSessionsForRisk(ctx, job.SessionID, job.UserID, nowMs, revokeReasonGeoPolicyBlocked, job.RequestID, "risk_worker")
if err != nil {
return err
}
denylistSessionIDs := uniqueRiskSessionIDs(append([]string{job.SessionID}, riskSessionIDs...))
if s.ipDecisionCache != nil {
for _, sessionID := range denylistSessionIDs {
// DB 吊销负责 refresh 续期失败Redis denylist 负责让已经签发出去的 access token 立刻在 gateway 失效。
if err := s.ipDecisionCache.SetRevokedSession(ctx, job.AppCode, sessionID, revokeReasonGeoPolicyBlocked, s.loginRiskPolicy.DenylistTTL); err != nil {
logx.Warn(ctx, "revoked_session_denylist_write_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", sessionID), slog.String("error", err.Error()))
}
}
}
if err := s.authRepository.MarkLoginAuditBlocked(ctx, job.RequestID, job.UserID, job.CountryCode, riskBlockReasonIPAsync); err != nil {
logx.Warn(ctx, "login_audit_mark_blocked_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.String("error", err.Error()))
}
logx.Info(ctx, "login_session_revoked_by_risk", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.Int64("user_id", job.UserID), slog.String("reason", revokeReasonGeoPolicyBlocked), slog.String("country", job.CountryCode), slog.Int("risk_session_count", len(riskSessionIDs)), slog.Int("denylist_session_count", len(denylistSessionIDs)))
_ = attempts
return nil
}
func uniqueRiskSessionIDs(sessionIDs []string) []string {
seen := make(map[string]struct{}, len(sessionIDs))
unique := make([]string, 0, len(sessionIDs))
for _, sessionID := range sessionIDs {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
continue
}
if _, ok := seen[sessionID]; ok {
continue
}
seen[sessionID] = struct{}{}
unique = append(unique, sessionID)
}
return unique
}
func trimRiskFailure(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if len(value) > 128 {
return value[:128]
}
return value
}
func (r IPGeoResult) String() string {
return fmt.Sprintf("%s/%d", r.CountryCode, r.Confidence)
}