feat(auth): 限制同时在线 IP 数 (默认 30, 第 31 拒绝)

背景: 防 token 泄漏被滥用 + 限共享账号人数。

- 新增 app/services/active_ip.py:
    Redis ZSET 'active_ips' 存 IP,score=last_seen_unix
    登录/refresh 时 check_or_register_login_ip():
        IP 已在 set → 刷新 score,放行(老用户重连)
        IP 不在 set + ZCARD < 30 → 加入,放行
        IP 不在 set + ZCARD >= 30 → raise 429
    每个已认证请求 _resolve_user() 调 touch_ip_dependency()
        滑动 TTL,30 天没活动自动从 set 剔除
- get_client_ip() 取真实 IP,优先级 X-Forwarded-For > X-Real-IP > client.host
    trust_x_forwarded_for 默认 True(生产 Caddy/Nginx 后面)
- config 加 3 个开关:
    site_max_active_ips: int = 30
    site_active_ip_idle_days: int = 30
    trust_x_forwarded_for: bool = True
- admin.py 加 3 个端点:
    GET  /admin/active-ips       — 看当前活跃 IP 列表 + last_seen
    POST /admin/active-ips/kick  — 强制踢出指定 IP(body={ip})
    DELETE /admin/active-ips/{ip}— 简写踢出
- 注: refresh 也算 IP 占用(拿到 access token 就能用)
    但已存在的 IP 直接放行,不会踢自己
This commit is contained in:
xiaji
2026-06-13 18:22:40 +08:00
parent 0df6e79e56
commit b500613d22
5 changed files with 259 additions and 4 deletions

View File

@@ -0,0 +1,165 @@
"""站点活跃 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。
"""
if settings.trust_x_forwarded_for:
xff = request.headers.get("x-forwarded-for")
if xff:
# 取第一个(原始客户端),可能形如 "1.2.3.4, 10.0.0.1"
ip = xff.split(",")[0].strip()
if ip:
return ip
xri = request.headers.get("x-real-ip")
if xri:
return xri.strip()
if request.client and request.client.host:
return request.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
# 看 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:
ip = get_client_ip(request)
await touch_active_ip(ip)
except Exception as e: # noqa: BLE001
logger.debug("touch_active_ip failed (ignored): %s", e)