✨ 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:
1 parent
28127949db
commit
ba502297ac
16 files changed
+1165
No files matched your search
@@ -0,0 +1 @@
|
||||
"""认证模块:Web 会话与依赖。"""
|
||||
@@ -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())
|
||||
@@ -0,0 +1,60 @@
|
||||
"""认证路由:开发档登录/登出(Web Session Cookie)。
|
||||
|
||||
正式 OAuth 2.1 授权码流程(/oauth2/authorize、/oauth2/token、PKCE)属 WP-01
|
||||
后续条目;本路由只建立 Session 边界骨架供垂直切片验证。
|
||||
"""
|
||||
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
|
||||
from app.auth.service import (
|
||||
SESSION_COOKIE,
|
||||
create_session,
|
||||
revoke_session,
|
||||
)
|
||||
from app.db.session import DbSession
|
||||
from app.models import PersonalDomain, User
|
||||
|
||||
router = APIRouter(prefix="/v1/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/sessions", status_code=201)
|
||||
async def dev_login(
|
||||
request: Request, response: Response, db: DbSession, body: dict[str, str]
|
||||
) -> dict[str, str]:
|
||||
"""开发档登录:按 email 找到或创建用户与个人域,签发会话。"""
|
||||
email = body.get("email", "").strip().lower()
|
||||
if not email or "@" not in email:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "email 不合法")
|
||||
from sqlalchemy import select
|
||||
|
||||
existing = (
|
||||
(await db.execute(select(User).where(User.email == email))).scalar_one_or_none()
|
||||
)
|
||||
if existing is None:
|
||||
user = User(email=email, display_name=email.split("@")[0])
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
domain = PersonalDomain(user_id=user.id, name="默认镜域")
|
||||
db.add(domain)
|
||||
else:
|
||||
user = existing
|
||||
_, token, csrf = await create_session(db, user.id)
|
||||
await db.commit()
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
return {"csrf_token": csrf, "user_id": user.id}
|
||||
|
||||
|
||||
@router.delete("/sessions/current", status_code=204)
|
||||
async def logout(request: Request, response: Response, db: DbSession) -> None:
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if token is not None and await revoke_session(db, token):
|
||||
await db.commit()
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Web 会话认证:签发、校验与撤销(契约 api-conventions 第 2 节)。
|
||||
|
||||
- Cookie 名 ``__Host-hunjing_session``:HttpOnly、Secure、SameSite=Lax、Path=/。
|
||||
- Cookie 值为 CSPRNG opaque token;数据库只存 SHA-256 哈希(ADR-015)。
|
||||
- 状态变更请求要求 Origin 校验与 ``Hunjing-CSRF-Token`` 双提交。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Session
|
||||
|
||||
SESSION_COOKIE = "__Host-hunjing_session"
|
||||
SESSION_TTL = timedelta(days=14)
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_session_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def generate_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
async def create_session(db: AsyncSession, user_id: str) -> tuple[Session, str, str]:
|
||||
"""签发会话:返回(会话记录,opaque token,CSRF token)。"""
|
||||
token = generate_session_token()
|
||||
csrf = generate_csrf_token()
|
||||
session = Session(
|
||||
id=f"web_{secrets.token_urlsafe(12)}",
|
||||
user_id=user_id,
|
||||
token_hash=_hash_token(token),
|
||||
csrf_token_hash=_hash_token(csrf),
|
||||
expires_at=datetime.now(UTC) + SESSION_TTL,
|
||||
)
|
||||
db.add(session)
|
||||
return session, token, csrf
|
||||
|
||||
|
||||
async def resolve_session(db: AsyncSession, token: str) -> Session | None:
|
||||
"""按 opaque token 查找有效(未过期、未撤销)会话。"""
|
||||
result = await db.execute(
|
||||
select(Session).where(
|
||||
Session.token_hash == _hash_token(token),
|
||||
Session.revoked_at.is_(None),
|
||||
Session.expires_at > datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def revoke_session(db: AsyncSession, token: str) -> bool:
|
||||
session = await resolve_session(db, token)
|
||||
if session is None:
|
||||
return False
|
||||
session.revoked_at = datetime.now(UTC)
|
||||
return True
|
||||
|
||||
|
||||
def session_is_csrf_valid(session: Session, csrf_header: str | None) -> bool:
|
||||
if csrf_header is None:
|
||||
return False
|
||||
return secrets.compare_digest(session.csrf_token_hash, _hash_token(csrf_header))
|
||||
Reference in new issue
Block a user