feat: 平台认证(scrypt上移/内存token 8h/登录限流/改密/角色依赖/409语义)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 08:18:15 +08:00
parent bfcaba5c87
commit f49e8e92b1
8 changed files with 626 additions and 71 deletions
+295
View File
@@ -0,0 +1,295 @@
"""平台认证内核(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"的时序枚举面。"""
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)
# 模块级单例: 路由与依赖共用同一份进程内存态(同 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。"""
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 require_super(user: CurrentUser) -> UserContext:
"""非 super → 403(端点级覆盖在 Task 5;本任务依赖直测)。"""
if user.role != "super":
raise HTTPException(status_code=403, detail="需要平台超管权限")
return user
def require_tenant(slug: str):
"""依赖工厂: super 或指定租户的管理员放行,否则 403。"""
def _dep(user: CurrentUser) -> UserContext:
if user.role == "super" or user.tenant_slug == slug:
return user
raise HTTPException(status_code=403, detail="无权访问该租户资源")
return _dep
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)]
+9 -60
View File
@@ -17,19 +17,16 @@ PlatformBase.metadata,engine/models.ensure_schema 只跑 Base.metadata——
- admin_users:tenant_id NULL = 超管。password_hash 存版本化串
"scrypt$<n>$<r>$<p>$<salt_hex>$<hash_hex>"(参数与摘要同存,将来提参/
换算法有迁移路径,verify 解析兼容);salt 列同步存同一盐 hex(单一写方
hash_password,verify 以串内为准)。哈希 = hashlib.scrypt(n=2**14, r=8,
p=1)——偏差注记:spec §7.2 写 bcrypt/argon2,用 stdlib scrypt 为有意
取舍(零新依赖,抗 GPU 性能同级);verify 走 hmac.compare_digest
常量时间比较。
hash_password,verify 以串内为准)。哈希实现 = hashlib.scrypt(n=2**14,
r=8, p=1)——Task 2 起上移至 platform/auth.py(单一事实源,签名纯参式),
本模块 re-export 兼容既有导入;偏差注记:spec §7.2 写 bcrypt/argon2,
用 stdlib scrypt 为有意取舍(零新依赖,抗 GPU 性能同级)。
测试/teardown 纪律:建过 platform engine 的测试结尾 await engine.dispose()
(Windows 文件锁,同 registry)。
"""
import datetime
import hashlib
import hmac
import pathlib
import secrets
from sqlalchemy import (
JSON,
@@ -51,6 +48,10 @@ from sqlalchemy.ext.asyncio import (
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
# 密码哈希单一定义点:Task 2 上移至 platform/auth.py(纯参式签名,解 ORM
# 绑定——M-2b),此处 re-export 兼容既有 from app.platform.db import 路径
from app.platform.auth import hash_password, verify_password # noqa: F401
def _utcnow() -> datetime.datetime:
# 跟随租户库 models.py 既有约定:datetime.now(timezone.utc)(tz-aware UTC)
@@ -173,59 +174,7 @@ async def ensure_platform_schema(engine: AsyncEngine) -> None:
await conn.run_sync(PlatformBase.metadata.create_all)
# ---- 密码哈希(scrypt,偏差注记见模块 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)。
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, user: AdminUser) -> bool:
"""解析 password_hash 版本化串重算比对,常量时间比较
(hmac.compare_digest)——防时序侧信道。
解析兼容(M-1):带 "scrypt$" 前缀 → 参数/盐/摘要全取自串内;
无前缀(过渡/手工行)→ 盐取 salt 列、参数取当前默认。任何解析失败
(坏 hex/坏段数/坏整数)按验败返回 False 而非炸——存储异常不该变 500。"""
stored = user.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.fromhex(salt_hex)
expected = bytes.fromhex(hash_hex)
else:
salt = bytes.fromhex(user.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, n=n, r=r, p=p)
return hmac.compare_digest(expected, actual)
# ---- 密码哈希:定义在 auth.py(见文件头 re-export)---------------------------
# ---- 超管引导 ---------------------------------------------------------------
+1
View File
@@ -0,0 +1 @@
"""平台层路由包(Phase 2 Task 2 起):/api/platform/* 下的后台端点。"""
+132
View File
@@ -0,0 +1,132 @@
"""平台认证路由(Phase 2 Task 2):/api/platform/auth/{login,logout,change-password}。
挂载在主 app(Task 2 即挂——jiancai 等已知租户 Host 下经 Host 中间件直达,
TDD 循环闭环;Task 3 为 /api/platform/ 前缀加中间件豁免,豁免≠放行——
认证守护在本路由依赖)。
安全纪律:
- 防枚举: 用户不存在/停用/密码错同一 401 静态文案;查无用户时跑等代价
哑 scrypt(auth._dummy_credentials)抹平时序差。
- 密码/token 永不进日志: 本模块不 log 任何凭据字段,HTTPException
detail 均静态串。
- token 走 Authorization: Bearer(无 Cookie 免 CSRF,spec §7.2)。
- 登录限流先于验证: 锁定期内正确密码也 429(不泄露"密码对了")。
"""
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.platform.auth import (
CurrentUser,
_dummy_credentials,
_bearer_token,
hash_password,
login_limiter,
platform_user,
token_store,
verify_password,
)
from app.platform.db import AdminUser, Tenant
router = APIRouter(prefix="/api/platform/auth", tags=["platform-auth"])
BAD_LOGIN_DETAIL = "用户名或密码错误"
LOCKED_DETAIL = "登录尝试过于频繁,请15分钟后再试"
class LoginRequest(BaseModel):
username: str
password: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
def _client_ip(request: Request) -> str:
return request.client.host if request.client else ""
@router.post("/login")
async def login(body: LoginRequest, request: Request):
"""限流 → 查用户(active)→ 验密 → 发 token。"""
ip = _client_ip(request)
if not login_limiter.allow(body.username, ip):
raise HTTPException(status_code=429, detail=LOCKED_DETAIL)
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
row = (await session.execute(
select(AdminUser, Tenant.slug)
.outerjoin(Tenant, AdminUser.tenant_id == Tenant.id)
.filter(AdminUser.username == body.username)
)).first()
user = row[0] if row else None
if user is not None and user.status == "active":
ok = verify_password(body.password, user.password_hash, user.salt)
else:
# 防枚举(时序): 查无用户/停用也跑一次等代价 scrypt,响应耗时与
# 密码错不可区分;结果仍统一 401
dummy_hash, dummy_salt = _dummy_credentials()
verify_password(body.password, dummy_hash, dummy_salt)
ok = False
if not ok:
login_limiter.record_fail(body.username, ip)
raise HTTPException(status_code=401, detail=BAD_LOGIN_DETAIL)
login_limiter.reset(body.username, ip) # 成功清失败计数(窗口恢复正路)
role = "super" if user.tenant_id is None else "tenant"
tenant_slug = row[1]
token = token_store.issue(
user_id=user.id, role=role, tenant_slug=tenant_slug,
must_change_password=bool(user.must_change_password))
return {
"token": token,
"role": role,
"tenant_slug": tenant_slug,
"must_change_password": bool(user.must_change_password),
}
@router.post("/logout")
async def logout(request: Request, user: platform_user):
"""撤销当前 token(立即失效)。
Task 2 的 enforce_password_changed 挂载位:must_change=True → 409
(Task 5 起所有 /api/platform/* 受保护端点统一走 platform_user,
login/change-password 豁免)。依赖已验过 token,此处重读头取原串
撤销——409/401 都在依赖层先行。"""
token_store.revoke(_bearer_token(request) or "")
return {"ok": True}
@router.post("/change-password")
async def change_password(
body: ChangePasswordRequest, request: Request, user: CurrentUser):
"""验旧密 → 写新 hash+salt → must_change 清零。
本端点豁免 enforce_password_changed(否则引导密码死锁:不改不让用、
要用得先改)。
改密后 token 语义(本实现选定并写明):当前 token 不失效——must_change
标志就地在 TokenStore 清零,用户改完密无需重登。旧密码即刻失效
(hash+salt 已换)。其他会话的存量 token 不吊销(内存态实现的简化
取舍;将来要"改密踢全部会话"再按 user_id 索引吊销,接口不用动)。"""
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
db_user = await session.get(AdminUser, user.user_id)
if db_user is None:
raise HTTPException(status_code=401, detail="账号不存在或已删除")
if not verify_password(
body.old_password, db_user.password_hash, db_user.salt):
raise HTTPException(status_code=400, detail="原密码不正确")
db_user.password_hash, db_user.salt = hash_password(body.new_password)
db_user.must_change_password = False
await session.commit()
token_store.clear_must_change(_bearer_token(request) or "")
return {"ok": True, "must_change_password": False}