Files
ai-kefu-admin/app/platform/auth.py
T
浅念 f938118fee feat(limits): 限流器过期键周期清扫(内存上限铁律)
LoginLimiter._fails 与 rate_limit._buckets 只增不删——懒清理只覆盖被
回访的键,扫号换 IP/设备轮换产生的键永不被回访 → 进程长跑缓慢累积。
两处各加 sweep()(全表清「全部时间戳均已出窗」的键,窗沿 <= 与既有
保留判据 t > now - W 同沿),main.lifespan 起周期任务(300s loop)
启动创建/关停取消(cancel 后 await 落地,不留 pending task)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 18:59:22 +08:00

319 lines
13 KiB
Python

"""平台认证内核(Phase 2 Task 2):哈希上移 + 内存 token + 登录限流 + 依赖。
本模块零 app 内依赖(不 import db/main)——hash_password/verify_password
从 db.py 上移到此为单一事实源(不留双份漂移),db.py 反向 re-export 兼容
既有导入;verify 签名解 ORM 绑定为纯参式 (password, password_hash, salt)
(Task 1 加固 M-2b)。
构成:
- hash_password / verify_password: 版本化 scrypt,格式与解析兼容不变
("scrypt$<n>$<r>$<p>$<salt_hex>$<hash_hex>";无前缀过渡行 = salt 列 +
默认参,兼容照旧)
- TokenStore: 内存 token 表,secrets.token_urlsafe(32),TTL 默认 8h(可注入)
- LoginLimiter: (username, ip) 滑动窗失败计数,5 次/15 分钟(窗长可注入),
风格照抄 engine/services/rate_limit.py
- UserContext + get_current_user / require_super / require_tenant(slug) /
enforce_password_changed / platform_user: FastAPI 依赖
单进程纪律: TokenStore/LoginLimiter 均进程内存态,uvicorn 必须 --workers 1
(>1 时各 worker 各持一份:token 在 A 签发 B 不识 → 401 误伤;限流配额随
worker 数翻倍)。与 rate_limit.py 同一取舍(单 worker 部署形态)。
安全语义:
- 防枚举: 用户不存在/密码错/停用同一 401 静态文案(auth_routes);用户不
存在时也跑一次等代价哑 scrypt,抹平时序侧信道差。
- 密码/token 永不进日志: 本层与路由层不 log 任何凭据字段,错误 detail
均静态串。
- token 走 Authorization: Bearer 头,无 Cookie 免 CSRF(spec §7.2)。
- 偏差注记: spec §7.2 写 bcrypt/argon2,实现用 hashlib.scrypt(stdlib 零
新依赖,抗 GPU 性能同级)——有意取舍。单次 ~50ms CPU,登录/改密路径
可接受(不在每请求热路径——依赖只查内存 token 表)。
"""
import functools
import hashlib
import hmac
import secrets
import time
from collections import defaultdict
from dataclasses import dataclass
from typing import Annotated
from fastapi import Depends, HTTPException, Request
# ---- 密码哈希(scrypt,从 db.py 上移;偏差注记见模块 docstring)------------------
_SCRYPT_N = 2 ** 14
_SCRYPT_R = 8
_SCRYPT_P = 1
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
"""scrypt(password, salt) → (password_hash, salt_hex)。
password_hash 为版本化串 "scrypt$<n>$<r>$<p>$<salt_hex>$<hash_hex>":
参数与摘要同存,将来提参/换算法时旧行自解释,有迁移路径(M-1)。
salt 返回值(列存)与串内嵌的同一盐 hex——单一写方在此同步两处,
verify 以串内为准。salt 缺省每次随机 16 字节;显式传 salt 可复现
(测试/verify)。"""
if salt is None:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode("utf-8"), salt=salt,
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P)
versioned = (
f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}"
f"${salt.hex()}${digest.hex()}")
return versioned, salt.hex()
def verify_password(password: str, password_hash: str, salt: str) -> bool:
"""解析版本化串重算比对,常量时间比较(hmac.compare_digest)。
纯参式 (password, password_hash, salt)(M-2b 解 ORM 绑定):salt 仅在
无前缀过渡行时使用(列存 hex),版本化行以串内为准。
解析兼容(M-1): 带 "scrypt$" 前缀 → 参数/盐/摘要全取自串内;无前缀
(过渡/手工行)→ 盐取 salt 列、参数取当前默认。任何解析失败(坏 hex/
坏段数/坏整数)按验败返回 False 而非炸——存储异常不该变 500。"""
stored = password_hash
try:
if stored.startswith("scrypt$"):
parts = stored.split("$")
if len(parts) != 6:
return False
_, n_s, r_s, p_s, salt_hex, hash_hex = parts
n, r, p = int(n_s), int(r_s), int(p_s)
salt_bytes = bytes.fromhex(salt_hex)
expected = bytes.fromhex(hash_hex)
else:
salt_bytes = bytes.fromhex(salt)
expected = bytes.fromhex(stored)
n, r, p = _SCRYPT_N, _SCRYPT_R, _SCRYPT_P
except ValueError:
return False
actual = hashlib.scrypt(
password.encode("utf-8"), salt=salt_bytes, n=n, r=r, p=p)
return hmac.compare_digest(expected, actual)
@functools.lru_cache(maxsize=1)
def dummy_credentials() -> tuple[str, str]:
"""固定哑凭据 (password_hash, salt)——登录查无用户时跑一次同代价
verify,抹平"用户不存在秒回/密码错 50ms"的时序枚举面。公共面:
auth_routes 的防枚举时序等化依赖它。"""
return hash_password("", salt=b"\x00" * 16)
# ---- TokenStore -----------------------------------------------------------------
TOKEN_TTL_SECONDS = 8 * 3600 # 默认 8h(可注入,测试拨窗用)
@dataclass
class _TokenRecord:
user_id: int
role: str # "super" | "tenant"
tenant_slug: str | None
must_change_password: bool
expires_at: float # time.time() 纪元秒
class TokenStore:
"""内存 token 表: token → 用户快照。单进程纪律见模块 docstring。
must_change_password 是签发时快照;change-password 就地清零当前
token 的标志(auth_routes 语义注记),其他会话的旧 token 保持各自
快照(重登后消除)。"""
def __init__(self, ttl_seconds: float = TOKEN_TTL_SECONDS) -> None:
self.ttl_seconds = ttl_seconds
self._tokens: dict[str, _TokenRecord] = {}
def issue(
self, *, user_id: int, role: str, tenant_slug: str | None,
must_change_password: bool) -> str:
self._prune() # 懒清理(签发时)
token = secrets.token_urlsafe(32) # 256 bit,碰撞可忽略
self._tokens[token] = _TokenRecord(
user_id=user_id, role=role, tenant_slug=tenant_slug,
must_change_password=must_change_password,
expires_at=time.time() + self.ttl_seconds)
return token
def validate(self, token: str) -> _TokenRecord | None:
"""有效返回快照;缺失/过期返回 None(过期顺手删除——懒 prune)。"""
record = self._tokens.get(token)
if record is None:
return None
if record.expires_at <= time.time():
del self._tokens[token]
return None
return record
def revoke(self, token: str) -> None:
"""logout: 立即失效。不存在也静默(重复 logout 幂等)。"""
self._tokens.pop(token, None)
def clear_must_change(self, token: str) -> None:
"""改密成功后就地清当前 token 的引导标志(见类 docstring)。"""
record = self._tokens.get(token)
if record is not None:
record.must_change_password = False
def _prune(self) -> None:
now = time.time()
for token in [t for t, r in self._tokens.items()
if r.expires_at <= now]:
del self._tokens[token]
# ---- LoginLimiter ---------------------------------------------------------------
LOGIN_WINDOW_SECONDS = 15 * 60
LOGIN_MAX_FAILS = 5
class LoginLimiter:
"""(username, ip) 键滑动窗失败计数: 窗内第 5 次失败后拒绝后续尝试,
窗过恢复;登录成功即清该键计数(正确密码不受历史失败连累)。
单进程内存态(同 TokenStore 纪律);风格照抄 rate_limit.py。只计
失败不计成功——正常用户重复登录不锁。"""
def __init__(
self,
window_seconds: float = LOGIN_WINDOW_SECONDS,
max_fails: int = LOGIN_MAX_FAILS) -> None:
self.window_seconds = window_seconds
self.max_fails = max_fails
self._fails: dict[tuple[str, str], list[float]] = defaultdict(list)
def allow(self, username: str, ip: str) -> bool:
"""窗内失败数是否仍低于上限(不记录——记录走 record_fail)。"""
self._prune((username, ip), time.time())
return len(self._fails.get((username, ip), ())) < self.max_fails
def record_fail(self, username: str, ip: str) -> None:
key = (username, ip)
now = time.time()
self._prune(key, now)
self._fails[key].append(now)
def reset(self, username: str, ip: str) -> None:
"""登录成功清计数(窗口恢复的正路)。"""
self._fails.pop((username, ip), None)
def _prune(self, key: tuple[str, str], now: float) -> None:
fails = self._fails.get(key)
if fails is None:
return
kept = [t for t in fails if t > now - self.window_seconds]
if kept:
self._fails[key] = kept
else:
self._fails.pop(key, None)
def sweep(self, now: float | None = None) -> int:
"""清扫过期键(spec 2026-10-19 §7——内存上限铁律):删「全部时间戳
均已出窗」的键,返回清除数。与 _prune 的单键懒清理互补——allow/
record_fail 只顺手清被回访的键,失败一次即消失的键(扫号换 IP/
用户名)永不被回访,靠本方法全表兜底;由 main.lifespan 周期任务
调用。时间戳按记录序追加,ts[-1] 即最新——最新出窗则全键出窗。"""
now = time.time() if now is None else now
dead = [k for k, ts in self._fails.items()
if not ts or ts[-1] <= now - self.window_seconds]
for k in dead:
del self._fails[k]
return len(dead)
# 模块级单例: 路由与依赖共用同一份进程内存态(同 rate_limit._buckets 模式)
token_store = TokenStore()
login_limiter = LoginLimiter()
def _reset_for_test() -> None:
"""单例复位(测试隔离): token/失败计数全清,注入的 TTL/窗长还原默认。
conftest autouse 前后各跑一次(照抄 _reset_rate_limit 纪律)。"""
token_store._tokens.clear()
token_store.ttl_seconds = TOKEN_TTL_SECONDS
login_limiter._fails.clear()
login_limiter.window_seconds = LOGIN_WINDOW_SECONDS
# ---- FastAPI 依赖 ------------------------------------------------------------------
@dataclass(frozen=True)
class UserContext:
user_id: int
role: str # "super" | "tenant"
tenant_slug: str | None # super 为 None
must_change_password: bool
def bearer_token(request: Request) -> str | None:
"""Authorization: Bearer <token> → token;非该形态返回 None。
公共面:get_current_user 依赖与 auth_routes 的 logout/change-password
(依赖验过后重读头取原串)共用同一解析,不各写一份漂移。"""
header = request.headers.get("authorization", "")
if not header.startswith("Bearer "):
return None
return header[len("Bearer "):].strip()
async def get_current_user(request: Request) -> UserContext:
"""Bearer 解析 → TokenStore 查验 → UserContext。
缺头/坏格式/未知/过期一律 401(静态文案,不细分原因——token 本身
就是凭据,细化文案只会泄露信息)。"""
token = bearer_token(request)
record = token_store.validate(token) if token else None
if record is None:
raise HTTPException(status_code=401, detail="未认证或凭证已失效")
return UserContext(
user_id=record.user_id,
role=record.role,
tenant_slug=record.tenant_slug,
must_change_password=record.must_change_password)
CurrentUser = Annotated[UserContext, Depends(get_current_user)]
def enforce_password_changed(user: CurrentUser) -> UserContext:
"""must_change_password=True → 409:除 login/change-password 外的受保护
端点都要过本依赖(引导/重置出的初始密码不得只当摆设——spec 强制
语义;auth_routes.logout 是 Task 2 的挂载位)。"""
if user.must_change_password:
raise HTTPException(status_code=409, detail="必须先修改初始密码")
return user
# 组合依赖: Task 5 起全部 /api/platform/* 受保护端点统一注
# `user: platform_user`(= get_current_user + enforce_password_changed,
# 链式 Depends 自动先认证后查标志)。
platform_user = Annotated[UserContext, Depends(enforce_password_changed)]
def require_super(user: platform_user) -> UserContext:
"""非 super → 403(端点级覆盖在 Task 5;本任务依赖直测)。
参数走 platform_user(认证+已改密连锁):Task 5 路由挂本依赖时
must_change 409 门自动生效,不可能静默漏挂。"""
if user.role != "super":
raise HTTPException(status_code=403, detail="需要平台超管权限")
return user
def require_tenant(slug: str):
"""依赖工厂: super 或指定租户的管理员放行,否则 403。
参数同 require_super 走 platform_user——409 门连锁,理由见其
docstring。"""
def _dep(user: platform_user) -> UserContext:
if user.role == "super" or user.tenant_slug == slug:
return user
raise HTTPException(status_code=403, detail="无权访问该租户资源")
return _dep