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
+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}