- 认证: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
81 lines
2.9 KiB
Python
81 lines
2.9 KiB
Python
"""请求上下文中间件与结构化日志。
|
|
|
|
- `Hunjing-Request-ID`:合法则回显,缺失由服务端生成(UUIDv4,request_ 前缀)。
|
|
- `traceparent`:透传 W3C Trace Context;缺失生成新 trace-id(不与业务请求 ID 混用)。
|
|
- JSON 结构化日志:Authorization/Cookie/Set-Cookie 等敏感头脱敏。
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.requests import Request
|
|
|
|
from app.core.ids import new_id
|
|
|
|
REDACTED_HEADERS = {"authorization", "cookie", "set-cookie", "hunjing-csrf-token", "x-api-key"}
|
|
_TRACEPARENT_RE = re.compile(r"^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$")
|
|
|
|
|
|
def _sanitize_headers(headers: Any) -> dict[str, str]:
|
|
return {
|
|
k: ("[REDACTED]" if k.lower() in REDACTED_HEADERS else v) for k, v in headers.items()
|
|
}
|
|
|
|
|
|
class JsonFormatter(logging.Formatter):
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
payload: dict[str, Any] = {
|
|
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
|
|
"level": record.levelname,
|
|
"logger": record.name,
|
|
"message": record.getMessage(),
|
|
}
|
|
for key in ("request_id", "trace_id", "method", "path", "status"):
|
|
value = getattr(record, key, None)
|
|
if value is not None:
|
|
payload[key] = value
|
|
if record.exc_info:
|
|
payload["exception"] = self.formatException(record.exc_info)
|
|
return json.dumps(payload, ensure_ascii=False)
|
|
|
|
|
|
def configure_logging(level: str = "info") -> None:
|
|
handler = logging.StreamHandler()
|
|
handler.setFormatter(JsonFormatter())
|
|
root = logging.getLogger()
|
|
root.handlers = [handler]
|
|
root.setLevel(level.upper())
|
|
|
|
|
|
class RequestContextMiddleware(BaseHTTPMiddleware):
|
|
async def dispatch(self, request: Request, call_next: Any) -> Any:
|
|
request_id = request.headers.get("Hunjing-Request-ID") or new_id("request_", version=4)
|
|
traceparent = request.headers.get("traceparent")
|
|
if traceparent is None or not _TRACEPARENT_RE.match(traceparent):
|
|
traceparent = f"00-{uuid.uuid4().hex}-{uuid.uuid4().hex[:16]}-01"
|
|
trace_id = traceparent.split("-")[1]
|
|
request.state.request_id = request_id
|
|
request.state.trace_id = trace_id
|
|
|
|
response = await call_next(request)
|
|
response.headers["Hunjing-Request-ID"] = request_id
|
|
response.headers["traceparent"] = traceparent
|
|
logging.getLogger("hunjing.access").info(
|
|
"request",
|
|
extra={
|
|
"request_id": request_id,
|
|
"trace_id": trace_id,
|
|
"method": request.method,
|
|
"path": request.url.path,
|
|
"status": response.status_code,
|
|
},
|
|
)
|
|
return response
|
|
|
|
|
|
__all__ = ["RequestContextMiddleware", "configure_logging", "_sanitize_headers"]
|