diff --git a/app/platform/auth.py b/app/platform/auth.py index b604cb5..89f9d79 100644 --- a/app/platform/auth.py +++ b/app/platform/auth.py @@ -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;非该形态返回 None。""" +def bearer_token(request: Request) -> str | None: + """Authorization: Bearer → 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 diff --git a/app/platform/routes/auth_routes.py b/app/platform/routes/auth_routes.py index 965b9ce..2b3a385 100644 --- a/app/platform/routes/auth_routes.py +++ b/app/platform/routes/auth_routes.py @@ -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} diff --git a/tests/test_auth.py b/tests/test_auth.py index 9f5a8ae..de97d0d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -159,3 +159,37 @@ def test_tenant_admin_role_scoped(): with pytest.raises(HTTPException) as ei2: dep(other) assert ei2.value.status_code == 403 # 它租户 403 + + +def test_role_dependencies_chain_must_change_409(app_client): + """审查修复 Issue 1: require_super/require_tenant 参数走 platform_user + (get_current_user + enforce 连锁)——must_change=True 的请求先撞 409 门, + Task 5 路由挂角色依赖时不可能静默漏挂。 + + 连锁只在 FastAPI 依赖解析时生效(直调函数体只跑角色判断),故用 + 一次性探针 app 挂真实 DI 验证;token 经主 app 的真实 login/change-password + 路由取放(同一进程级 TokenStore)。端点级覆盖仍在 Task 5。""" + from fastapi import Depends, FastAPI + from fastapi.testclient import TestClient + + probe = FastAPI() + + @probe.post("/probe-super") + async def _super(user=Depends(require_super)): + return {"user_id": user.user_id} + + @probe.post("/probe-tenant") + async def _tenant(user=Depends(require_tenant("jiancai"))): + return {"user_id": user.user_id} + + token = _login(app_client).json()["token"] # 超管, must_change=True + with TestClient(probe) as c: + # 409 门先于角色判断 + assert c.post("/probe-super", headers=_auth(token)).status_code == 409 + assert c.post("/probe-tenant", headers=_auth(token)).status_code == 409 + # 改密(标志就地清零)后放行到角色层: super 两个依赖都过 + app_client.post( + CHANGE_PW, headers=_auth(token), + json={"old_password": "admin123", "new_password": "n3w-pass-9"}) + assert c.post("/probe-super", headers=_auth(token)).status_code == 200 + assert c.post("/probe-tenant", headers=_auth(token)).status_code == 200