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

@@ -205,3 +205,58 @@ async def reset_quota(payload: QuotaReset) -> dict[str, Any]:
key = f"translation:month:{now:%Y%m}"
await r.set(key, payload.used_chars)
return {"key": key, "value": payload.used_chars}
# === 活跃 IP 看板(防 token 泄漏 / 排查异地登录)===
class ActiveIpItem(BaseModel):
ip: str
last_seen_unix: int
last_seen_iso: str
idle_seconds: int
class ActiveIpList(BaseModel):
items: list[ActiveIpItem]
limit: int
idle_days: int
count: int
@router.get("/active-ips", response_model=ActiveIpList)
async def list_active_ips_admin():
from app.config import settings
from app.services.active_ip import list_active_ips
items = await list_active_ips()
return ActiveIpList(
items=[ActiveIpItem(**i) for i in items],
limit=settings.site_max_active_ips,
idle_days=settings.site_active_ip_idle_days,
count=len(items),
)
class KickIpRequest(BaseModel):
ip: str
class KickIpResponse(BaseModel):
kicked: bool
ip: str
@router.post("/active-ips/kick", response_model=KickIpResponse)
async def kick_active_ip(payload: KickIpRequest):
"""owner 强制剔除某个 IP。下次该 IP 登录时会再次被算作"新 IP""""
from app.services.active_ip import remove_active_ip
ok = await remove_active_ip(payload.ip)
return KickIpResponse(kicked=ok, ip=payload.ip)
@router.delete("/active-ips/{ip}", response_model=KickIpResponse)
async def delete_active_ip(ip: str):
from app.services.active_ip import remove_active_ip
ok = await remove_active_ip(ip)
return KickIpResponse(kicked=ok, ip=ip)

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from jwt.exceptions import InvalidTokenError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +18,7 @@ from app.core.security import (
from app.database import get_session
from app.models.user import User
from app.schemas.auth import LoginRequest, RefreshRequest, TokenPair
from app.services.active_ip import check_or_register_login_ip, get_client_ip
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -33,7 +34,16 @@ def _pair_for(user: User) -> TokenPair:
@router.post("/login", response_model=TokenPair)
async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)):
async def login(
body: LoginRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
# === 先校验 IP 上限(在查 DB 前,避免密码错误也消耗 DB)===
# 注:这一步是公开的(未认证),不暴露"这个 IP 是不是 owner"的信息
client_ip = get_client_ip(request)
await check_or_register_login_ip(client_ip)
result = await session.execute(select(User).where(User.username == body.username))
user = result.scalars().first()
if not user or not user.is_active or not verify_password(body.password, user.password_hash):
@@ -44,7 +54,16 @@ async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)
@router.post("/refresh", response_model=TokenPair)
async def refresh(body: RefreshRequest, session: AsyncSession = Depends(get_session)):
async def refresh(
body: RefreshRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
# refresh 也要算 IP 占用(因为 refresh 拿到 access token 就能用)
# 但如果 IP 已经在 set 里,等于合法老用户,直接放过
client_ip = get_client_ip(request)
await check_or_register_login_ip(client_ip)
try:
payload = decode_token(body.refresh_token)
if payload.get("type") != "refresh":