# -*- coding: utf-8 -*- """登录 IP 归属地探测与异地自动封禁。 (自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。) """ import json import sqlite3 import time from core.http_client import client as _http from .db import ( _x, ) from .logs import ( add_log, ) from .users import ( ban_user, ) _PROVINCE_CACHE: dict[str, str] = {} def _is_private_ip(ip: str) -> bool: """内网/回环地址判断(172.16-31.* 才是私网,不能把整个 172.* 误判)""" try: import ipaddress addr = ipaddress.ip_address(ip) return addr.is_private or addr.is_loopback or addr.is_link_local except ValueError: return False async def query_ip_province(ip: str) -> str: """查询 IP 归属省份(内网/失败返回空串),带进程内缓存 改用官方路径 /json/{query} 与 zh-CN 语言参数。 走 core.http_client 的原生异步通道(原实现经 core.sync_http 同步门面 在后台事件循环桥接——调用方本就是 async 的登录链路,无需再桥一次)。 """ ip = (ip or '').split(',')[0].strip() if not ip or ip in ('-', '127.0.0.1', '0.0.0.0') or _is_private_ip(ip): return '' if ip in _PROVINCE_CACHE: return _PROVINCE_CACHE[ip] province = '' try: import urllib.parse as _ul r = await _http.request( 'GET', 'http://ip-api.com/json/' + _ul.quote(ip), params={'lang': 'zh-CN', 'fields': 'status,regionName'}, timeout=5) d = r.json() if d.get('status') == 'success': province = str(d.get('regionName') or '') except Exception: province = '' # 简单容量上限,防止公网长期运行下无界增长 if len(_PROVINCE_CACHE) > 5000: _PROVINCE_CACHE.clear() # 查询失败(超时/限流/垃圾 JSON)的空省份不写缓存——旧口径一次 # ip-api 抖动窗口内登录的 IP 被永久标记"无省份",省份防倒卖静默失效 if province: _PROVINCE_CACHE[ip] = province return province PROVINCE_WINDOW = 24 * 3600 # 省份变化检测窗口:24 小时 PROVINCE_BAN_LIMIT = 3 # 窗口内出现 >=3 个不同省份即封禁 async def record_login_location(user: sqlite3.Row, ip: str) -> tuple: """记录本次登录省份并检测异常:24h 内出现 >=3 个不同省份自动封禁。 返回 (是否被封禁, 说明) 落库走原生异步通道(auth_system 已迁移为异步 DB 层),直接 await, 不再经 asyncio.to_thread 桥接。 """ if user['role'] == 'admin': return False, '' province = await query_ip_province(ip) if not province: return False, '' now = int(time.time()) try: history = json.loads(user['recent_provinces'] if 'recent_provinces' in user.keys() and user['recent_provinces'] else '[]') except Exception: history = [] history = [h for h in history if isinstance(h, dict) and now - int(h.get('t', 0)) <= PROVINCE_WINDOW] history.append({'p': province, 't': now}) provinces = [] for h in history: if h.get('p') and h['p'] not in provinces: provinces.append(h['p']) await _x('UPDATE users SET recent_provinces = ? WHERE id = ?', (json.dumps(history, ensure_ascii=False), user['id'])) if len(provinces) >= PROVINCE_BAN_LIMIT: reason = f'24小时内检测到 {len(provinces)} 个不同省份登录({"/".join(provinces[-3:])}),疑似账号倒卖,已自动封禁' await ban_user(user['id'], reason) await add_log('fail', user['username'], ip, '自动封禁', reason) return True, reason return False, province