Files
diary-news/backend/app/services/active_ip.py
xiaji 0d5f29fd37 fix(active_ip): 修 debug log 引用未定义变量导致 500
bug: 之前 smoke 加 print 时把 print 放到了 'limit = ...' 之前,
Python 看到函数内有 'limit =' 就把整个 limit 视为 local,
print 在赋值前引用 → UnboundLocalError → 整个 login 返 500。

修: 把 print 挪到 limit 赋值之后。
2026-06-13 19:16:58 +08:00

179 lines
6.1 KiB
Python

"""站点活跃 IP 限制(防 token 泄漏被滥用)
- Redis SortedSet `active_ips` 存当前活跃客户端 IP
- member = IP 字符串
- score = last_seen unix 秒
- 每次已认证请求: 刷新该 IP 的 score(滑动 TTL)
- 登录时: 检查 ZCARD
- 当前 IP 已在 set → 直接放行(自己人不踢)
- 当前 IP 不在 set 且 ZCARD >= limit → 拒绝登录
- 当前 IP 不在 set 且 ZCARD < limit → 加入 set,放行
- 30 天没活动的 IP 自动从 set 剔除
- 整个 key 30 天无活动自动过期(EXPIRE)
注意:
- 信任 X-Forwarded-For 必须显式开启(trust_x_forwarded_for=True),
否则攻击者伪造头绕过 IP 限制
- 不限流请求本身(只限流"新 IP 登录"),活跃 IP 持续刷新即可
- API token(api_tokens 表)也走这个限流(同一个 IP 拿到 token 后被滥用也防)
"""
from __future__ import annotations
import logging
import time
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from app.config import settings
from app.redis_client import get_redis
logger = logging.getLogger("news.auth.active_ip")
REDIS_KEY = "active_ips"
def get_client_ip(request: Request) -> str:
"""提取客户端真实 IP。
优先级:
1) X-Forwarded-For 第一个(被代理链信任时)
2) X-Real-IP(Nginx 风格)
3) request.client.host(直连)
仅在配置 trust_x_forwarded_for=True 时信任 1+2,否则只用 3。
"""
import sys
xff_raw = request.headers.get("x-forwarded-for")
xri_raw = request.headers.get("x-real-ip")
client_host = request.client.host if request.client else None
print(f"[GET_CLIENT_IP] xff={xff_raw!r} xri={xri_raw!r} client_host={client_host!r} trust={settings.trust_x_forwarded_for}", file=sys.stderr, flush=True)
if settings.trust_x_forwarded_for:
xff = xff_raw
if xff:
ip = xff.split(",")[0].strip()
if ip:
print(f"[GET_CLIENT_IP] -> use xff split[0] = {ip!r}", file=sys.stderr, flush=True)
return ip
xri = xri_raw
if xri:
return xri.strip()
if client_host:
return client_host
return "unknown"
async def _prune_and_count(now: int) -> int:
"""清理过期 IP 并返回当前活跃数。"""
r = get_redis()
idle_cutoff = now - settings.site_active_ip_idle_days * 86400
# 删 score < idle_cutoff
await r.zremrangebyscore(REDIS_KEY, 0, idle_cutoff - 1)
return await r.zcard(REDIS_KEY)
async def check_or_register_login_ip(ip: str) -> None:
"""登录/refresh 时调用。
- 如果 IP 已在 set 里:刷新 score,放行
- 如果 IP 不在 set 里,且活跃数 < limit:加入 set,放行
- 如果 IP 不在 set 里,且活跃数 >= limit:raise 429
注意:不限制 owner/非 owner(整个站点一个池,所有用户共享)
"""
r = get_redis()
now = int(time.time())
limit = settings.site_max_active_ips
import sys
print(f"[CHECK] ip={ip!r} limit={limit}", file=sys.stderr, flush=True)
# 看 IP 是否已在 set
existing = await r.zscore(REDIS_KEY, ip)
if existing is not None:
# 已在 set,刷新 score 即可(滑动 TTL)
await r.zadd(REDIS_KEY, {ip: now})
await r.expire(REDIS_KEY, settings.site_active_ip_idle_days * 86400)
return
# 不在 set — 先清理过期再计数
count = await _prune_and_count(now)
if count >= limit:
# 第 (limit+1) 个新 IP,拒绝
logger.warning(
"active IP limit reached: %d >= %d, reject login from %s",
count, limit, ip,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"站点同时在线 IP 数已达上限({limit} 个)。"
f"如有疑问请等待空闲 IP 退出后重试,或联系 owner 调整 site_max_active_ips。"
),
headers={"Retry-After": "3600"},
)
# 通过:加入 set
await r.zadd(REDIS_KEY, {ip: now})
await r.expire(REDIS_KEY, settings.site_active_ip_idle_days * 86400)
logger.info("active IP registered: %s (total now %d)", ip, count + 1)
async def touch_active_ip(ip: str) -> None:
"""每次已认证请求时调用,刷新该 IP 的 score(滑动 TTL)。
已废弃 IP 不主动删,等下次 login 时 prune。
"""
r = get_redis()
now = int(time.time())
# NX:仅当 member 不存在时设置 — 但我们要 UPDATE score,不能用 NX
# 正常 ZADD 即可:member 存在就更新 score
await r.zadd(REDIS_KEY, {ip: now})
# 同时顺手 prune,避免 set 无限膨胀
await _prune_and_count(now)
await r.expire(REDIS_KEY, settings.site_active_ip_idle_days * 86400)
async def list_active_ips() -> list[dict]:
"""给 owner 看:当前活跃 IP + last_seen。
按 last_seen 倒序。
"""
r = get_redis()
now = int(time.time())
await _prune_and_count(now)
rows = await r.zrange(REDIS_KEY, 0, -1, withscores=True, desc=True)
return [
{
"ip": ip,
"last_seen_unix": int(score),
"last_seen_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(score)),
"idle_seconds": int(now - score),
}
for ip, score in rows
]
async def remove_active_ip(ip: str) -> bool:
"""owner 强制踢人。"""
r = get_redis()
removed = await r.zrem(REDIS_KEY, ip)
return bool(removed)
# === FastAPI 依赖 ===
async def touch_ip_dependency(request: Request) -> None:
"""每个已认证请求的依赖:刷新当前 IP 的 last_seen。
不会失败(吞掉异常),避免 Redis 抖动影响正常请求。
"""
try:
import sys
ip = get_client_ip(request)
# 临时诊断:看实际 IP 提取
xff = request.headers.get("x-forwarded-for")
xri = request.headers.get("x-real-ip")
client_host = request.client.host if request.client else None
print(f"[TOUCH_DEP] ip={ip!r} xff={xff!r} xri={xri!r} client_host={client_host!r}", file=sys.stderr, flush=True)
await touch_active_ip(ip)
except Exception as e: # noqa: BLE001
logger.debug("touch_active_ip failed (ignored): %s", e)