diff --git a/qy_lty/.planning/phases/03-client-and-log-mask/03-RESEARCH.md b/qy_lty/.planning/phases/03-client-and-log-mask/03-RESEARCH.md new file mode 100644 index 0000000..d45f167 --- /dev/null +++ b/qy_lty/.planning/phases/03-client-and-log-mask/03-RESEARCH.md @@ -0,0 +1,793 @@ +# Phase 3:客户端读取与日志脱敏 - Research + +**Researched**: 2026-05-08 +**Domain**: Django 4.2 客户端 REST 接口 + Python `logging.Filter` 日志脱敏 +**Confidence**: HIGH(所有结论均通过 read 实证仓库源码得出) + +## Summary + +本 phase 在仓库现状下属于**纯结构落地** —— Phase 1 / Phase 2 已经把模型、序列化器、`mask_token`、`get_solo()`、`success_response`、`RedisTokenAuthentication`、`get_standardized_response_schema` 等所有依赖件全部备齐。CRED-05 的实现是**复刻 `CredentialSlotAdminView`,删 PUT、删 `_ensure_admin`、删 `_build_response_data` 中的 `mask_token` 调用**这三步即可。 + +CRED-06(日志脱敏)的研究核心结论:**当前生产中实际会泄露 `access_token` 明文的代码路径只有 1 条**(不是 CONTEXT 假设的 3 条),即 `userapp/authentication.py:23` 的 `logger.debug(f"Authorization header: {token}")`,且该路径上的 token 是 user/admin token(Authorization 头),**不是** `CredentialSlot.access_token` 字段。`CredentialSlot.access_token` **没有任何代码路径**会经 logger 输出 —— `StandardResponseMiddleware` 不打日志(见验证),view 层只 `logger.warning` 用户 id(不打 access_token 字段),Django 默认 access log 不包含 body。 + +但 CRED-06 的存在意义仍然成立:**为未来防御性兜底**。任何后续开发者写 `logger.info(f"PUT body: {request.data}")` 这类语句,都会一次性把 `access_token` 明文打到 Aliyun 日志服务(INFO 级 → `aliyun` handler)。`AccessTokenMaskFilter` 挂在 LOGGING.handlers 上是这层防御的标准实现。 + +**Primary recommendation**:CRED-05 在 `aiapp/urls.py` 加一行;客户端命名空间根据现有约定走 `qy_lty/urls.py:50` 的 `path('ai/', include('aiapp.urls'))` 已经收容的子 urls —— 但 CONTEXT 锁定路径是 `/api/credential-slot/`(不带 `ai/` 前缀),所以**实际选定**的注册位置是直接在 `qy_lty/urls.py` 的 `api_urlpatterns` 列表中加一行 `path('credential-slot/', CredentialSlotClientView.as_view(), ...)`(详见下文「客户端路由汇总点」)。CRED-06 在 `common/logging/filters.py`(**新建文件**,含 `__init__.py`)里实现 `AccessTokenMaskFilter`。 + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| 客户端 GET 凭据明文 | API / Backend(DRF APIView) | — | 与 Phase 2 同位置(aiapp/views.py),同 model 同 serializer,行为反向 | +| 鉴权识别 user token | 横切(已有 `RedisTokenAuthentication`) | — | 复用 Phase 2 已 import | +| 路由注册 | 项目级 url 顶层(qy_lty/urls.py) | — | CONTEXT 锁定 `/api/credential-slot/` 不在任何 app 的 sub-namespace 下 | +| 日志脱敏 filter | 横切(common/logging/) | LOGGING handler 配置(settings.py) | 单一 filter 类挂到所有 handler,覆盖所有 logger 路径 | +| 修改记录条目 | docs/修改记录.md(仅 qy_lty) | — | CONTEXT 明确不写 qy-lty-admin 互引(Unity 客户端不在 qy-lty-admin) | + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**CRED-05 客户端 GET 接口**: +- 路径:`/api/credential-slot/`(与管理端 `/api/v1/admin/credential-slot/` 完全分开;客户端走 `/api/` 一级命名空间) +- HTTP 方法:仅 GET(客户端不能写) +- View 类:`CredentialSlotClientView`(与 `CredentialSlotAdminView` 形成对称命名) +- View 实现:自定义 `APIView` + 仅 `get` 方法 +- View 放置位置:`aiapp/views.py` 末尾(与 `CredentialSlotAdminView` 紧邻) +- 鉴权:`authentication_classes = [RedisTokenAuthentication]` + `permission_classes = [IsAuthenticated]` +- 不做 `is_staff` 校验 —— 手机/设备 user token 与 admin token 都允许 +- 响应序列化器:直接复用 Phase 2 的 `CredentialSlotSerializer`(不新建客户端专用) +- 客户端 view 行为:`success_response(data=serializer.data)` 直返明文(不调 `mask_token`) +- Swagger:method-level `@swagger_auto_schema` + `get_standardized_response_schema`,response description 标注「明文 APP ID + Access Token」 + +**CRED-06 日志脱敏**: +- 走「路径 A」:自定义 `logging.Filter` 实现 +- 文件位置:`common/logging/filters.py`(新建) +- 类名:`AccessTokenMaskFilter(logging.Filter)` +- 注册位置:`settings.LOGGING.filters` + 挂到 `handlers.aliyun` / `handlers.console` 上 +- 不动 `StandardResponseMiddleware` +- 正则建议覆盖 4 种序列化形态:JSON 字符串 / Python dict repr / query string / 其他 + +### Claude's Discretion + +- filter 用 `logging.Filter` 子类还是 `logging.Formatter` 子类(前者改 record,后者改格式化输出 —— 推荐 Filter 更易测) +- 是否在 `StandardResponseMiddleware` 加辅助保险(视 researcher 看到 middleware 实际是否输出日志而定 —— **本 RESEARCH 已证实 middleware 不打日志,故无需加**) + +### Deferred Ideas (OUT OF SCOPE) + +- DB at-rest 加密 access_token 字段(留 v2.x) +- 客户端调用频率限流 +- token 轮换 / refresh token 机制 +- Unity 客户端 SDK 联调(在独立 repo) +- 生产日志脱敏自动化测试(CI) +- DEBUG / CORS_ALLOW_ALL_ORIGINS 收紧(独立 phase) + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| CRED-05 | 客户端 GET `/api/credential-slot/`:user token 鉴权(`token:{token}` Redis key 体系,复用 `RedisTokenAuthentication`);明文返回 `{ app_id, access_token, updated_at }` | `aiapp/views.py:600-687` 提供 `CredentialSlotAdminView` 完整 1:1 模板(删 PUT、删 `_ensure_admin`、删 `mask_token` 调用即可);`get_user_id_from_token` 已支持 admin/user token 双查(`userapp/utils.py:39-45`),双 token 持有者都能通过 `IsAuthenticated` | +| CRED-06 | Access Token 日志过滤:阿里云日志格式化器 / 自定义日志过滤器中识别 `access_token` 字段并脱敏,覆盖 PUT 请求体、admin GET 响应体两条最易泄露路径 | LOGGING 配置位于 `qy_lty/settings.py:372-412`;阿里云 handler 类:`common.aliyun_logging.AliyunLogHandler`(settings.py:378);`mask_token` 已存在于 `common/utils.py:10`;当前仓库零处自定义 `logging.Filter`,需新建 `common/logging/filters.py`(含 `__init__.py`) | + + +## Standard Stack + +### Core(已就位,无需 install) +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Django | 4.2.13 | 项目主框架 | [VERIFIED: settings.py:4 注释 + qy_lty/.planning/codebase/STACK.md] | +| DRF (`rest_framework`) | 3.x(unpinned) | REST 视图层 | [VERIFIED: requirements.txt + INSTALLED_APPS] | +| drf-yasg | unpinned | Swagger schema 自动生成 | [VERIFIED: settings.py:529-554 + qy_lty/urls.py:22-24] | +| `aliyun-log-python-sdk` | unpinned | 阿里云日志 SDK,提供 `LogClient` / `PutLogsRequest` / `LogItem` | [VERIFIED: common/aliyun_logging.py:2 + requirements.txt] | +| Python `logging` | stdlib | filter / formatter / handler 基础设施 | [VERIFIED: stdlib,Django LOGGING dictConfig 直接调用] | + +### Supporting(已就位) +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `common.utils.mask_token` | 项目内 | 末 4 位脱敏 | filter 内调用,复用 Phase 1 实现 | +| `common.responses.success_response` | 项目内 | 标准 200 壳层 | view return | +| `common.swagger_utils.get_standardized_response_schema` | 项目内 | drf-yasg 标准响应 schema | swagger_auto_schema responses | +| `userapp.authentication.RedisTokenAuthentication` | 项目内 | Bearer token 双查(admin / user) | view authentication_classes | + +### Alternatives Considered(不采用) +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| `logging.Filter` 子类 | `logging.Formatter` 子类 | Formatter 改格式化输出文本,对 `record.args` 是 dict / tuple 时改不动结构化字段;Filter 改 `record.msg` + `record.args` 早于 Formatter,更彻底(CONTEXT 已锁定 Filter,此条仅备查) | +| 独立 lib(如 `python-json-logger`) | — | 引入新依赖,违反 CONTEXT 「不引入新依赖」 | +| 在 view / middleware 内显式脱敏 | — | 覆盖面窄、侵入大,CONTEXT 已否决 | + +**版本验证**:本 phase 不安装新包,无需跑 `npm view` / `pip show`。所有依赖在 `requirements.txt` 中已列出(unpinned),生产已运行的版本即为目标版本。 + +## Architecture Patterns + +### System Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ [客户端] Unity Mobile/Device │ +│ │ │ +│ │ GET /api/credential-slot/ Authorization: Bearer │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ Django MIDDLEWARE chain (settings.py:82-95) │ │ +│ │ SecurityMiddleware → CorsMiddleware → AuthenticationMiddleware │ │ +│ │ → StandardResponseMiddleware (不打日志) │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ qy_lty/urls.py:66 path('api/', include(api_urlpatterns)) │ +│ │ │ +│ ▼ │ +│ qy_lty/urls.py:NEW path('credential-slot/', CredentialSlotClientView)│ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ CredentialSlotClientView (aiapp/views.py末尾,新增) │ │ +│ │ authentication_classes = [RedisTokenAuthentication] │ │ +│ │ └─ 双查 admin_token:{token} → token:{token} (utils.py:39-45) │ │ +│ │ logger.debug(f"Authorization header: {token}") ⚠ 已存在泄露 │ │ +│ │ permission_classes = [IsAuthenticated] (admin & user 都通过) │ │ +│ │ get(): │ │ +│ │ instance = CredentialSlot.get_solo() │ │ +│ │ serializer = CredentialSlotSerializer(instance) │ │ +│ │ return success_response(data=serializer.data) ← 明文 access_token│ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 并行:Python logging chain (settings.py:372-412) │ +│ logger ('aiapp' / 'userapp' / 'common' / 'django') │ +│ │ │ +│ ▼ record │ +│ handlers: ['aliyun', 'console'] │ +│ │ ↑ 新增 filters: ['access_token_mask'] │ +│ │ │ +│ ▼ │ +│ AccessTokenMaskFilter.filter(record): ← 新建,common/logging/filters.py│ +│ 重写 record.msg + record.args 中的 access_token 值 │ +│ ▼ │ +│ AliyunLogHandler.emit(record) → 阿里云日志服务(脱敏后) │ +│ StreamHandler.emit(record) → stderr(脱敏后) │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Recommended Project Structure(新增 / 修改文件) + +``` +qy_lty/ +├── qy_lty/ +│ ├── urls.py # [修改] api_urlpatterns 加 credential-slot/ 路径 +│ └── settings.py # [修改] LOGGING.filters 注册 + handlers.X.filters 引用 +│ +├── aiapp/ +│ └── views.py # [修改] 末尾追加 CredentialSlotClientView 类 +│ +├── common/ +│ └── logging/ # [新建] 包目录 +│ ├── __init__.py # [新建] 空文件 +│ └── filters.py # [新建] AccessTokenMaskFilter +│ +└── docs/ + └── 修改记录.md # [修改] 顶部追加 Phase 3 条目(仅 qy_lty 端,不写互引) +``` + +### Pattern 1:客户端 view 1:1 模板 + +**模板源**:`aiapp/views.py:600-687`(`CredentialSlotAdminView`) + +```python +# Source: aiapp/views.py:600-656 (Phase 2 已交付,本 phase 1:1 复刻 GET 部分) + +class CredentialSlotAdminView(APIView): + authentication_classes = [RedisTokenAuthentication] # ← 直接复用 + permission_classes = [IsAuthenticated] # ← 直接复用 + tags = ['通用凭据槽位(管理端)'] + + def _ensure_admin(self, request): # ✗ 客户端 view 删 + if not request.user.is_staff: + return error_response(...) + return None + + def _build_response_data(self, instance): # ✗ 客户端 view 删(明文直返) + serializer = CredentialSlotSerializer(instance) + data = dict(serializer.data) + data['access_token'] = mask_token(instance.access_token) # ← 客户端 view 不脱敏 + return data + + @swagger_auto_schema(...) + def get(self, request): + forbidden = self._ensure_admin(request) # ✗ 客户端 view 删 + if forbidden: + return forbidden + instance = CredentialSlot.get_solo() # ✓ 保留 + data = self._build_response_data(instance) # → 改为 CredentialSlotSerializer(instance).data + return success_response(data=data, message="读取成功") # ✓ 保留 +``` + +**1:1 复刻产物**(客户端 view 骨架,仅供 plan 参考;正式行号 / 缩进由 plan 确定): + +```python +# 在 aiapp/views.py 末尾追加(紧邻 CredentialSlotAdminView 之后) + +class CredentialSlotClientView(APIView): + """通用凭据槽位客户端读取接口(user / admin token 鉴权,明文返回)。 + + GET: 返回明文 app_id + access_token,供手机/设备端实际调用第三方服务。 + """ + authentication_classes = [RedisTokenAuthentication] + permission_classes = [IsAuthenticated] + tags = ['通用凭据槽位(客户端)'] + + @swagger_auto_schema( + operation_description="读取通用凭据槽位(明文 access_token 返回,user/admin token 均允许)", + responses={ + 200: openapi.Response('读取成功', get_standardized_response_schema(...)), + 401: openapi.Response('未提供有效 token', get_standardized_response_schema()), + }, + security=[{'Bearer': []}], + tags=['通用凭据槽位(客户端)'], + ) + def get(self, request): + instance = CredentialSlot.get_solo() + serializer = CredentialSlotSerializer(instance) + return success_response(data=serializer.data, message="读取成功") +``` + +**与 admin view 的差异点(plan 必须明确实现)**: +1. 删 `_ensure_admin` 整个方法 +2. 删 `_build_response_data` 整个方法(直接 `serializer.data` 即可) +3. 删 PUT method 与 PUT 的 swagger_auto_schema +4. swagger response data schema **不再**是 `_credential_slot_data_schema`(脱敏掩码语义);新建一份明文 schema,`access_token` description 标注「明文 Access Token,供手机/设备端实际调用第三方服务」 + +### Pattern 2:客户端路由注册 + +**当前 `qy_lty/urls.py:49-60` 的 `api_urlpatterns`**(实测): + +```python +# qy_lty/urls.py:49-60 (verbatim) +api_urlpatterns = [ + path('user/', include('userapp.urls')), # /api/user/* + path('ai/', include('aiapp.urls')), # /api/ai/* + # path('ali/vi/api/', include('ali_vi_app.urls')), # 已禁用 + path('device/', include('device_interaction.urls')), # /api/device/* + path('card/', include('card.urls')), # /api/card/* + path('achievement/', include('achievement_app.urls')),# /api/achievement/* + path('food/', include('food_app.urls')), # /api/food/* + path('common/upload/', upload_file, name='file-upload'), + path('v1/admin/', include('userapp.admin_urls')), # /api/v1/admin/* +] +``` + +**关键观察**: +- 所有客户端命名空间都是 `path('/', include('.urls'))` 风格(user/ai/device/card/achievement/food) +- **没有任何现成的 sub-urls 收容 `/api/credential-slot/`** —— `aiapp/urls.py:8-13` 的 urlpatterns 全部是 `chat//` / `multichat/` / `rtc-chat-history/` / router.urls,根本不挂在 `/api/<某 app>/credential-slot/` 下,必须放 `/api/credential-slot/`(CONTEXT 锁定) +- 候选 A(在 `qy_lty/urls.py:api_urlpatterns` 直接加):✓ 正确,与 `path('common/upload/', upload_file, ...)` 同风格 —— 单一 path() 项作为零层后兜底 +- 候选 B(在 `aiapp/urls.py` 加):✗ 错误,会变成 `/api/ai/credential-slot/` 而不是 `/api/credential-slot/` +- 候选 C(参考现有客户端接口):参考 `qy_lty/urls.py:57` 的 `path('common/upload/', upload_file, name='file-upload')` —— 单一 view 直接挂到 api_urlpatterns 顶层,无 sub-include;这就是本 phase 的 1:1 风格参考 + +**最终选定的 urls.py 文件路径**:`qy_lty/urls.py` + +**最终选定的注册代码行**(在 `api_urlpatterns` 列表内追加,建议位置:紧邻 `path('common/upload/', upload_file, name='file-upload'),` 之后、`path('v1/admin/', include('userapp.admin_urls')),` 之前,以保持「客户端散点 → admin 命名空间」的视觉分组): + +```python +# qy_lty/urls.py 顶部 imports 段追加: +from aiapp.views import CredentialSlotClientView + +# qy_lty/urls.py:49-60 api_urlpatterns 列表中追加: +path('credential-slot/', CredentialSlotClientView.as_view(), name='client_credential_slot'), +``` + +### Pattern 3:`AccessTokenMaskFilter` 注册骨架 + +**Django 4.2 LOGGING dictConfig 标准结构**(含 filters)—— `[CITED: docs.djangoproject.com/en/4.2/topics/logging/]`: + +```python +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + + # 新增段(当前 settings.py:372-412 没有 filters 段,需新增) + 'filters': { + 'access_token_mask': { + '()': 'common.logging.filters.AccessTokenMaskFilter', + }, + }, + + 'handlers': { + 'aliyun': { + 'level': 'INFO', + 'class': 'common.aliyun_logging.AliyunLogHandler', + 'filters': ['access_token_mask'], # ← 新增 + }, + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'filters': ['access_token_mask'], # ← 新增 + }, + }, + + 'loggers': { + # 现有 4 条 logger 配置无需改动 —— filter 是挂在 handler 上的,不是 logger 上 + 'django': { 'handlers': ['aliyun', 'console'], 'level': 'INFO', 'propagate': True }, + 'django.request': { 'handlers': ['console'], 'level': 'ERROR', 'propagate': False }, + 'aiapp': { 'handlers': ['aliyun', 'console'], 'level': 'INFO', 'propagate': True }, + 'common': { 'handlers': ['aliyun', 'console'], 'level': 'INFO', 'propagate': True }, + 'userapp': { 'handlers': ['aliyun', 'console'], 'level': 'INFO', 'propagate': True }, + }, +} +``` + +**`AccessTokenMaskFilter` 类骨架**(`common/logging/filters.py`): + +```python +import logging +import re +from common.utils import mask_token + + +class AccessTokenMaskFilter(logging.Filter): + """识别日志记录中的 access_token 明文值并脱敏(保留末 4 位)。 + + 覆盖三种序列化形态: + 1. JSON 字符串: '"access_token": "VALUE"' 或 '"access_token":"VALUE"' + 2. Python repr: "'access_token': 'VALUE'" 或 "access_token=VALUE" + 3. URL query: 'access_token=VALUE&...' 或 'access_token=VALUE' + + 挂载点:settings.LOGGING.filters['access_token_mask'] → + settings.LOGGING.handlers.{aliyun|console}.filters + """ + + # 正则模式说明(plan 阶段确认 / 微调) + _PATTERNS = [ + # 1) JSON 字符串:双引号 + re.compile(r'("access_token"\s*:\s*")([^"]+)(")'), + # 2) Python dict repr:单引号 + re.compile(r"('access_token'\s*:\s*')([^']+)(')"), + # 3) URL query:以 `&` / 空格 / 行尾为终止 + re.compile(r'(access_token=)([^&\s"\']+)'), + # 4) 兜底:等号或冒号 + 空格分隔(容错 logger.info("access_token: VALUE") 风格) + re.compile(r'(access_token\s*[:=]\s*)([^\s,;)\]\}"\']+)'), + ] + + def _mask_in_text(self, text: str) -> str: + if not isinstance(text, str) or 'access_token' not in text.lower(): + return text + for pattern in self._PATTERNS: + text = pattern.sub(lambda m: self._sub_mask(m), text) + return text + + def _sub_mask(self, match) -> str: + groups = match.groups() + if len(groups) == 3: + # 模式 1 / 2:('"access_token":"', VALUE, '"') + return groups[0] + mask_token(groups[1]) + groups[2] + elif len(groups) == 2: + # 模式 3 / 4:('access_token=', VALUE) + return groups[0] + mask_token(groups[1]) + return match.group(0) + + def filter(self, record: logging.LogRecord) -> bool: + # 1. record.msg 字符串脱敏 + if isinstance(record.msg, str): + record.msg = self._mask_in_text(record.msg) + + # 2. record.args 元组 / 字典中的元素脱敏 + if record.args: + if isinstance(record.args, dict): + record.args = { + k: (mask_token(v) if k == 'access_token' and isinstance(v, str) + else self._mask_in_text(v) if isinstance(v, str) + else v) + for k, v in record.args.items() + } + elif isinstance(record.args, tuple): + record.args = tuple( + self._mask_in_text(a) if isinstance(a, str) else a + for a in record.args + ) + + return True # filter 永远不丢弃 record +``` + +**关键要点**: +- `()` 工厂语法是 Django dictConfig 创建无参可调用对象的标准写法,参见 [CITED: https://docs.djangoproject.com/en/4.2/topics/logging/#configuring-logging] 「Configuring filters」段 +- filter 必须 `return True`(False 会丢弃 record) +- `mask_token` 复用 `common/utils.py:10-32`,行为与 admin view 一致 + +### Anti-Patterns to Avoid + +- **在 logger 名上挂 filter**(loggers.X.filters):filter 挂在 logger 上仅过滤直接通过该 logger 的 record,不过滤通过 handler 的;挂在 handler 上才统一覆盖所有 logger → handler 的链路。本 phase 必须挂在 handlers,不挂在 loggers。 +- **用 `logging.Formatter` 子类替代 Filter**:Formatter 是「格式化输出文本」阶段,对 `record.args` 是 dict / 复杂结构时改不动;CONTEXT 也锁定走 Filter,本注解仅作为复盘备查。 +- **在 view 内做 try/except 包裹脱敏**:view 应只关心业务,脱敏是横切关注点 —— 走 filter 不要往 view 里塞。 +- **regex 写成贪婪 `.+`**:会跨字段误吃。本 phase 4 个模式全部用 `[^"]+` / `[^']+` / `[^&\s]+` 限定终止符。 + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| 客户端凭据 GET serializer | 新建 `CredentialSlotClientSerializer` | 复用 `aiapp.serializers.CredentialSlotSerializer` | CONTEXT 已锁定;字段集与管理端完全一致;分化会造成两处更新漂移 | +| 客户端 token 校验 | 新建 client-only auth class | 复用 `RedisTokenAuthentication` | `get_user_id_from_token` (utils.py:39-45) 已经先查 admin_token 再查 token,admin / user 都返回 user_id;`IsAuthenticated` 对两种 token 都通过 —— 这正是 CONTEXT 决策「admin user 也是手机用户的超集」的实现基础 | +| 单例获取 | 新建 `CredentialSlot.objects.get_or_create(pk=1)` | 复用 `CredentialSlot.get_solo()` | Phase 1 已实现,单一入口 | +| 末 4 位脱敏 | 自己写正则切片 | 复用 `common.utils.mask_token` | Phase 1 已实现,行为已被 Phase 2 验收 | +| 标准响应壳层 | 手搓 dict + Response | 复用 `success_response` / `error_response` | 与 Phase 2 一致 | +| Swagger response 标准化 schema | 手写 openapi.Schema | 复用 `get_standardized_response_schema` | Phase 2 admin view 已用,drf-yasg 中间件兜层一致 | + +**关键洞察**:本 phase 95% 的「实现」是「复用」。CRED-05 真正的新代码只有 view 类骨架(约 25-30 行)+ 1 行 url 注册。CRED-06 真正的新代码只有 filter 类(约 60-80 行)+ settings.LOGGING 4-5 行修改。 + +## Runtime State Inventory + +> 本 phase 不涉及 rename / refactor / migration —— 是纯新增 view + 新增 filter,无需迁移现有数据 / 配置 / 注册项。 +> +> **结论**:跳过此节(无 runtime state 需要清点)。 + +## Common Pitfalls + +### Pitfall 1:filter 挂错位置(loggers vs handlers) +**What goes wrong**:filter 挂在 `loggers['aiapp'].filters` 而不是 `handlers['aliyun'].filters`,结果只过滤直接通过 `getLogger('aiapp')` 的 record,**不**过滤 root logger 或 `django` logger 走到同一 aliyun handler 的 record。 +**Why it happens**:Python `logging` filter 在 logger 和 handler 两个层级都能挂;新手不区分。 +**How to avoid**:CONTEXT 已明示「在 LOGGING.handlers.aliyun / console 等 handler 上配置 filters: ['access_token_mask']」。plan 必须把 filter 挂在 **handlers 段**,不要挂在 loggers 段。 +**Warning signs**:grep 验证日志中含 `'aiapp'` 模块 logger 输出脱敏,但 `'common'` 或 `'userapp'` 模块的 access_token 输出仍是明文 → 说明 filter 漏挂。 + +### Pitfall 2:`AliyunLogHandler.emit` 用 `record.getMessage()` 但不更新 `record.msg` +**What goes wrong**:filter 改了 `record.msg`,但若有人用 `record.message` 或在 filter 之前已经调用过 `record.getMessage()` 缓存了原始消息,handler emit 时用的可能是旧值。 +**Why it happens**:`record.message` 是 `Formatter.format()` 调用 `record.getMessage()` 后赋值的属性。filter 在 handler 里执行时 `record.message` 还不存在;handler emit 调用 `record.getMessage()` 会基于 `record.msg` + `record.args` 实时拼出 → 只要 filter 改了 `record.msg` 和 `record.args`,emit 时拿到的就是脱敏后的版本。验证 `common/aliyun_logging.py:23` 用的就是 `record.getMessage()`,符合预期。 +**How to avoid**:filter 同时改 `record.msg` 和 `record.args`,不只改 `record.msg`。本 RESEARCH 的骨架已包含两者。 +**Warning signs**:手动测试 `logger.info("access_token=%s", "test_zzz")` 时 console 仍输出 `test_zzz` 明文 → 说明只改了 `record.msg` 没改 `record.args`。 + +### Pitfall 3:误把 user/admin Bearer token 当 access_token 脱敏 +**What goes wrong**:`userapp/authentication.py:23` 的 `logger.debug(f"Authorization header: {token}")` 是 user/admin token(30 天 Redis 凭证),不是 `CredentialSlot.access_token`。filter 的正则应**只**匹配 `access_token` 字段名,不要泛化到 `Authorization header:` 或 `token:` 这类格式 —— 那是另一类敏感数据,不在本 phase 范围。 +**Why it happens**:开发者一看到「token」字样就想脱敏全部。 +**How to avoid**:filter 正则 4 个模式全部以 `access_token` 字段名为前缀锚点,不脱敏裸 token / Bearer / Authorization header。 +**Warning signs**:用户在 Aliyun 日志里发现 `Authorization header:` 后面的 user-token 也被改成 `*****xxxx` —— 说明 filter 写得太宽,需要收紧。 +**Note**:`logger.debug` 在 LOGGING 配置下走 `'django'` logger(`__name__='userapp.authentication'` 命中 root 兜底;root 默认 WARNING,所以实际 DEBUG 不会被任何 handler 处理);该行**当前在生产环境不输出**。详见下文「Access Token 实际泄露路径」。 + +### Pitfall 4:`access_token` 字段值含 JSON 特殊字符未转义 +**What goes wrong**:若 `access_token` 值含 `"` 或 `\`,正则的 `[^"]+` 会提前终止,匹配残缺。 +**Why it happens**:access_token 一般是 base64 / hex / 大小写字母数字混合,第三方服务商 (阿里云 / 火山 / 腾讯) 都不会在 token 中放 JSON 特殊字符;但理论上不严密。 +**How to avoid**:本 phase 的 access_token 由阿里云 / 第三方 SDK 颁发,业界惯例是 alphanumeric + `-_.~`。可接受 4 个模式的正则覆盖。若未来出现含 `"` 的 token,filter 可降级为「字段值整体打 `***`」处理。 +**Warning signs**:日志中出现 `"access_token": "abc\"def\"...` 残缺脱敏。 + +### Pitfall 5:Django 4.2 dictConfig 工厂语法 +**What goes wrong**:写成 `'class': 'common.logging.filters.AccessTokenMaskFilter'` 而不是 `'()': 'common.logging.filters.AccessTokenMaskFilter'`。 +**Why it happens**:handler 用 `'class'`,但 filter 用 `'()'`。 +**How to avoid**:filter 段用 `'()': 'dotted.path.to.FilterClass'`(无参实例化);如要传参,再加 `'arg1': value`。本 phase 无参,骨架已写正确。 +**Warning signs**:启动报 `ValueError: Unable to configure filter 'access_token_mask'` 或类似。 + +## Code Examples + +### Example 1:客户端 view 注册(完整改动 diff 等价物) + +```python +# Source: 1:1 复刻 aiapp/views.py:600-656 (CredentialSlotAdminView) +# 增量:CredentialSlotClientView 类(约 25 行)+ 1 行 url 注册 + +# === 1) aiapp/views.py 末尾追加 === +class CredentialSlotClientView(APIView): + """通用凭据槽位客户端读取接口(user/admin token 鉴权,明文返回)。""" + authentication_classes = [RedisTokenAuthentication] + permission_classes = [IsAuthenticated] + tags = ['通用凭据槽位(客户端)'] + + @swagger_auto_schema( + operation_description="读取通用凭据槽位(明文 access_token,供手机/设备端实际调用第三方)", + responses={ + 200: openapi.Response( + '读取成功', + get_standardized_response_schema(_credential_slot_client_data_schema), + ), + 401: openapi.Response('未提供有效 token', get_standardized_response_schema()), + }, + security=[{'Bearer': []}], + tags=['通用凭据槽位(客户端)'], + ) + def get(self, request): + instance = CredentialSlot.get_solo() + serializer = CredentialSlotSerializer(instance) + return success_response(data=serializer.data, message="读取成功") + + +# === 2) aiapp/views.py 中追加客户端 schema(紧邻 _credential_slot_data_schema)=== +_credential_slot_client_data_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'app_id': openapi.Schema(type=openapi.TYPE_STRING, description='第三方服务商分配的 APP ID(明文)'), + 'access_token': openapi.Schema( + type=openapi.TYPE_STRING, + description='明文 Access Token,供手机/设备端实际调用第三方服务(管理端同接口会脱敏返回末 4 位)', + ), + 'updated_at': openapi.Schema(type=openapi.TYPE_STRING, format='date-time'), + }, +) + + +# === 3) qy_lty/urls.py imports 段追加 === +from aiapp.views import CredentialSlotClientView + +# === 4) qy_lty/urls.py api_urlpatterns 列表追加(在 common/upload/ 之后、v1/admin/ 之前) === +api_urlpatterns = [ + path('user/', include('userapp.urls')), + path('ai/', include('aiapp.urls')), + path('device/', include('device_interaction.urls')), + path('card/', include('card.urls')), + path('achievement/', include('achievement_app.urls')), + path('food/', include('food_app.urls')), + path('common/upload/', upload_file, name='file-upload'), + path('credential-slot/', CredentialSlotClientView.as_view(), name='client_credential_slot'), # ← 新增 + path('v1/admin/', include('userapp.admin_urls')), +] +``` + +### Example 2:filter 注册(settings.LOGGING diff 等价物) + +```python +# Source: qy_lty/settings.py:372-412 + Django 4.2 logging dictConfig 标准 + +# 修改前(settings.py:372-412 当前实测): +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'aliyun': {'level': 'INFO', 'class': 'common.aliyun_logging.AliyunLogHandler'}, + 'console': {'level': 'DEBUG', 'class': 'logging.StreamHandler'}, + }, + 'loggers': { + 'django': {'handlers': ['aliyun', 'console'], 'level': 'INFO', 'propagate': True}, + 'django.request': {'handlers': ['console'], 'level': 'ERROR', 'propagate': False}, + 'aiapp': {'handlers': ['aliyun', 'console'], 'level': 'INFO', 'propagate': True}, + 'common': {'handlers': ['aliyun', 'console'], 'level': 'INFO', 'propagate': True}, + 'userapp': {'handlers': ['aliyun', 'console'], 'level': 'INFO', 'propagate': True}, + }, +} + +# 修改后: +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'filters': { # ← 新增段 + 'access_token_mask': { + '()': 'common.logging.filters.AccessTokenMaskFilter', + }, + }, + 'handlers': { + 'aliyun': { + 'level': 'INFO', + 'class': 'common.aliyun_logging.AliyunLogHandler', + 'filters': ['access_token_mask'], # ← 新增 + }, + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'filters': ['access_token_mask'], # ← 新增 + }, + }, + 'loggers': { # 无改动 + 'django': {'handlers': ['aliyun', 'console'], 'level': 'INFO', 'propagate': True}, + 'django.request': {'handlers': ['console'], 'level': 'ERROR', 'propagate': False}, + 'aiapp': {'handlers': ['aliyun', 'console'], 'level': 'INFO', 'propagate': True}, + 'common': {'handlers': ['aliyun', 'console'], 'level': 'INFO', 'propagate': True}, + 'userapp': {'handlers': ['aliyun', 'console'], 'level': 'INFO', 'propagate': True}, + }, +} +``` + +### Example 3:`RedisTokenAuthentication` 双 token 兼容性证实 + +```python +# Source: userapp/utils.py:39-45 (verbatim) +def get_user_id_from_token(token): + # 优先尝试从管理员token中获取 + user_id = cache.get(f"admin_token:{token}") + if user_id is not None: + return user_id + # 再尝试从普通用户token中获取 + return cache.get(f"token:{token}") +``` + +```python +# Source: userapp/authentication.py:14-34 (verbatim) +def authenticate(self, request): + authorization = request.headers.get('Authorization') + if not authorization: + return None + if len(authorization.split(' ')) < 2: + return None + token = authorization.split(' ')[1] + if not token: + return None + logger.debug(f"Authorization header: {token}") # ⚠ 见下文「实际泄露路径」#3 注 + + user_id = get_user_id_from_token(token) # ← 双查 admin/user,admin 优先 + if not user_id: + raise AuthenticationFailed('Invalid token') + + try: + user = ParadiseUser.objects.get(id=user_id) + except ParadiseUser.DoesNotExist: + raise AuthenticationFailed('User not found') + + return (user, None) # ← 返回真实 ParadiseUser 对象 +``` + +**结论证实 CONTEXT 「访问无 admin 限制」的 token 决策**: +- admin token 与 user token 都返回真实的 `ParadiseUser` 对象 +- admin user 一般 `is_staff=True`,普通 user `is_staff=False`,但 `IsAuthenticated` 检查的是 `request.user.is_authenticated`(继承自 `AbstractUser`,注册过的 user 永远 True),**不**检查 `is_staff` +- 因此 `permission_classes = [IsAuthenticated]` 对两种 token 都通过 ✓ —— 这就是 CONTEXT「都允许」决策的实现基础 +- Phase 2 admin view 用 `_ensure_admin(request)` 显式检查 `request.user.is_staff` 来拒绝 user token;**Phase 3 客户端 view 直接不调 `_ensure_admin`**,达到「都允许」效果 + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| 无 | logging.Filter dictConfig | Python 3.x stdlib | filter 是 Python `logging` 模块自 Python 2.x 起就有的标准能力,Django 4.2 LOGGING dictConfig 完全支持;本 phase 不依赖新特性 | +| 自己写 access log 中间件 | DRF + `StandardResponseMiddleware`(不打 body 日志) | 项目已有 | 已确认中间件不打日志,无需改 | + +**Deprecated/outdated**:本 phase 无 deprecated 项 —— 全部走 Python stdlib + Django 4.2 + 已有项目工具链。 + +## Assumptions Log + +> 本 RESEARCH 所有结论均通过 read 实证仓库源码或 grep 验证,**无需用户确认**任何假设。 +> +> 若 plan 阶段发现以下「实证结论」不符合用户预期,再回到 discuss-phase 修正: + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | `RedisTokenAuthentication` 对 admin / user token 都返回 `is_authenticated=True` 的 user 对象 | Code Examples Ex.3 | 若 risk 为真:admin token 持有者无法访问 client view,需要在 client view 加显式分支 —— 但这违反 CONTEXT「都允许」决策。**已通过 read userapp/utils.py + authentication.py + Phase 2 验收记录证实,无 risk** | +| A2 | `StandardResponseMiddleware.process_response` 不调用任何 `logger.xxx`(即不打日志) | access_token 实际泄露路径 + Pitfall 复盘 | 若 risk 为真:会有第 4 条隐蔽泄露路径需要覆盖 —— **已通过 grep `logger\.[a-z]+` in common/middleware.py 验证,0 命中,无 risk** | +| A3 | 仓库现有 view 中没有任何代码 `logger.xxx(... CredentialSlot.access_token ...)` | access_token 实际泄露路径 | 若 risk 为真:会有具体 view 内 logger 泄露需要单独修 —— **已通过 grep `logger\..*token` 全仓验证:仅 user/admin token(Bearer)和 RTC token,无 CredentialSlot.access_token 字段,无 risk** | + +## Open Questions + +无 open questions —— 本 phase 所有决策均已被 CONTEXT 锁定 + 本 RESEARCH 实证。 + +## Environment Availability + +> 本 phase 是纯 Python 代码改动,无外部工具 / 服务依赖,跳过此节。 +> +> (Aliyun 日志服务在生产已可用 —— 见 `common/aliyun_logging.py` 已稳定运行;Redis/PostgreSQL 与 Phase 1/2 共享,已可用。) + +## Validation Architecture + +> 检查 `qy_lty/.planning/config.json` 是否设置 `workflow.nyquist_validation: false` —— 实地无 .planning/config.json 文件,按缺省「enabled」处理。 + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | 当前仓库 **未配置** pytest / pytest-django;候选优先级 #5(brownfield 候选项已记录)尚未消化。本 phase 沿用 Phase 2 同款验收方式:`django.test.Client` 程序化端到端测试(in-process,不启 daphne) | +| Config file | 无(每个 phase 临时写 `_phaseN_verify.py` 跑完即删,参考 Phase 2 的 `_phase2_verify.py` 模式) | +| Quick run command | `python manage.py shell < _phase3_verify.py`(plan 阶段创建脚本) | +| Full suite command | 同上 | + +### Phase Requirements → Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| CRED-05 | client GET 携 user token → 200 + 明文 access_token | integration | `django.test.Client.get('/api/credential-slot/', HTTP_AUTHORIZATION=f'Bearer {user_token}')` | ❌ Wave 0:plan 阶段写 `_phase3_verify.py` | +| CRED-05 | client GET 携 admin token → 200 + 明文(不区分) | integration | 同上换 admin_token | ❌ Wave 0 | +| CRED-05 | client GET 无 token → 401 + 标准壳层 | integration | `Client.get('/api/credential-slot/')` | ❌ Wave 0 | +| CRED-05 | client GET 过期 token → 401 | integration | `Client.get(... HTTP_AUTHORIZATION='Bearer fake_token_zzz')` | ❌ Wave 0 | +| CRED-05 | swagger 暴露 `/api/credential-slot/` 路径 | integration | `Client.get('/swagger.json/')` + path key 校验 | ❌ Wave 0 | +| CRED-05 | 端到端往返:admin PUT roundtrip → client GET 拿到一致明文 | integration | 串联 PUT + GET | ❌ Wave 0 | +| CRED-06 | filter 单元:`logger.info("access_token=secret_zzz")` 后 `record.msg` 已脱敏 | unit | `pytest`-free 自检:实例化 filter,构造 LogRecord,调 `filter()`,assert `record.getMessage()` 含 `*****zzz` 不含 `secret_zzz` | ❌ Wave 0 | +| CRED-06 | filter 4 种模式覆盖:JSON / Python repr / query / 等号兜底 | unit | 同上分 4 case | ❌ Wave 0 | +| CRED-06 | 日志链路真实输出:捕获 stderr,触发 `PUT /api/v1/admin/credential-slot/`,断言 stderr 不含完整明文 | integration | `Client.put(...)` + capsys / StringIO redirect | ❌ Wave 0 | + +### Sampling Rate +- **Per task commit**:`python manage.py shell < _phase3_verify.py` 全量跑一次 +- **Per wave merge**:同上 +- **Phase gate**:CRED-05 的 6 项 client view + CRED-06 的 3 项 filter 全 PASS + +### Wave 0 Gaps +- [ ] `_phase3_verify.py`(仓库根,plan 阶段写、phase end 删)—— 端到端 client view + filter 行为校验 +- [ ] CredentialSlot DB 终态:以 Phase 2 `02-VERIFICATION.md` 末尾「DB 终态记录」(`probe_app` / `probe_secret_xxxx`) 为起点;Phase 3 验收前后 DB 状态保持不变(验收脚本主动还原) +- [ ] 无独立 test framework 引入 —— 与 Phase 2 一致沿用 `django.test.Client` 模式 + +## Security Domain + +> 检查 `qy_lty/.planning/config.json` 是否设置 `security_enforcement: false` —— 实地无 .planning/config.json 文件,按缺省「enabled」处理。 + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes | `RedisTokenAuthentication`(已有,TTL 30 天 Redis 凭证) | +| V3 Session Management | partial | Token 即 session 替代,30 天 TTL;本 phase 不引入新 session 机制 | +| V4 Access Control | yes | `IsAuthenticated`(client view)/ `_ensure_admin`(admin view 已落地) | +| V5 Input Validation | n/a | client view 仅 GET,无请求 body | +| V6 Cryptography | n/a | 不在本 phase(DB at-rest 加密已 deferred) | +| V7 Error Handling and Logging | **yes** | **本 phase 核心**:`AccessTokenMaskFilter` 防止敏感字段进 Aliyun 日志服务 | +| V9 Communications | partial | HTTPS 由反向代理(Nginx)兜底,不在本 phase | +| V12 API and Web Service | yes | DRF + drf-yasg + Bearer token,已落地 | + +### Known Threat Patterns for Django + DRF + Aliyun Log Service + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| `logger.info(f"PUT body: {request.data}")` 类型代码导致 access_token 进生产日志 | Information Disclosure | 本 phase `AccessTokenMaskFilter` 在 handler 层兜底,覆盖未来开发者的不慎 logger 调用 | +| Bearer token 在 `Authorization header:` 日志被打印 | Information Disclosure | **不在本 phase 范围**(CRED-06 仅针对 access_token 字段;user/admin Bearer token 是另一类敏感数据,留 v2.x 候选优先级 #1 / #3 处理) | +| 客户端 view 被 admin token 调用 | Privilege Escalation(false positive) | 不构成提权 —— 客户端 view 返回的数据是「明文 APP ID + Access Token」,admin 通过管理端 PUT 接口本来就能写明文,所以读明文不是新增信息(CONTEXT 已论证) | +| swagger 暴露 client endpoint 给未鉴权读者 | Information Disclosure | swagger 本身仅暴露 schema 不暴露数据;schema 中 description 标注「明文,需 token」即可(与 admin view 同模式) | + +## Sources + +### Primary (HIGH confidence) +- `qy_lty/qy_lty/settings.py:372-412` —— LOGGING 配置全貌(read 实证) +- `qy_lty/qy_lty/urls.py:49-60` —— api_urlpatterns 完整列表(read 实证) +- `qy_lty/aiapp/views.py:600-687` —— `CredentialSlotAdminView` 完整模板(read 实证) +- `qy_lty/aiapp/views.py:1-19` —— imports 段(确认 RedisTokenAuthentication / IsAuthenticated / mask_token / success_response / get_standardized_response_schema 均已可用) +- `qy_lty/aiapp/urls.py:1-13` —— 现有 ai 子 urlpatterns(read 实证) +- `qy_lty/userapp/admin_urls.py:1-15` —— admin 命名空间注册(read 实证 + Phase 2 模板) +- `qy_lty/userapp/urls.py:1-32` —— 客户端命名空间风格参考(read 实证) +- `qy_lty/userapp/authentication.py:1-34` —— `RedisTokenAuthentication` + `logger.debug(f"Authorization header: {token}")` 行号 23(read 实证) +- `qy_lty/userapp/utils.py:39-45` —— `get_user_id_from_token` 双查逻辑(read 实证) +- `qy_lty/common/middleware.py:1-145` —— `StandardResponseMiddleware` 全文(read 实证:0 处 logger 调用) +- `qy_lty/common/utils.py:10-32` —— `mask_token` 行为契约(read 实证) +- `qy_lty/common/responses.py:41-52` —— `success_response` 行为(read 实证) +- `qy_lty/common/aliyun_logging.py:16-36` —— `AliyunLogHandler` 类(read 实证:用 `record.getMessage()`,filter 改 `record.msg` 后即生效) +- `qy_lty/.planning/phases/02-admin-rest/02-VERIFICATION.md` —— Phase 2 验收记录 + DB 终态(read 实证) + +### Secondary (MEDIUM confidence) +- 无 —— 本 phase 不依赖外部文档,所有结论均通过仓库源码 read 实证 + +### Tertiary (LOW confidence) +- 无 —— 不依赖 web search + +### CITED 文档参考(按需查阅,非本 phase 阻塞项) +- [CITED: https://docs.djangoproject.com/en/4.2/topics/logging/#configuring-logging] Django 4.2 LOGGING dictConfig 标准(filters / handlers / loggers 三段式 + `()` 工厂语法) +- [CITED: https://docs.python.org/3/library/logging.html#logging.Filter] Python `logging.Filter` `filter(record)` 接口(return True/False,可改 record 属性) + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH —— 全部已存在于 requirements.txt;本 phase 不引入新依赖;版本沿用生产已运行版本 +- Architecture: HIGH —— 1:1 复刻 Phase 2 admin view 模板,CRED-06 的 LOGGING.filters 是 Django dictConfig 标准 +- Pitfalls: HIGH —— 通过实证仓库现状(middleware 不打日志 / view 不打 access_token / filter 注册位置)已逐项验证 + +**Research date**: 2026-05-08 +**Valid until**: 2026-06-08(30 天,因仓库代码相对稳定 + 本 phase 是纯增量改动;超期前若 Phase 1/2 文件被改动,需重新 read 验证) + +--- + +## 附录:access_token 实际泄露路径详细分析 + +### CONTEXT 假设的 3 条路径 vs 实际证据 + +| # | CONTEXT 假设的路径 | 实际是否泄露 | 证据 | +|---|------|------|------| +| 1 | 管理端 PUT 请求体(`access_token` 进日志) | ✗ 不会自动泄露 | `StandardResponseMiddleware.process_response` 与 `process_exception` **不调任何 logger**(grep `logger\.` in common/middleware.py = 0 命中);Django 默认 access log(`django.request` logger)只在 ERROR 级别(settings.py:391-394 配置 ERROR + console only)输出 stack trace,不输出请求 body;除非未来开发者在 `CredentialSlotAdminView.put` 内显式 `logger.info(f"PUT body: {request.data}")`,否则 PUT 请求体永不进 logger | +| 2 | 管理端 GET 响应体(`access_token` 进日志) | ✗ 不会泄露 | 同上 + Phase 2 admin view GET 已经在 view 层 `_build_response_data` 中 `mask_token` 替换 access_token 字段;即使开发者后续加 `logger.info(success_response.data)`,也只会打到末 4 位脱敏值。**真正的明文 access_token 仅在 `serializer.save()` 的 instance 内存对象中,未走 logger 路径** | +| 3 | 客户端 GET 响应体(`access_token` 进日志) | ✗ 不会自动泄露 | 客户端 view 返回 `success_response(data=serializer.data)`,serializer.data 的明文 access_token 在 Response 对象内,被 `StandardResponseMiddleware` 包裹后写 `response.content`,但**不进 logger**;同 #1,除非开发者显式 logger 调用,否则不泄露 | + +### 实际确实存在的泄露代码位置 + +经全仓 grep `logger\.[a-z]+.*token` 验证,**真实存在**的 token 进 logger 代码点: + +| 文件:行号 | 代码 | 影响范围 | 是否本 phase 范围 | +|---|---|---|---| +| `userapp/authentication.py:23` | `logger.debug(f"Authorization header: {token}")` | **每次** 携 Bearer token 的 HTTP 请求都会 debug-log Bearer token 明文 | ⚠ 范围外但相邻:这是 user/admin **登录 token**(30 天 Redis 凭证),不是 `CredentialSlot.access_token`。当前 LOGGING 配置 `userapp` logger level=INFO(settings.py:406-410),DEBUG 级别**不会被任何 handler 处理**,故生产环境**不实际输出**。但若未来有人改成 `logger.info` 或调高 logger level,会立刻泄露 30 天 Redis token | +| `device_interaction/auth.py:47` | `print(f"Token authentication error: {str(e)}")` | WebSocket 鉴权异常时 print | 范围外:是 exception message 不含 token 值 | +| `device_interaction/views.py:1215` | `logger.error(f"Failed to get token by MAC address: {str(e)}")` | RTC token 失败 path | 范围外:exception message 不含 token | +| `device_interaction/consumers.py:23/27/75/103` | 4 处 logger.warning/error 关于 token authentication failure | WebSocket 鉴权失败路径 | 范围外:exception 不含 token 值 | +| `aiapp/audio/AliyunAudioService.py:48` | `print("token = " + token)` | NLS 调用 stdout print | ⚠ 是阿里云 NLS appkey/token,与 access_token 字段同名但**值不同**;`AccessTokenMaskFilter` 的字段名匹配会**误伤**这条 print —— 不过 print 不走 logger,filter 不影响 print。stdout 在容器中可能进日志 → 留 v2.x 处理 | +| `aiapp/audio/test.py:38` | 同上 | 测试代码 | 范围外:不在生产路径 | + +### 结论:CRED-06 的真实价值 + +`AccessTokenMaskFilter` 在**当前**仓库状态下**几乎没有 record 可过滤** —— 真正的 `CredentialSlot.access_token` 在生产代码中根本没有进 logger 的路径。 + +**但这恰是 CRED-06 的价值所在 —— 防御性兜底**: +1. 任何后续开发者写 `logger.info(f"PUT body: {request.data}")` 类型代码 → 立即被 filter 兜住 +2. Django 默认 access log 若被升级为 INFO 级别(如调试需求) → filter 兜住 +3. Phase 2 / 3 之外的 view 若需要 dump 整个 model → filter 兜住 + +**强烈建议 plan 阶段**:在 CRED-06 task 内显式新增一个验证步骤 —— +- **触发一次 `PUT /api/v1/admin/credential-slot/`**(用 Phase 2 admin view),并**临时** 在 admin view 末尾加一行 `logger.info(f"PUT 请求体测试: {request.data}")` → +- **验证 stderr / Aliyun 日志**该字段已脱敏 → +- **删掉**这行临时 logger.info(CRED-06 验收完毕)。 + +这样能验证 filter 真实在工作,而不是空跑。 + +--- + +*Phase 3 RESEARCH 完成;下一步 plan-phase 可基于本文件创建任务清单*