✨ feat: 阶段 0 收尾——认证边界、请求上下文与垂直切片(WP-01 第 5-7 步)

- 认证:Web Session(__Host- Cookie、HttpOnly/Secure/SameSite=Lax、CSPRNG opaque token 数据库只存哈希)、CSRF 双提交、Origin 校验、dev 登录/登出端点;未认证 401、缺 CSRF 403
- 中间件:Hunjing-Request-ID 回显/生成(request_ UUIDv4)+ W3C traceparent 校验透传;JSON 结构化日志与敏感头脱敏
- 迁移 2:conversation/branch/message/run/idempotency_record 五表(含检查约束与复合索引);Session 补 token/csrf 哈希列
- 垂直切片:POST /v1/conversations/{id}/messages 按 Idempotency-Key 幂等(同键同体 200 重放、异体 409),同事务原子写 Message+Run+Audit+Outbox+幂等记录;ContentBlock text/markdown 判别联合(未知类型 422)
- SSE:GET /v1/runs/{id}/events 返回快照与终止状态事件 envelope
- 测试 26 项全绿:IDOR 跨域 404 fail-closed、失败注入回滚无孤儿(Run 崩溃后 message/run/audit/outbox/幂等全零)、SSE 事件序、auth 边界;ruff/mypy strict 干净
- 修复真实问题:asyncpg 跨事件循环(anyio/pytest-asyncio 双 loop、模块级 engine 改为 app 实例持有)、httpx 拒存 http 下 Secure Cookie
This commit is contained in:
chenxin committed 2026-09-10 10:13:45 +08:00
1 parent 28127949db
commit ba502297ac
16 files changed
+1165

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
"""认证依赖:当前用户解析与状态变更防护。"""
from typing import Annotated
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.service import SESSION_COOKIE, resolve_session
from app.db.session import get_db
from app.models import User
ALLOWED_ORIGINS: set[str] = set() # 由配置注入;空集表示本地开发放行同源
async def get_current_user(
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
token = request.cookies.get(SESSION_COOKIE)
if token is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "未登录")
session = await resolve_session(db, token)
if session is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "会话无效或已过期")
user = await db.get(User, session.user_id)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "会话主体不存在")
# 请求级上下文供后续依赖使用(不重复查询)
request.state.session_id = session.id
request.state.session_csrf_hash = session.csrf_token_hash
return user
async def require_state_change(
request: Request,
user: Annotated[User, Depends(get_current_user)],
hunjing_csrf_token: Annotated[str | None, Header(alias="Hunjing-CSRF-Token")] = None,
) -> User:
"""Cookie 状态变更:Origin 校验 + CSRF 双提交(契约 2.1 节)。"""
if ALLOWED_ORIGINS:
origin = request.headers.get("Origin")
if origin is not None and origin not in ALLOWED_ORIGINS:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Origin 不被允许")
csrf_hash = getattr(request.state, "session_csrf_hash", None)
if csrf_hash is None or not _csrf_matches(csrf_hash, hunjing_csrf_token):
raise HTTPException(status.HTTP_403_FORBIDDEN, "CSRF 校验失败")
return user
def _csrf_matches(expected_hash: str, provided: str | None) -> bool:
import hashlib
import secrets
if provided is None:
return False
return secrets.compare_digest(expected_hash, hashlib.sha256(provided.encode()).hexdigest())