"""平台认证路由(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}