420 lines
12 KiB
Go
420 lines
12 KiB
Go
package regionblock
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"net/netip"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"hyapp-admin-server/internal/appctx"
|
||
"hyapp-admin-server/internal/integration/userclient"
|
||
)
|
||
|
||
const (
|
||
maxRegionBlockKeywords = 200
|
||
maxRegionBlockKeywordLength = 128
|
||
maxIPWhitelistItems = 500
|
||
maxUserWhitelistItems = 500
|
||
)
|
||
|
||
var (
|
||
countryCodePattern = regexp.MustCompile(`^[A-Za-z]{2,3}$`)
|
||
errInvalidPayload = errors.New("region block payload invalid")
|
||
errWhitelistCacheRefresh = errors.New("login risk whitelist cache refresh failed")
|
||
)
|
||
|
||
type Service struct {
|
||
userDB *sql.DB
|
||
user userclient.Client
|
||
}
|
||
|
||
type RegionBlock struct {
|
||
BlockID int64 `json:"block_id"`
|
||
Keyword string `json:"keyword"`
|
||
CountryCode string `json:"country_code"`
|
||
Enabled bool `json:"enabled"`
|
||
CreatedAtMS int64 `json:"created_at_ms"`
|
||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||
}
|
||
|
||
type IPWhitelistItem struct {
|
||
WhitelistID int64 `json:"whitelist_id"`
|
||
IPAddress string `json:"ip_address"`
|
||
Enabled bool `json:"enabled"`
|
||
CreatedAtMS int64 `json:"created_at_ms"`
|
||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||
}
|
||
|
||
type UserWhitelistItem struct {
|
||
WhitelistID int64 `json:"whitelist_id"`
|
||
UserID int64 `json:"user_id,string"`
|
||
Enabled bool `json:"enabled"`
|
||
CreatedAtMS int64 `json:"created_at_ms"`
|
||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||
}
|
||
|
||
type Config struct {
|
||
Items []RegionBlock `json:"items"`
|
||
Total int `json:"total"`
|
||
WhitelistItems []IPWhitelistItem `json:"whitelist_items"`
|
||
WhitelistTotal int `json:"whitelist_total"`
|
||
WhitelistUserItems []UserWhitelistItem `json:"whitelist_user_items"`
|
||
WhitelistUserTotal int `json:"whitelist_user_total"`
|
||
}
|
||
|
||
type normalizedKeyword struct {
|
||
Keyword string
|
||
CountryCode string
|
||
}
|
||
|
||
type countryCodeQuerier interface {
|
||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||
}
|
||
|
||
func NewService(userDB *sql.DB, user userclient.Client) *Service {
|
||
return &Service{userDB: userDB, user: user}
|
||
}
|
||
|
||
// List 返回当前 App 已启用的登录地区屏蔽词、IP 白名单和用户白名单。
|
||
// 表属于 user-service 登录风控事实,admin-server 只作为后台维护入口直接写 user DB。
|
||
func (s *Service) List(ctx context.Context) (Config, error) {
|
||
if s.userDB == nil {
|
||
return Config{}, errors.New("user db is not configured")
|
||
}
|
||
appCode := appctx.FromContext(ctx)
|
||
blocks, err := queryEnabledBlocks(ctx, s.userDB, appCode)
|
||
if err != nil {
|
||
return Config{}, err
|
||
}
|
||
whitelist, err := queryEnabledIPWhitelist(ctx, s.userDB, appCode)
|
||
if err != nil {
|
||
return Config{}, err
|
||
}
|
||
userWhitelist, err := queryEnabledUserWhitelist(ctx, s.userDB, appCode)
|
||
if err != nil {
|
||
return Config{}, err
|
||
}
|
||
return Config{Items: blocks, Total: len(blocks), WhitelistItems: whitelist, WhitelistTotal: len(whitelist), WhitelistUserItems: userWhitelist, WhitelistUserTotal: len(userWhitelist)}, nil
|
||
}
|
||
|
||
// Replace 用页面提交的词表整体替换当前 App 的生效配置。
|
||
// 整体替换比逐条增删更适合配置页保存语义,也能避免客户端和服务端出现半旧半新的屏蔽词集合。
|
||
func (s *Service) Replace(ctx context.Context, requestID string, actorID int64, keywords []string, whitelistIPs *[]string, whitelistUserIDs *[]string) (Config, error) {
|
||
if s.userDB == nil {
|
||
return Config{}, errors.New("user db is not configured")
|
||
}
|
||
normalized, err := normalizeKeywords(ctx, s.userDB, appctx.FromContext(ctx), keywords)
|
||
if err != nil {
|
||
return Config{}, err
|
||
}
|
||
var normalizedIPs []string
|
||
if whitelistIPs != nil {
|
||
normalizedIPs, err = normalizeIPWhitelist(*whitelistIPs)
|
||
if err != nil {
|
||
return Config{}, err
|
||
}
|
||
}
|
||
var normalizedUserIDs []int64
|
||
if whitelistUserIDs != nil {
|
||
normalizedUserIDs, err = normalizeUserIDWhitelist(*whitelistUserIDs)
|
||
if err != nil {
|
||
return Config{}, err
|
||
}
|
||
}
|
||
tx, err := s.userDB.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return Config{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
nowMS := time.Now().UTC().UnixMilli()
|
||
appCode := appctx.FromContext(ctx)
|
||
if _, err := tx.ExecContext(ctx, `
|
||
UPDATE login_risk_country_blocks
|
||
SET enabled = 0, updated_by_user_id = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND enabled = 1
|
||
`, nullableActor(actorID), nowMS, appCode); err != nil {
|
||
return Config{}, err
|
||
}
|
||
for _, keyword := range normalized {
|
||
if _, err := tx.ExecContext(ctx, `
|
||
INSERT INTO login_risk_country_blocks (
|
||
app_code, keyword, country_code, enabled,
|
||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||
)
|
||
VALUES (?, ?, ?, 1, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
country_code = VALUES(country_code),
|
||
enabled = 1,
|
||
updated_by_user_id = VALUES(updated_by_user_id),
|
||
updated_at_ms = VALUES(updated_at_ms)
|
||
`, appCode, keyword.Keyword, keyword.CountryCode, nullableActor(actorID), nullableActor(actorID), nowMS, nowMS); err != nil {
|
||
return Config{}, err
|
||
}
|
||
}
|
||
if whitelistIPs != nil {
|
||
if _, err := tx.ExecContext(ctx, `
|
||
UPDATE login_risk_ip_whitelist
|
||
SET enabled = 0, updated_by_user_id = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND enabled = 1
|
||
`, nullableActor(actorID), nowMS, appCode); err != nil {
|
||
return Config{}, err
|
||
}
|
||
for _, ip := range normalizedIPs {
|
||
if _, err := tx.ExecContext(ctx, `
|
||
INSERT INTO login_risk_ip_whitelist (
|
||
app_code, ip_address, enabled,
|
||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||
)
|
||
VALUES (?, ?, 1, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
enabled = 1,
|
||
updated_by_user_id = VALUES(updated_by_user_id),
|
||
updated_at_ms = VALUES(updated_at_ms)
|
||
`, appCode, ip, nullableActor(actorID), nullableActor(actorID), nowMS, nowMS); err != nil {
|
||
return Config{}, err
|
||
}
|
||
}
|
||
}
|
||
if whitelistUserIDs != nil {
|
||
if _, err := tx.ExecContext(ctx, `
|
||
UPDATE login_risk_user_whitelist
|
||
SET enabled = 0, updated_by_user_id = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND enabled = 1
|
||
`, nullableActor(actorID), nowMS, appCode); err != nil {
|
||
return Config{}, err
|
||
}
|
||
for _, userID := range normalizedUserIDs {
|
||
if _, err := tx.ExecContext(ctx, `
|
||
INSERT INTO login_risk_user_whitelist (
|
||
app_code, user_id, enabled,
|
||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||
)
|
||
VALUES (?, ?, 1, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
enabled = 1,
|
||
updated_by_user_id = VALUES(updated_by_user_id),
|
||
updated_at_ms = VALUES(updated_at_ms)
|
||
`, appCode, userID, nullableActor(actorID), nullableActor(actorID), nowMS, nowMS); err != nil {
|
||
return Config{}, err
|
||
}
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return Config{}, err
|
||
}
|
||
if whitelistIPs != nil || whitelistUserIDs != nil {
|
||
if err := s.refreshWhitelistCache(ctx, requestID); err != nil {
|
||
return Config{}, err
|
||
}
|
||
}
|
||
return s.List(ctx)
|
||
}
|
||
|
||
func normalizeKeywords(ctx context.Context, querier countryCodeQuerier, appCode string, raw []string) ([]normalizedKeyword, error) {
|
||
if len(raw) > maxRegionBlockKeywords {
|
||
return nil, errInvalidPayload
|
||
}
|
||
result := make([]normalizedKeyword, 0, len(raw))
|
||
seen := map[string]struct{}{}
|
||
for _, item := range raw {
|
||
keyword := strings.TrimSpace(item)
|
||
if keyword == "" || utf8.RuneCountInString(keyword) > maxRegionBlockKeywordLength {
|
||
return nil, errInvalidPayload
|
||
}
|
||
if countryCodePattern.MatchString(keyword) {
|
||
keyword = strings.ToUpper(keyword)
|
||
}
|
||
key := strings.ToLower(keyword)
|
||
if _, ok := seen[key]; ok {
|
||
continue
|
||
}
|
||
countryCode, err := resolveCountryCode(ctx, querier, appCode, keyword)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
seen[key] = struct{}{}
|
||
result = append(result, normalizedKeyword{Keyword: keyword, CountryCode: countryCode})
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func normalizeIPWhitelist(raw []string) ([]string, error) {
|
||
if len(raw) > maxIPWhitelistItems {
|
||
return nil, errInvalidPayload
|
||
}
|
||
result := make([]string, 0, len(raw))
|
||
seen := map[string]struct{}{}
|
||
for _, item := range raw {
|
||
addr, err := netip.ParseAddr(strings.TrimSpace(item))
|
||
if err != nil {
|
||
return nil, errInvalidPayload
|
||
}
|
||
ip := addr.String()
|
||
if _, ok := seen[ip]; ok {
|
||
continue
|
||
}
|
||
seen[ip] = struct{}{}
|
||
result = append(result, ip)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func normalizeUserIDWhitelist(raw []string) ([]int64, error) {
|
||
if len(raw) > maxUserWhitelistItems {
|
||
return nil, errInvalidPayload
|
||
}
|
||
result := make([]int64, 0, len(raw))
|
||
seen := map[int64]struct{}{}
|
||
for _, item := range raw {
|
||
value := strings.TrimSpace(item)
|
||
if value == "" {
|
||
return nil, errInvalidPayload
|
||
}
|
||
userID, err := strconv.ParseInt(value, 10, 64)
|
||
if err != nil || userID <= 0 {
|
||
return nil, errInvalidPayload
|
||
}
|
||
if _, ok := seen[userID]; ok {
|
||
continue
|
||
}
|
||
seen[userID] = struct{}{}
|
||
result = append(result, userID)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) refreshWhitelistCache(ctx context.Context, requestID string) error {
|
||
if s.user == nil {
|
||
return nil
|
||
}
|
||
_, err := s.user.RefreshLoginRiskWhitelistCache(ctx, userclient.RefreshLoginRiskWhitelistCacheRequest{
|
||
RequestID: requestID,
|
||
Caller: "admin-server",
|
||
})
|
||
if err != nil {
|
||
return errWhitelistCacheRefresh
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func resolveCountryCode(ctx context.Context, querier countryCodeQuerier, appCode string, keyword string) (string, error) {
|
||
if countryCodePattern.MatchString(keyword) {
|
||
return strings.ToUpper(keyword), nil
|
||
}
|
||
upperKeyword := strings.ToUpper(keyword)
|
||
var countryCode string
|
||
err := querier.QueryRowContext(ctx, `
|
||
SELECT country_code
|
||
FROM countries
|
||
WHERE app_code = ?
|
||
AND (
|
||
country_name = ?
|
||
OR country_display_name = ?
|
||
OR UPPER(country_code) = ?
|
||
OR UPPER(COALESCE(iso_alpha3, '')) = ?
|
||
)
|
||
ORDER BY enabled DESC, sort_order ASC, country_id ASC
|
||
LIMIT 1
|
||
`, appCode, keyword, keyword, upperKeyword, upperKeyword).Scan(&countryCode)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return "", nil
|
||
}
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return strings.ToUpper(strings.TrimSpace(countryCode)), nil
|
||
}
|
||
|
||
func queryEnabledBlocks(ctx context.Context, db *sql.DB, appCode string) ([]RegionBlock, error) {
|
||
rows, err := db.QueryContext(ctx, `
|
||
SELECT block_id, keyword, country_code, enabled, created_at_ms, updated_at_ms
|
||
FROM login_risk_country_blocks
|
||
WHERE app_code = ? AND enabled = 1
|
||
ORDER BY country_code ASC, keyword ASC, block_id ASC
|
||
`, appCode)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]RegionBlock, 0)
|
||
for rows.Next() {
|
||
var item RegionBlock
|
||
if err := rows.Scan(&item.BlockID, &item.Keyword, &item.CountryCode, &item.Enabled, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
|
||
return nil, err
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func queryEnabledIPWhitelist(ctx context.Context, db *sql.DB, appCode string) ([]IPWhitelistItem, error) {
|
||
rows, err := db.QueryContext(ctx, `
|
||
SELECT whitelist_id, ip_address, enabled, created_at_ms, updated_at_ms
|
||
FROM login_risk_ip_whitelist
|
||
WHERE app_code = ? AND enabled = 1
|
||
ORDER BY ip_address ASC, whitelist_id ASC
|
||
`, appCode)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]IPWhitelistItem, 0)
|
||
for rows.Next() {
|
||
var item IPWhitelistItem
|
||
if err := rows.Scan(&item.WhitelistID, &item.IPAddress, &item.Enabled, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
|
||
return nil, err
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func queryEnabledUserWhitelist(ctx context.Context, db *sql.DB, appCode string) ([]UserWhitelistItem, error) {
|
||
rows, err := db.QueryContext(ctx, `
|
||
SELECT whitelist_id, user_id, enabled, created_at_ms, updated_at_ms
|
||
FROM login_risk_user_whitelist
|
||
WHERE app_code = ? AND enabled = 1
|
||
ORDER BY user_id ASC, whitelist_id ASC
|
||
`, appCode)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]UserWhitelistItem, 0)
|
||
for rows.Next() {
|
||
var item UserWhitelistItem
|
||
if err := rows.Scan(&item.WhitelistID, &item.UserID, &item.Enabled, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
|
||
return nil, err
|
||
}
|
||
if item.UserID > 0 {
|
||
items = append(items, item)
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func nullableActor(actorID int64) any {
|
||
if actorID <= 0 {
|
||
return nil
|
||
}
|
||
return actorID
|
||
}
|