2026-05-16 08:12:42 +08:00

255 lines
10 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()
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
}
revoked, err := s.authRepository.RevokeSessionWithReason(ctx, job.SessionID, nowMs, revokeReasonGeoPolicyBlocked, job.RequestID, "risk_worker")
if err != nil {
return err
}
if s.ipDecisionCache != nil {
if err := s.ipDecisionCache.SetRevokedSession(ctx, job.AppCode, job.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", job.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.Bool("revoked", revoked))
_ = attempts
return nil
}
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)
}