fix(mcp-server): two audit-found register bugs

Found by post-fix audit pass over this session's changes:

1. Predcheck used `effective_username = (req.username or "").strip()` but the
   write at the User() construction site still used `req.username` raw. If a
   client sent "alice " with trailing whitespace, predcheck queried for "alice"
   (clean), missed the conflict, then wrote "alice " back. Now both sites use
   the same `effective_username` value — single source of truth.

2. Post-commit `verify_code` was guarded by `except Exception`, but
   `asyncio.CancelledError` is a BaseException and propagates through. If the
   request task is cancelled (client disconnect / pod shutdown) after DB
   commit but before verify_code finishes, the verification code stays in
   Redis with full 10-min TTL. Wrapped with `asyncio.shield(...)` so
   verify_code completes regardless of cancellation, and an explicit
   `except CancelledError: raise` preserves FastAPI's cancellation semantics
   for the outer request.

Verified via smoke:
- Register with username "ws_user_$ts  " (trailing spaces) → DB stores
  "ws_user_$ts" (18 chars, no whitespace). Predcheck and write now agree.
- P4 透传 (balance/models/usage/logs via 55@55.com) still 4/4 — no regression.

Latent bug noticed but NOT introduced this session, deferred:
- auth.py:981 writes ResourceAllocation.resource_id=str(provider.id) for
  model allocations, but channel.py:1881 queries by resource_id==model_name.
  Pre-existing inconsistency means update_tenant_model_quota never finds
  rows created at register time. TenantModelKey row is still updated
  correctly so end-user quota is honored; only the ResourceAllocation
  audit/reporting view diverges. Fix requires deciding which side is
  canonical — out of scope for security hardening.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 17:09:55 +08:00
co-authored by Claude Opus 4.7
parent 1461051755
commit 267172e103
+13 -2
View File
@@ -779,7 +779,9 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
# 创建新用户
password_hash = get_password_hash(req.password)
username = req.username or req.email.split("@")[0]
# 用同一套 effective_username 写入 —— line 745 的预检和这里的写入必须 100%
# 匹配,否则带空格的 username(如 "alice ")会预检通过但写入不一致。
username = effective_username
name = req.full_name or username
new_user = User(
@@ -1053,8 +1055,17 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
# DB commit 已成功 —— 最后才真正消费验证码。这一步即使失败也不再回滚
# (用户已经注册成功,重复消费没意义;Redis 里的过期码不会被复用,因为
# 同 email 第二次 register 会在 line 737 的 existing_user 检查处 400)。
#
# 用 asyncio.shield 保护 verify_code 不被 client disconnect / task cancel
# 半道杀掉 —— 否则 CancelledError 是 BaseException 直接穿透 except
# Exception,Redis 里残留 10min TTL 的码(不影响安全,但占资源)。
import asyncio # local import 避免 module-level 顺序问题
try:
await verify_code(req.email, req.verification_code)
await asyncio.shield(verify_code(req.email, req.verification_code))
except asyncio.CancelledError:
# request 已经被取消,shield 内部的 verify_code 还会跑完。重新抛出,
# 让 FastAPI 走正常取消流程。
raise
except Exception as e:
logger.warning("注册成功后消费验证码失败(不影响注册结果)",
email=req.email, error=str(e))