Become a sponsor

说明
项目提供客户端 IP 获取、IP 地理位置查询和 User-Agent 解析三大能力。IP 获取位于 src/common/utils/ip.py,地理位置查询基于 ip2region 离线数据库(src/common/utils/ip2region.py),UA 解析位于 src/common/utils/user_agent.py。三者组合使用,为登录日志、操作日志、登录锁定等场景提供客户端身份信息。
请求进入
│
▼
┌──────────────────────────────────────────────────────┐
│ get_client_ip() │
│ ├─ 直连对端命中可信代理白名单? │
│ │ ├─ 是 → 从 X-Forwarded-For 取真实客户端 IP │
│ │ └─ 否 → request.remote_addr(fail-closed) │
│ └─ remote_addr 为 None → 回退 127.0.0.1 │
└──────────┬───────────────────────────────────────────┘
│
┌─────┴─────┐
▼ ▼
┌─────────┐ ┌──────────────────────────────────┐
│ 登录锁定 │ │ get_ip_region(ip) │
│ 验证码防刷│ │ ├─ 内网/保留 IP → "内网IP" │
│ 验证码防刷│ │ ├─ ip2region.xdb 离线查询 │
└─────────┘ │ └─ 异常降级 → "未知" │
└──────────┬───────────────────────┘
│
▼
┌──────────────────────┐
│ parse_user_agent(ua) │
│ ├─ 操作系统(正则) │
│ └─ 浏览器(正则) │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ 写入日志表 │
│ ip / location / │
│ os / browser │
└──────────────────────┘位于 src/common/utils/ip.py,从 Flask Request 获取客户端真实 IP。
# src/common/utils/ip.py
def get_client_ip():
"""
获取客户端真实IP地址
通过Flask ProxyFix中间件由PROXY_NUM配置自动设置
request.remote_addr为真实客户端IP。未配置ProxyFix时,
request.remote_addr即为直连IP,避免客户端伪造。
"""
from flask import request
return request.remote_addr返回值:客户端 IP 地址字符串(如 "1.2.3.4")。
当应用部署在 Nginx/HAProxy 等反向代理之后,request.remote_addr 拿到的是代理 IP 而非真实客户端 IP。项目通过 Flask 的 ProxyFix 中间件自动处理,在 src/app.py 中通过 PROXY_NUM 配置:
# src/app.py - create_app()
from werkzeug.middleware.proxy_fix import ProxyFix
proxy_num = int(os.getenv('PROXY_NUM', 0))
if proxy_num > 0:
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=proxy_num, x_proto=proxy_num)| 场景 | 行为 |
|---|---|
PROXY_NUM=0(默认) | 不信任任何代理头,直接返回 request.remote_addr |
PROXY_NUM>=1 | 信任 N 层代理,从 XFF 右侧取真实客户端 IP |
remote_addr 为 None | 回退到 127.0.0.1 |
在 .env 中配置可信代理:
# 信任的代理层数(0 = 不信任代理头)
PROXY_NUM=1
# 可信代理IP白名单(逗号分隔)
PROXY_NUM=127.0.0.1常见部署配置:
| 部署方式 | PROXY_NUM | PROXY_NUM |
|---|---|---|
| 直连(无代理) | 0 | 留空 |
| 单层 Nginx | 1 | 127.0.0.1(本机)或 Nginx 内网 IP |
| Nginx + CDN | 2 | Nginx 内网 IP |
| Docker bridge 映射端口 | 0 | 留空(容器外为客户端直连) |
安全提示
如果应用被直接暴露在公网(如 Docker bridge 映射端口、未走 Nginx),务必保持 PROXY_NUM=0。否则客户端可随意伪造 XFF 首部,绕过基于 IP 的登录锁定。
位于 src/common/utils/ip2region.py,基于 ip2region 离线数据库,无需网络请求。
获取完整区域信息字典,是最底层的查询接口。
def get_ip_region(ip) -> dict:
"""
根据IP获取完整区域信息字典
Returns:
dict: {
"country": "中国", # 国家
"province": "浙江省", # 省份(无数据时为空字符串)
"city": "杭州市", # 城市(无数据时为空字符串)
"isp": "阿里", # 运营商(无数据时为空字符串)
"country_code": "CN" # 国家代码(无数据时为空字符串)
}
"""返回值示例:
| IP | 返回值 |
|---|---|
8.8.8.8 | {"country": "United States", "province": "California", "city": "", "isp": "Google LLC", "country_code": "US"} |
114.114.114.114 | {"country": "中国", "province": "江苏省", "city": "南京市", "isp": "南京信风网络科技", "country_code": "CN"} |
10.0.0.1 | {"country": "内网IP", "province": "", "city": "", "isp": "", "country_code": ""} |
172.abc | {"country": "未知", "province": "", "city": "", "isp": "", "country_code": ""} |
获取地理位置文本描述。
def get_ip_location(ip) -> str:
"""
格式: "国家 省份 城市"
内网IP返回 "内网IP",查询失败返回 "未知"
示例:
>>> get_ip_location('114.114.114.114')
'中国 江苏省 南京市'
>>> get_ip_location('8.8.8.8')
'United States California'
>>> get_ip_location('10.0.0.1')
'内网IP'
"""去重逻辑
当城市与省份相同时,城市不会重复拼接。例如 province="北京", city="北京" 时返回 "中国 北京" 而非 "中国 北京 北京"。
| 函数 | 返回值 | 示例 |
|---|---|---|
get_ip_country(ip) | 国家 | "中国" |
get_ip_province(ip) | 省份 | "浙江省" |
get_ip_city(ip) | 城市 | "杭州市" |
get_ip_isp(ip) | 运营商 | "阿里" |
ip2region.xdb 文件位于 src/common/utils/ip2region.xdb,采用懒加载单例模式:
_XDB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ip2region.xdb")
_searcher = None
def _get_searcher():
"""获取搜索器单例(懒加载,首次调用时初始化)"""
global _searcher
if _searcher is not None:
return _searcher
c_buff = util.load_content_from_file(_XDB_PATH)
_searcher = searcher.new_with_buffer(util.IPv4, c_buff)
return _searcherNone,查询降级为 "未知"内网/保留 IP 直接返回 "内网IP",不查询数据库:
| 类型 | 匹配规则 | 示例 |
|---|---|---|
| 回环地址 | 127.0.0.1、localhost、0.0.0.0 | 127.0.0.1 |
| A 类私有 | 10.x.x.x | 10.0.0.1 |
| B 类私有 | 172.16.x.x ~ 172.31.x.x | 172.16.5.5 |
| C 类私有 | 192.168.x.x | 192.168.1.1 |
畸形 IP 防御
畸形输入(如 172.abc)不抛异常,按非内网处理交由查询侧兜底返回 "未知"。
所有查询接口均采用 fail-open 降级策略——查询失败绝不向上抛异常,保证日志落库不受影响:
except Exception as e:
logger.warning(f"IP地理位置查询失败 [{ip}]: {e}")
return {"country": "未知", "province": "", "city": "", "isp": "", "country_code": ""}| 异常场景 | 降级行为 |
|---|---|
| xdb 文件不存在 | get_ip_region 返回 "未知" |
| xdb 加载失败 | 同上 |
| 畸形 IP 地址 | 同上 |
| 查询运行时异常 | 同上 + warning 日志 |
位于 src/common/utils/user_agent.py,通过正则匹配从 User-Agent 字符串提取操作系统和浏览器信息。
def parse_user_agent(ua_string):
"""
从 User-Agent 字符串提取 OS 和浏览器信息
Args:
ua_string: HTTP User-Agent 头部字符串
Returns:
tuple: (os_name, browser_name)
示例:
>>> parse_user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
('Windows 10.0', 'Chrome 120.0')
"""| 操作系统 | UA 关键字 | 输出示例 |
|---|---|---|
| Windows | Windows NT 10.0 | Windows 10.0 |
| macOS | Mac OS X 14_2_1 | Mac OS X 14.2.1 |
| Android | Android 14 | Android 14 |
| iPhone | iPhone OS 17_2 | iOS 17.2 |
| iPad | iPad; CPU OS 17_2 | iOS 17.2 |
| Linux | Linux | Linux |
按匹配优先级排列(Edge 优先于 Chrome,避免 Edg/ 被误判为 Chrome):
| 浏览器 | UA 关键字 | 输出示例 |
|---|---|---|
| Edge | Edg/120.0.0.0 | Edge 120.0 |
| Chrome | Chrome/120.0.0.0 | Chrome 120.0 |
| Firefox | Firefox/121.0 | Firefox 121.0 |
| Safari | Safari/605.1.15 | Safari 605.1 |
登录/退出时自动记录 IP、地理位置、操作系统、浏览器:
# src/modules/login_log/service.py
ip = get_client_ip()
location = get_ip_location(ip)
ua = request.headers.get('User-Agent', '')
os_name, browser_name = parse_user_agent(ua)
log = LoginLog(
username=username,
ip=ip, # "1.2.3.4"
location=location, # "中国 浙江省 杭州市"
os=os_name, # "Windows 10.0"
browser=browser_name, # "Chrome 120.0"
...
)数据库字段(flask_login_log 表):
| 字段 | 类型 | 说明 | 示例 |
|---|---|---|---|
ip | VARCHAR(50) | 客户端 IP | 1.2.3.4 |
location | VARCHAR(100) | IP 归属地 | 中国 浙江省 杭州市 |
os | VARCHAR(100) | 操作系统 | Windows 10.0 |
browser | VARCHAR(100) | 浏览器 | Chrome 120.0 |
写操作(add/update/delete/status)自动记录操作者 IP 和设备信息:
# src/modules/operation_log/service.py
ip = get_client_ip()
location = get_ip_location(ip)
ua = request.headers.get('User-Agent', '')
os_name, browser_name = parse_user_agent(ua)数据库字段(flask_operation_log 表):
| 字段 | 类型 | 说明 |
|---|---|---|
ip | VARCHAR(100) | 请求 IP |
location | VARCHAR(255) | 请求所在区域 |
os | VARCHAR(100) | 操作系统 |
browser | VARCHAR(100) | 浏览器 |
按 IP + 用户名维度统计登录失败次数,超过阈值自动锁定:
# src/modules/auth/service.py
ip = get_client_ip()
locked, remaining = _is_locked(redis, ip, data['username'])
if locked:
return R.failed(f"登录失败次数过多,请{remaining}秒后再试")Redis Key 设计:
| Key | 格式 | 说明 |
|---|---|---|
| 失败计数 | login:fail:{ip}:{username} | 累计失败次数 |
| 锁定状态 | login:lock:{ip}:{username} | 锁定时自动设置 TTL |
按 IP 统计验证码请求频率,超过阈值拒绝生成新验证码:
# src/common/utils/captcha.py
client_ip = get_client_ip()
rate_key = f"captcha:rate:{client_ip}"
count = redis.get(rate_key)
if count and int(count) >= CAPTCHA_RATE_LIMIT:
raise BizError(msg="请求过于频繁,请等待 N 秒后再试")# ============================================================
# 可信代理配置
# ============================================================
# 信任的代理层数(0 = 不信任代理头,直接使用 request.remote_addr)
PROXY_NUM=1原因:本地直连无代理,request.remote_addr 就是 127.0.0.1。
说明:这是正常行为。生产环境配置 PROXY_NUM 后即可获取真实客户端 IP。
可能原因:
解决:检查 src/common/utils/ip2region.xdb 文件是否存在,启动日志中是否有加载成功提示。
原因:客户端 IP 在内网/保留地址段内(10.x、172.16-31.x、192.168.x、127.0.0.1)。
说明:常见于开发环境或内网部署。生产环境通过反代获取真实外网 IP 即可。
| 模块 | 文件 | 核心能力 | 降级策略 |
|---|---|---|---|
| IP 获取 | src/common/utils/ip.py | 从 Request 提取真实客户端 IP,支持可信代理白名单 | remote_addr 为 None 时回退 127.0.0.1 |
| 地理位置 | src/common/utils/ip2region.py | 基于 ip2region.xdb 离线查询,零网络依赖 | 异常/畸形/内网统一降级返回 "未知" 或 "内网IP" |
| UA 解析 | src/common/utils/user_agent.py | 正则匹配提取操作系统和浏览器 | UA 为空时返回 ('', '') |
三者组合为登录日志、操作日志、登录锁定、验证码防刷等场景提供完整的客户端身份信息链路。所有接口均采用降级策略,保证日志落库等核心流程不受异常影响。