295 lines
8.9 KiB
Go
295 lines
8.9 KiB
Go
package auth
|
||
|
||
import (
|
||
"context"
|
||
"net/netip"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp/pkg/appcode"
|
||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||
)
|
||
|
||
const (
|
||
loginRiskIPWhitelistCacheTTL = 30 * time.Second
|
||
loginRiskUserWhitelistCacheTTL = 5 * time.Minute
|
||
)
|
||
|
||
// LoginRiskWhitelistCacheSnapshot 是后台保存配置后主动刷新缓存的结果。
|
||
// IP 白名单仍沿用原短 TTL;用户白名单按运营要求缓存 5 分钟,避免每个风控任务回源 MySQL。
|
||
type LoginRiskWhitelistCacheSnapshot struct {
|
||
Refreshed bool
|
||
IPWhitelistCount int
|
||
UserWhitelistCount int
|
||
UserCacheExpiresAtMs int64
|
||
}
|
||
|
||
// ListLoginRiskBlockedCountries 返回当前 App 实际生效的登录地区风控词表。
|
||
// 静态配置和后台动态配置都下发给 App;App 可异步做本地风险提示,最终封禁仍以服务端 worker 为准。
|
||
func (s *Service) ListLoginRiskBlockedCountries(ctx context.Context) ([]authdomain.LoginRiskCountryBlock, error) {
|
||
if !s.loginRiskPolicy.Enabled {
|
||
return nil, nil
|
||
}
|
||
appCode := appcode.FromContext(ctx)
|
||
blocks := make([]authdomain.LoginRiskCountryBlock, 0, len(s.loginRiskPolicy.UnsupportedCountries))
|
||
seen := map[string]struct{}{}
|
||
for _, country := range s.loginRiskPolicy.UnsupportedCountries {
|
||
countryCode := normalizeCountryCode(country)
|
||
if countryCode == "" {
|
||
continue
|
||
}
|
||
block := authdomain.LoginRiskCountryBlock{
|
||
AppCode: appCode,
|
||
Keyword: countryCode,
|
||
CountryCode: countryCode,
|
||
Enabled: true,
|
||
}
|
||
blocks = appendUniqueCountryBlock(blocks, seen, block)
|
||
}
|
||
if s.authRepository != nil {
|
||
dynamicBlocks, err := s.authRepository.ListEnabledLoginRiskCountryBlocks(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, block := range dynamicBlocks {
|
||
block.AppCode = appcode.Normalize(block.AppCode)
|
||
block.Keyword = strings.TrimSpace(block.Keyword)
|
||
block.CountryCode = normalizeCountryCode(block.CountryCode)
|
||
block.Enabled = true
|
||
if block.Keyword == "" {
|
||
continue
|
||
}
|
||
blocks = appendUniqueCountryBlock(blocks, seen, block)
|
||
}
|
||
}
|
||
sort.SliceStable(blocks, func(i, j int) bool {
|
||
if blocks[i].CountryCode != blocks[j].CountryCode {
|
||
return blocks[i].CountryCode < blocks[j].CountryCode
|
||
}
|
||
return strings.ToLower(blocks[i].Keyword) < strings.ToLower(blocks[j].Keyword)
|
||
})
|
||
return blocks, nil
|
||
}
|
||
|
||
// CheckLoginRiskIPWhitelisted 判断入口 IP 是否命中后台白名单。
|
||
// 白名单是“跳过登录地区屏蔽”的明确运营例外,只做标准化后的单 IP 精确匹配,不扩展为网段或地区推断。
|
||
func (s *Service) CheckLoginRiskIPWhitelisted(ctx context.Context, clientIP string) (bool, error) {
|
||
ip := normalizeLoginRiskIP(clientIP)
|
||
if ip == "" {
|
||
return false, nil
|
||
}
|
||
ips, err := s.listLoginRiskIPWhitelist(ctx)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
for _, item := range ips {
|
||
if item == ip {
|
||
return true, nil
|
||
}
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
// CheckLoginRiskUserWhitelisted 判断登录用户是否命中后台白名单。
|
||
// 该白名单只在 user-service 登录后风控 worker 中使用;gateway 登录前没有可信 user_id,不能用它跳过入口快拦。
|
||
func (s *Service) CheckLoginRiskUserWhitelisted(ctx context.Context, userID int64) (bool, error) {
|
||
if userID <= 0 {
|
||
return false, nil
|
||
}
|
||
userIDs, err := s.listLoginRiskUserWhitelist(ctx)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
for _, item := range userIDs {
|
||
if item == userID {
|
||
return true, nil
|
||
}
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
// RefreshLoginRiskWhitelistCache 从 MySQL 读取当前 App 白名单并立即写入 Redis。
|
||
// 后台保存直接调用该方法,避免新增白名单后仍等 5 分钟 TTL 自然过期才生效。
|
||
func (s *Service) RefreshLoginRiskWhitelistCache(ctx context.Context) (LoginRiskWhitelistCacheSnapshot, error) {
|
||
if s.authRepository == nil {
|
||
return LoginRiskWhitelistCacheSnapshot{}, nil
|
||
}
|
||
ips, err := s.loadLoginRiskIPWhitelist(ctx)
|
||
if err != nil {
|
||
return LoginRiskWhitelistCacheSnapshot{}, err
|
||
}
|
||
userIDs, err := s.loadLoginRiskUserWhitelist(ctx)
|
||
if err != nil {
|
||
return LoginRiskWhitelistCacheSnapshot{}, err
|
||
}
|
||
snapshot := LoginRiskWhitelistCacheSnapshot{
|
||
IPWhitelistCount: len(ips),
|
||
UserWhitelistCount: len(userIDs),
|
||
UserCacheExpiresAtMs: s.now().Add(loginRiskUserWhitelistCacheTTL).UnixMilli(),
|
||
}
|
||
if s.ipDecisionCache == nil {
|
||
return snapshot, nil
|
||
}
|
||
// IP 和用户白名单共用一次刷新入口;任一缓存写失败都返回错误,让后台保存页能提示重试刷新。
|
||
if err := s.ipDecisionCache.SetIPWhitelist(ctx, appcode.FromContext(ctx), ips, loginRiskIPWhitelistCacheTTL); err != nil {
|
||
return LoginRiskWhitelistCacheSnapshot{}, err
|
||
}
|
||
if err := s.ipDecisionCache.SetUserWhitelist(ctx, appcode.FromContext(ctx), userIDs, loginRiskUserWhitelistCacheTTL); err != nil {
|
||
return LoginRiskWhitelistCacheSnapshot{}, err
|
||
}
|
||
snapshot.Refreshed = true
|
||
return snapshot, nil
|
||
}
|
||
|
||
func (s *Service) listLoginRiskIPWhitelist(ctx context.Context) ([]string, error) {
|
||
appCode := appcode.FromContext(ctx)
|
||
if s.ipDecisionCache != nil {
|
||
ips, ok, err := s.ipDecisionCache.GetIPWhitelist(ctx, appCode)
|
||
if err == nil && ok {
|
||
return normalizeLoginRiskIPList(ips), nil
|
||
}
|
||
}
|
||
ips, err := s.loadLoginRiskIPWhitelist(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if s.ipDecisionCache != nil {
|
||
// 白名单读取结果按 App 整体缓存 30 秒;空列表也缓存,避免无配置时每次登录都回源 DB。
|
||
_ = s.ipDecisionCache.SetIPWhitelist(ctx, appCode, ips, loginRiskIPWhitelistCacheTTL)
|
||
}
|
||
return ips, nil
|
||
}
|
||
|
||
func (s *Service) listLoginRiskUserWhitelist(ctx context.Context) ([]int64, error) {
|
||
appCode := appcode.FromContext(ctx)
|
||
if s.ipDecisionCache != nil {
|
||
userIDs, ok, err := s.ipDecisionCache.GetUserWhitelist(ctx, appCode)
|
||
if err == nil && ok {
|
||
return normalizeLoginRiskUserIDList(userIDs), nil
|
||
}
|
||
}
|
||
userIDs, err := s.loadLoginRiskUserWhitelist(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if s.ipDecisionCache != nil {
|
||
// 用户白名单按运营要求缓存 5 分钟;空列表也缓存,减少无配置时的任务回源。
|
||
_ = s.ipDecisionCache.SetUserWhitelist(ctx, appCode, userIDs, loginRiskUserWhitelistCacheTTL)
|
||
}
|
||
return userIDs, nil
|
||
}
|
||
|
||
func (s *Service) loadLoginRiskIPWhitelist(ctx context.Context) ([]string, error) {
|
||
if s.authRepository == nil {
|
||
return nil, nil
|
||
}
|
||
items, err := s.authRepository.ListEnabledLoginRiskIPWhitelist(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ips := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
if ip := normalizeLoginRiskIP(item.IPAddress); ip != "" {
|
||
ips = append(ips, ip)
|
||
}
|
||
}
|
||
return normalizeLoginRiskIPList(ips), nil
|
||
}
|
||
|
||
func (s *Service) loadLoginRiskUserWhitelist(ctx context.Context) ([]int64, error) {
|
||
if s.authRepository == nil {
|
||
return nil, nil
|
||
}
|
||
items, err := s.authRepository.ListEnabledLoginRiskUserWhitelist(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
userIDs := make([]int64, 0, len(items))
|
||
for _, item := range items {
|
||
if item.UserID > 0 {
|
||
userIDs = append(userIDs, item.UserID)
|
||
}
|
||
}
|
||
return normalizeLoginRiskUserIDList(userIDs), nil
|
||
}
|
||
|
||
func appendUniqueCountryBlock(blocks []authdomain.LoginRiskCountryBlock, seen map[string]struct{}, block authdomain.LoginRiskCountryBlock) []authdomain.LoginRiskCountryBlock {
|
||
key := strings.ToLower(block.CountryCode + "\x00" + block.Keyword)
|
||
if _, ok := seen[key]; ok {
|
||
return blocks
|
||
}
|
||
seen[key] = struct{}{}
|
||
return append(blocks, block)
|
||
}
|
||
|
||
func (s *Service) countryBlockedByStaticPolicy(countryCode string) bool {
|
||
for _, country := range s.loginRiskPolicy.UnsupportedCountries {
|
||
if normalizeCountryCode(country) == countryCode {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (s *Service) countryBlockedByDynamicPolicy(ctx context.Context, countryCode string) (bool, error) {
|
||
if s.authRepository == nil {
|
||
return false, nil
|
||
}
|
||
blocks, err := s.authRepository.ListEnabledLoginRiskCountryBlocks(ctx)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
for _, block := range blocks {
|
||
if normalizeCountryCode(block.CountryCode) == countryCode || normalizeCountryCode(block.Keyword) == countryCode {
|
||
return true, nil
|
||
}
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
func normalizeLoginRiskIP(raw string) string {
|
||
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return addr.String()
|
||
}
|
||
|
||
func normalizeLoginRiskIPList(raw []string) []string {
|
||
seen := make(map[string]struct{}, len(raw))
|
||
ips := make([]string, 0, len(raw))
|
||
for _, item := range raw {
|
||
ip := normalizeLoginRiskIP(item)
|
||
if ip == "" {
|
||
continue
|
||
}
|
||
if _, ok := seen[ip]; ok {
|
||
continue
|
||
}
|
||
seen[ip] = struct{}{}
|
||
ips = append(ips, ip)
|
||
}
|
||
sort.Strings(ips)
|
||
return ips
|
||
}
|
||
|
||
func normalizeLoginRiskUserIDList(raw []int64) []int64 {
|
||
seen := make(map[int64]struct{}, len(raw))
|
||
userIDs := make([]int64, 0, len(raw))
|
||
for _, userID := range raw {
|
||
if userID <= 0 {
|
||
continue
|
||
}
|
||
if _, ok := seen[userID]; ok {
|
||
continue
|
||
}
|
||
seen[userID] = struct{}{}
|
||
userIDs = append(userIDs, userID)
|
||
}
|
||
sort.Slice(userIDs, func(i, j int) bool {
|
||
return userIDs[i] < userIDs[j]
|
||
})
|
||
return userIDs
|
||
}
|