签入版本
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
# IP/UA 解析
|
||||
|
||||
:::tip 说明
|
||||
项目提供客户端 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 │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
## 一、IP 获取
|
||||
|
||||
位于 `src/common/utils/ip.py`,从 Flask Request 获取客户端真实 IP。
|
||||
|
||||
### get_client_ip()
|
||||
|
||||
```python
|
||||
def get_client_ip() -> str:
|
||||
"""
|
||||
获取客户端真实IP地址
|
||||
|
||||
仅当直连对端命中 TRUSTED_PROXY_IPS 白名单且 TRUSTED_PROXY_COUNT>0 时才信任
|
||||
X-Forwarded-For 头:
|
||||
- TRUSTED_PROXY_COUNT==0 或 TRUSTED_PROXY_IPS 未配置 / 对端不命中:不信任代理头,
|
||||
直接使用 request.remote_addr(fail-closed,防止被直接暴露时伪造 XFF)
|
||||
- 命中白名单:从 X-Forwarded-For 右侧取倒数第 N 个 IP 作为真实客户端IP
|
||||
"""
|
||||
```
|
||||
|
||||
**返回值**:客户端 IP 地址字符串(如 `"1.2.3.4"`)。
|
||||
|
||||
### 代理链解析原理
|
||||
|
||||
当应用部署在 Nginx/HAProxy 等反向代理之后,`request.remote_addr` 拿到的是代理 IP 而非真实客户端 IP。此时需要从 `X-Forwarded-For`(XFF)头提取真实 IP。
|
||||
|
||||
```
|
||||
客户端 (1.2.3.4)
|
||||
│
|
||||
▼
|
||||
外层代理 (10.0.0.1) ← 追加 XFF: 1.2.3.4
|
||||
│
|
||||
▼
|
||||
内层代理 (10.0.0.2) ← 追加 XFF: 1.2.3.4, 10.0.0.1
|
||||
│
|
||||
▼
|
||||
Flask 应用
|
||||
request.remote_addr = 10.0.0.2
|
||||
X-Forwarded-For = "1.2.3.4, 10.0.0.1"
|
||||
```
|
||||
|
||||
**取值逻辑**:XFF 最右侧由受信代理追加,客户端可控的伪造条目只可能出现在左侧。跳过最右侧 `TRUSTED_PROXY_COUNT` 个代理条目,取真实客户端 IP。
|
||||
|
||||
### 安全设计:fail-closed
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| `TRUSTED_PROXY_COUNT=0` | 不信任任何代理头,直接返回 `request.remote_addr` |
|
||||
| `TRUSTED_PROXY_IPS` 未配置或为空 | 同上,fail-closed |
|
||||
| 直连对端不在白名单中 | 同上(防止应用被直接暴露时客户端伪造 XFF) |
|
||||
| 对端命中白名单 + XFF 条目少于代理层数 | 回退到 XFF 最右侧条目(避免拿到代理 IP) |
|
||||
| `remote_addr` 为 None | 回退到 `127.0.0.1` |
|
||||
|
||||
### 代理配置
|
||||
|
||||
在 `.env` 中配置可信代理:
|
||||
|
||||
```bash
|
||||
# 信任的代理层数(0 = 不信任代理头)
|
||||
TRUSTED_PROXY_COUNT=1
|
||||
|
||||
# 可信代理IP白名单(逗号分隔)
|
||||
TRUSTED_PROXY_IPS=127.0.0.1
|
||||
```
|
||||
|
||||
**常见部署配置**:
|
||||
|
||||
| 部署方式 | TRUSTED_PROXY_COUNT | TRUSTED_PROXY_IPS |
|
||||
|:---------|:----|:----|
|
||||
| 直连(无代理) | `0` | 留空 |
|
||||
| 单层 Nginx | `1` | `127.0.0.1`(本机)或 Nginx 内网 IP |
|
||||
| Nginx + CDN | `2` | Nginx 内网 IP |
|
||||
| Docker bridge 映射端口 | `0` | 留空(容器外为客户端直连) |
|
||||
|
||||
:::warning 安全提示
|
||||
如果应用被直接暴露在公网(如 Docker bridge 映射端口、未走 Nginx),务必保持 `TRUSTED_PROXY_COUNT=0` 或 `TRUSTED_PROXY_IPS` 留空。否则客户端可随意伪造 XFF 首部,绕过基于 IP 的登录锁定和请求限流。
|
||||
:::
|
||||
|
||||
## 二、IP 地理位置查询
|
||||
|
||||
位于 `src/common/utils/ip2region.py`,基于 [ip2region](https://github.com/lionsoul2014/ip2region) 离线数据库,无需网络请求。
|
||||
|
||||
### 核心接口
|
||||
|
||||
#### get_ip_region(ip) → dict
|
||||
|
||||
获取完整区域信息字典,是最底层的查询接口。
|
||||
|
||||
```python
|
||||
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": ""}` |
|
||||
|
||||
#### get_ip_location(ip) → str
|
||||
|
||||
获取地理位置文本描述。
|
||||
|
||||
```python
|
||||
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'
|
||||
"""
|
||||
```
|
||||
|
||||
:::tip 去重逻辑
|
||||
当城市与省份相同时,城市不会重复拼接。例如 `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`,采用懒加载单例模式:
|
||||
|
||||
```python
|
||||
_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 _searcher
|
||||
```
|
||||
|
||||
- **首次查询时**加载 xdb 文件到内存(约 10MB),后续查询零 IO 延迟
|
||||
- **xdb 文件不存在**时返回 `None`,查询降级为 `"未知"`
|
||||
- xdb 数据定期更新可从 ip2region 官方仓库获取
|
||||
|
||||
### 内网 IP 判断
|
||||
|
||||
内网/保留 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` |
|
||||
|
||||
:::warning 畸形 IP 防御
|
||||
畸形输入(如 `172.abc`)不抛异常,按非内网处理交由查询侧兜底返回 `"未知"`。
|
||||
:::
|
||||
|
||||
### 异常降级策略
|
||||
|
||||
所有查询接口均采用 **fail-open** 降级策略——查询失败绝不向上抛异常,保证日志落库不受影响:
|
||||
|
||||
```python
|
||||
except Exception as e:
|
||||
logger.warning(f"IP地理位置查询失败 [{ip}]: {e}")
|
||||
return {"country": "未知", "province": "", "city": "", "isp": "", "country_code": ""}
|
||||
```
|
||||
|
||||
| 异常场景 | 降级行为 |
|
||||
|:---------|:---------|
|
||||
| xdb 文件不存在 | `get_ip_region` 返回 `"未知"` |
|
||||
| xdb 加载失败 | 同上 |
|
||||
| 畸形 IP 地址 | 同上 |
|
||||
| 查询运行时异常 | 同上 + warning 日志 |
|
||||
|
||||
## 三、UA 解析
|
||||
|
||||
位于 `src/common/utils/user_agent.py`,通过正则匹配从 User-Agent 字符串提取操作系统和浏览器信息。
|
||||
|
||||
### parse_user_agent(ua_string)
|
||||
|
||||
```python
|
||||
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` |
|
||||
|
||||
## 四、应用场景
|
||||
|
||||
### 1. 登录日志
|
||||
|
||||
登录/退出时自动记录 IP、地理位置、操作系统、浏览器:
|
||||
|
||||
```python
|
||||
# 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` |
|
||||
|
||||
### 2. 操作日志
|
||||
|
||||
写操作(add/update/delete/status)自动记录操作者 IP 和设备信息:
|
||||
|
||||
```python
|
||||
# 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)` | 浏览器 |
|
||||
|
||||
### 3. 请求限流
|
||||
|
||||
基于 IP 的 Redis 滑动窗口限流,防止接口被刷:
|
||||
|
||||
```python
|
||||
# src/middleware/rate_limit.py
|
||||
client_ip = get_client_ip()
|
||||
current_key = f"rl:{client_ip}:{window_index}"
|
||||
count = sliding_window_incr(redis, current_key, ...)
|
||||
if count > RATE_LIMIT_LIMIT:
|
||||
return R.failed(msg="请求过于频繁,请 N 秒后再试")
|
||||
```
|
||||
|
||||
**配置项**(`.env`):
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|:-----|:-------|:-----|
|
||||
| `RATE_LIMIT_ENABLED` | `False` | 是否启用限流 |
|
||||
| `RATE_LIMIT_LIMIT` | `100` | 单窗口内每 IP 最大请求数 |
|
||||
| `RATE_LIMIT_WINDOW_SECONDS` | `60` | 窗口时长(秒) |
|
||||
| `RATE_LIMIT_KEY_PREFIX` | `rl:` | Redis key 前缀 |
|
||||
|
||||
### 4. 登录失败锁定
|
||||
|
||||
按 IP + 用户名维度统计登录失败次数,超过阈值自动锁定:
|
||||
|
||||
```python
|
||||
# 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 |
|
||||
|
||||
### 5. 验证码防刷
|
||||
|
||||
按 IP 统计验证码请求频率,超过阈值拒绝生成新验证码:
|
||||
|
||||
```python
|
||||
# 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 秒后再试")
|
||||
```
|
||||
|
||||
## 五、配置参考
|
||||
|
||||
### 完整 .env 配置
|
||||
|
||||
```bash
|
||||
# ============================================================
|
||||
# 可信代理配置
|
||||
# ============================================================
|
||||
# 信任的代理层数(0 = 不信任代理头,直接使用 request.remote_addr)
|
||||
TRUSTED_PROXY_COUNT=1
|
||||
# 可信代理IP白名单(逗号分隔,仅白名单内的直连对端才信任 XFF)
|
||||
TRUSTED_PROXY_IPS=127.0.0.1
|
||||
|
||||
# ============================================================
|
||||
# 请求限流配置
|
||||
# ============================================================
|
||||
# 是否启用请求限流(默认关闭)
|
||||
RATE_LIMIT_ENABLED=False
|
||||
# 单窗口内每个 IP 允许的最大请求数
|
||||
RATE_LIMIT_LIMIT=100
|
||||
# 统计窗口时长(秒)
|
||||
RATE_LIMIT_WINDOW_SECONDS=60
|
||||
# Redis 计数 key 前缀
|
||||
RATE_LIMIT_KEY_PREFIX=rl:
|
||||
```
|
||||
|
||||
## 六、常见问题
|
||||
|
||||
### 开发环境日志中 IP 显示为 127.0.0.1
|
||||
|
||||
**原因**:本地直连无代理,`request.remote_addr` 就是 `127.0.0.1`。
|
||||
|
||||
**说明**:这是正常行为。生产环境配置 `TRUSTED_PROXY_COUNT` 和 `TRUSTED_PROXY_IPS` 后即可获取真实客户端 IP。
|
||||
|
||||
### 地理位置显示为"未知"
|
||||
|
||||
**可能原因**:
|
||||
|
||||
1. ip2region.xdb 文件不存在或加载失败(检查启动日志)
|
||||
2. 查询的 IP 是 IPv6(当前仅支持 IPv4)
|
||||
3. 畸形 IP 地址
|
||||
|
||||
**解决**:检查 `src/common/utils/ip2region.xdb` 文件是否存在,启动日志中是否有加载成功提示。
|
||||
|
||||
### 地理位置显示为"内网IP"
|
||||
|
||||
**原因**:客户端 IP 在内网/保留地址段内(`10.x`、`172.16-31.x`、`192.168.x`、`127.0.0.1`)。
|
||||
|
||||
**说明**:常见于开发环境或内网部署。生产环境通过反代获取真实外网 IP 即可。
|
||||
|
||||
### 限流不生效
|
||||
|
||||
**排查步骤**:
|
||||
|
||||
1. 检查 `.env` 中 `RATE_LIMIT_ENABLED=True`
|
||||
2. 检查 Redis 连接是否正常
|
||||
3. 检查 `TRUSTED_PROXY_COUNT` 配置——如果限流基于 `request.remote_addr`(代理 IP),所有请求会共享同一 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 为空时返回 `('', '')` |
|
||||
|
||||
三者组合为登录日志、操作日志、限流、登录锁定、验证码防刷等场景提供完整的客户端身份信息链路。所有接口均采用降级策略,保证日志落库等核心流程不受异常影响。
|
||||
Reference in New Issue
Block a user