255 lines
10 KiB
Go
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)
|
|
}
|