feat: 平台认证(scrypt上移/内存token 8h/登录限流/改密/角色依赖/409语义)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""平台层路由包(Phase 2 Task 2 起):/api/platform/* 下的后台端点。"""
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user