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
+6
View File
@@ -80,9 +80,15 @@ app = FastAPI(title="HESUPIPE AI 客服", lifespan=lifespan)
from app.engine.routes.chat import router as chat_router
from app.engine.routes.chat_rest import router as chat_rest_router
from app.engine.routes.public import router as public_router
from app.platform.routes.auth_routes import router as platform_auth_router
app.include_router(chat_router)
app.include_router(chat_rest_router)
app.include_router(public_router)
# 平台认证路由(Task 2):本任务即挂——已知租户 Host(如 jiancai)下
# /api/platform/auth/* 经 Host 中间件直达,TDD 循环闭环。Task 3 将为
# /api/platform/ 前缀加中间件豁免(平台后台不依赖租户域名解析);
# 豁免≠放行——认证由路由依赖(get_current_user 系)守护。
app.include_router(platform_auth_router)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+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}
+11
View File
@@ -75,6 +75,17 @@ def _reset_conn_limiter():
_reset_for_test()
@pytest.fixture(autouse=True)
def _reset_platform_auth():
"""Phase 2 Task 2: 平台认证模块级单例(内存 token 表/登录限流失败
计数)前后复位,跨测试不泄漏——token/限流窗不隔离会串测试(前一脚的
锁定状态顶掉后一脚的登录);注入的 TTL/窗长一并还原默认。"""
from app.platform.auth import _reset_for_test
_reset_for_test()
yield
_reset_for_test()
@pytest.fixture
def jiancai_runtime():
"""直连测试用的 jiancai TenantRuntime(Task 6: manager 的 runtime 必填)。
+161
View File
@@ -0,0 +1,161 @@
"""平台认证(Phase 2 Task 2):login/logout/change-password + 认证依赖。
经 app_client(jiancai Host)打真实路由——Task 3 前 /api/platform/* 仍过
租户 Host 解析,jiancai 是已知域名故直达(计划注:本任务即挂路由,TDD
循环才能闭环)。conftest 的 lifespan bootstrap 超管 admin/admin123
(must_change=True)每测试全新临时平台库(Task 1 就绪)。
改密后 token 语义(本实现选定,见 auth_routes.change_password docstring):
当前 token 不失效、must_change 就地清零——改完密无需重登。
跨测试隔离:conftest autouse _reset_platform_auth 复位内存 token 表与
登录限流计数(照抄 rate_limit 纪律)。
"""
import pytest
from fastapi import HTTPException
from app.platform.auth import (
UserContext,
login_limiter,
require_super,
require_tenant,
)
LOGIN = "/api/platform/auth/login"
LOGOUT = "/api/platform/auth/logout"
CHANGE_PW = "/api/platform/auth/change-password"
def _login(client, username="admin", password="admin123"):
return client.post(LOGIN, json={"username": username, "password": password})
def _auth(token):
return {"Authorization": f"Bearer {token}"}
# ---- 登录 ---------------------------------------------------------------------
def test_login_ok_returns_token(app_client):
r = _login(app_client)
assert r.status_code == 200
data = r.json()
assert set(data) == {"token", "role", "tenant_slug", "must_change_password"}
assert data["role"] == "super"
assert data["tenant_slug"] is None
assert data["must_change_password"] is True # 引导密码须首登改掉
assert data["token"]
def test_login_bad_password_401(app_client):
r = _login(app_client, password="wrong")
assert r.status_code == 401
# 防枚举: 用户不存在与密码错同一响应(状态+文案都不区分)
r2 = _login(app_client, username="nosuchuser")
assert r2.status_code == 401
assert r2.json() == r.json()
def test_login_lockout_after_5_fails(app_client):
for _ in range(5):
assert _login(app_client, password="wrong").status_code == 401
# 第 6 次 429——即使密码正确(锁定检查先于验证)
assert _login(app_client).status_code == 429
# 窗口恢复: 已记录失败拨到窗外(等效 15 分钟过后,免真实等待)→ 可再登录
key = ("admin", "testclient")
login_limiter._fails[key] = [
t - login_limiter.window_seconds - 1
for t in login_limiter._fails[key]]
assert _login(app_client).status_code == 200
# ---- token 生命周期 ------------------------------------------------------------
def test_token_expiry(app_client, monkeypatch):
"""TTL 可注入: 拨成负值 → 签发即过期 → 受保护端点 401。"""
from app.platform import auth
monkeypatch.setattr(auth.token_store, "ttl_seconds", -1)
r = _login(app_client)
assert r.status_code == 200
token = r.json()["token"]
r2 = app_client.post(
CHANGE_PW, headers=_auth(token),
json={"old_password": "admin123", "new_password": "n3w-pass!"})
assert r2.status_code == 401
def test_protected_requires_auth(app_client):
"""无 Authorization → 401(依赖链 get_current_user 先于 enforce)。"""
assert app_client.post(LOGOUT).status_code == 401
def test_logout_invalidates_token(app_client):
token = _login(app_client).json()["token"]
# must_change=True → logout 409,先改密(enforce 语义连锁,见下)
app_client.post(
CHANGE_PW, headers=_auth(token),
json={"old_password": "admin123", "new_password": "n3w-pass-2"})
assert app_client.post(LOGOUT, headers=_auth(token)).status_code == 200
# 同 token 再用 → 401(已撤销,立即失效)
assert app_client.post(LOGOUT, headers=_auth(token)).status_code == 401
# ---- 改密 + must_change 409 语义 -------------------------------------------------
def test_change_password_flow(app_client):
token = _login(app_client).json()["token"]
# 旧密改新密成功
r = app_client.post(
CHANGE_PW, headers=_auth(token),
json={"old_password": "admin123", "new_password": "n3w-pass-1"})
assert r.status_code == 200
# 选定语义: 改密后当前 token 不失效、must_change 就地清零
# (logout 受 enforce 保护,改密后可 200 即证 token 存活且标志已清)
assert app_client.post(LOGOUT, headers=_auth(token)).status_code == 200
# 旧密失效
assert _login(app_client).status_code == 401
# 新密可登录,引导标志已清
data = _login(app_client, password="n3w-pass-1").json()
assert data["must_change_password"] is False
def test_must_change_password_409_semantics(app_client):
"""引导密码强制语义: must_change=True 时受保护端点(logout)409;
change-password 本身豁免(200);改完 409 消除。"""
token = _login(app_client).json()["token"]
assert app_client.post(LOGOUT, headers=_auth(token)).status_code == 409
r = app_client.post(
CHANGE_PW, headers=_auth(token),
json={"old_password": "admin123", "new_password": "n3w-pass-3"})
assert r.status_code == 200
assert app_client.post(LOGOUT, headers=_auth(token)).status_code == 200
# ---- 角色依赖(工厂直测;端点级覆盖在 Task 5)---------------------------------------
def test_tenant_admin_role_scoped():
tenant_admin = UserContext(
user_id=2, role="tenant", tenant_slug="jiancai",
must_change_password=False)
with pytest.raises(HTTPException) as ei:
require_super(tenant_admin)
assert ei.value.status_code == 403
dep = require_tenant("jiancai")
assert dep(tenant_admin) is tenant_admin # 本租户放行
super_user = UserContext(
user_id=1, role="super", tenant_slug=None, must_change_password=False)
assert dep(super_user) is super_user # super 全通
other = UserContext(
user_id=3, role="tenant", tenant_slug="auto",
must_change_password=False)
with pytest.raises(HTTPException) as ei2:
dep(other)
assert ei2.value.status_code == 403 # 它租户 403
+11 -11
View File
@@ -70,8 +70,8 @@ async def test_bootstrap_creates_superuser_once(tmp_path):
assert len(users) == 1 and users[0].username == "admin"
assert users[0].tenant_id is None # NULL = 超管
assert users[0].must_change_password is True # 引导密码必须首登改掉
assert verify_password("s3cret!", users[0])
assert not verify_password("other", users[0])
assert verify_password("s3cret!", users[0].password_hash, users[0].salt)
assert not verify_password("other", users[0].password_hash, users[0].salt)
finally:
await engine.dispose()
@@ -85,7 +85,7 @@ async def test_bootstrap_noop_when_any_user_exists(tmp_path):
await bootstrap_superuser(engine, username="root", password="second")
users = await list_admin_users(engine)
assert len(users) == 1 and users[0].username == "admin"
assert verify_password("first", users[0])
assert verify_password("first", users[0].password_hash, users[0].salt)
finally:
await engine.dispose()
@@ -128,23 +128,23 @@ def test_hash_password_versioned_format_and_fresh_salt():
def test_verify_password_bad_stored_hash_returns_false():
"""存储异常按验败处理不炸(M-4):坏 hex/坏段数/坏参数 → False;
无前缀过渡行兼容:salt 列 + 默认参数仍可验真(M-1 解析兼容)。"""
无前缀过渡行兼容:salt 列 + 默认参数仍可验真(M-1 解析兼容)。
Task 2 起 verify 纯参式 (password, password_hash, salt)——不再需要
ORM 实体即可直测。"""
bad_stored = [
"not-hex!", # 无前缀,坏 hex
"scrypt$16384$8$1$deadbeef", # 段数不足
"scrypt$abc$8$1$00$00", # 参数非整数
]
for stored in bad_stored:
user = AdminUser(username="u", password_hash=stored, salt="zz")
assert verify_password("whatever", user) is False
assert verify_password("whatever", stored, "zz") is False
salt = bytes(16)
digest = hashlib.scrypt( # 裸摘要 + salt 列的过渡行形态
b"legacy", salt=salt, n=2 ** 14, r=8, p=1)
legacy = AdminUser(
username="u", password_hash=digest.hex(), salt=salt.hex())
assert verify_password("legacy", legacy) is True
assert verify_password("other", legacy) is False
legacy_hash, legacy_salt = digest.hex(), salt.hex()
assert verify_password("legacy", legacy_hash, legacy_salt) is True
assert verify_password("other", legacy_hash, legacy_salt) is False
# ---- 模型约束 -------------------------------------------------------------
@@ -323,5 +323,5 @@ def test_lifespan_platform_engine_seam_and_bootstrap(tmp_path, monkeypatch):
users = asyncio.run(_probe())
assert [u.username for u in users] == ["bootadmin"]
assert verify_password("boot-pw", users[0])
assert verify_password("boot-pw", users[0].password_hash, users[0].salt)
assert users[0].must_change_password is True