- 认证: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
72 lines
2.2 KiB
Python
72 lines
2.2 KiB
Python
"""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))
|