291 lines
12 KiB
Go
291 lines
12 KiB
Go
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)
|
||
}
|