fix: 认证审查修复(角色依赖连锁409门/停用租户token取舍注记/公共面整理)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 08:29:01 +08:00
parent f49e8e92b1
commit c5e8dd6ded
3 changed files with 77 additions and 28 deletions
+31 -21
View File
@@ -97,9 +97,10 @@ def verify_password(password: str, password_hash: str, salt: str) -> bool:
@functools.lru_cache(maxsize=1)
def _dummy_credentials() -> tuple[str, str]:
def dummy_credentials() -> tuple[str, str]:
"""固定哑凭据 (password_hash, salt)——登录查无用户时跑一次同代价
verify,抹平"用户不存在秒回/密码错 50ms"的时序枚举面。"""
verify,抹平"用户不存在秒回/密码错 50ms"的时序枚举面。公共面:
auth_routes 的防枚举时序等化依赖它。"""
return hash_password("", salt=b"\x00" * 16)
@@ -237,8 +238,11 @@ class UserContext:
must_change_password: bool
def _bearer_token(request: Request) -> str | None:
"""Authorization: Bearer <token> → token;非该形态返回 None。"""
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
@@ -250,7 +254,7 @@ async def get_current_user(request: Request) -> UserContext:
缺头/坏格式/未知/过期一律 401(静态文案,不细分原因——token 本身
就是凭据,细化文案只会泄露信息)。"""
token = _bearer_token(request)
token = bearer_token(request)
record = token_store.validate(token) if token else None
if record is None:
raise HTTPException(status_code=401, detail="未认证或凭证已失效")
@@ -264,22 +268,6 @@ async def get_current_user(request: Request) -> UserContext:
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 强制
@@ -293,3 +281,25 @@ def enforce_password_changed(user: CurrentUser) -> UserContext:
# `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
+12 -7
View File
@@ -6,7 +6,7 @@ TDD 循环闭环;Task 3 为 /api/platform/ 前缀加中间件豁免,豁免≠放
安全纪律:
- 防枚举: 用户不存在/停用/密码错同一 401 静态文案;查无用户时跑等代价
哑 scrypt(auth._dummy_credentials)抹平时序差。
哑 scrypt(auth.dummy_credentials)抹平时序差。
- 密码/token 永不进日志: 本模块不 log 任何凭据字段,HTTPException
detail 均静态串。
- token 走 Authorization: Bearer(无 Cookie 免 CSRF,spec §7.2)。
@@ -19,8 +19,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.platform.auth import (
CurrentUser,
_dummy_credentials,
_bearer_token,
bearer_token,
dummy_credentials,
hash_password,
login_limiter,
platform_user,
@@ -51,7 +51,12 @@ def _client_ip(request: Request) -> str:
@router.post("/login")
async def login(body: LoginRequest, request: Request):
"""限流 → 查用户(active)→ 验密 → 发 token。"""
"""限流 → 查用户(active)→ 验密 → 发 token。
token 是签发时快照(校验不回查库):租户停用后其管理员的存量 token
最长再存活一个 TTL(默认 8h)——本实现接受该窗口,不做停用即时锁死;
若需要即时失效,归 Task 5 租户管理端点(validate 时回查 Tenant.status
并吊销该租户 token),此处注记归属而非留白。"""
ip = _client_ip(request)
if not login_limiter.allow(body.username, ip):
raise HTTPException(status_code=429, detail=LOCKED_DETAIL)
@@ -70,7 +75,7 @@ async def login(body: LoginRequest, request: Request):
else:
# 防枚举(时序): 查无用户/停用也跑一次等代价 scrypt,响应耗时与
# 密码错不可区分;结果仍统一 401
dummy_hash, dummy_salt = _dummy_credentials()
dummy_hash, dummy_salt = dummy_credentials()
verify_password(body.password, dummy_hash, dummy_salt)
ok = False
@@ -100,7 +105,7 @@ async def logout(request: Request, user: platform_user):
(Task 5 起所有 /api/platform/* 受保护端点统一走 platform_user,
login/change-password 豁免)。依赖已验过 token,此处重读头取原串
撤销——409/401 都在依赖层先行。"""
token_store.revoke(_bearer_token(request) or "")
token_store.revoke(bearer_token(request) or "")
return {"ok": True}
@@ -128,5 +133,5 @@ async def change_password(
db_user.must_change_password = False
await session.commit()
token_store.clear_must_change(_bearer_token(request) or "")
token_store.clear_must_change(bearer_token(request) or "")
return {"ok": True, "must_change_password": False}