安全与 guard 链
后端的准入控制由一组全局 Nest Guard 和若干方法级 Guard 组成。全局 Guard 在 apps/server/apps/api/src/app/app.module.ts 的 APP_GUARD 里按顺序装配,方法级 Guard (权限、角色、敏感操作、functional、throttle)经装饰器挂在具体的 controller/handler 上, 在全局链之后执行。
本文写当前实现与部署前提。认证流程本身的端点与令牌语义见 认证全链; 守卫链在 apps/server/AGENTS.md 里有简表,本文是展开版。
1. 全局 guard 链全景
1.1 顺序与职责
IP -> Security -> Device -> Risk(前) -> CAP(前) -> JWT -> Risk(后) -> CAP(后) -> MFA -> Sign -> Lock| # | Guard | 职责 | 主要失败码 | 跳过装饰器 |
|---|---|---|---|---|
| 1 | WalnutAdminGuardIP | 查 realIp 是否在永久(APP:SETTING 的 app.global.ipBlackList)或临时(Redis)黑名单 | 40603 | @WalnutAdminGuardIpFree() |
| 2 | WalnutAdminGuardSecurity | UA 存在性、恶意路径、bot、可疑请求、OS/浏览器白名单 | 40604 / 40610 / 40608 / 40609 / 40601 / 40602 | 无(仅 dev 的 isPostman 旁路) |
| 3 | WalnutAdminGuardDevice | 校验签名 DEVICE_ID cookie;缓存/DB 查设备行;封禁与锁定判定;IP 漂移时更新设备 IP | 40605 / 40606 / 40607 | @WalnutAdminGuardDeviceFree() |
| 4 | WalnutAdminGuardRisk(前) | 采集 pre-auth 频率,评估 IP/设备/失败登录/位置/频率,写 request.risk | 40666(当前不可达,见 P1-13) | 无 |
| 5 | WalnutAdminGuardCap(前) | 读 request.risk.comprehensive.recommendation;强制 CAPJS_TOKEN cookie 与缓存校验 | 40116 / 40117 | @WalnutAdminGuardCapFree() |
| 6 | JwtAccessGuard | Authorization: Bearer <AT>;validate 查 AUTH:SESSIONS | 40101 | @WalnutAdminGuardJwtFree();另有 @WalnutAdminGuardJwtOptional() |
| 7 | WalnutAdminGuardRisk(后) | 合并 post-auth 的 user/userDevice 评估,重写 request.risk | 40666(同前) | 无 |
| 8 | WalnutAdminGuardCap(后) | 与 #5 同一实现;已有 userId 时把 critical factor 标记为用户-设备维度 | 40116 / 40117 | @WalnutAdminGuardCapFree() |
| 9 | WalnutAdminGuardMFA | 已登录用户的 mfaSetup 强制与设备级 MFA 可信缓存 | 40114 / 40115 | @WalnutAdminGuardMFAFree() |
| 10 | WalnutAdminGuardSign | HMAC 签名验签(未登录单因子 / 已登录双因子) | 40112 / 40113 | @WalnutAdminGuardSignFree() |
| 11 | WalnutAdminGuardLock | 用户-设备锁定状态(两个缓存键都缺失时回源 DB;P1-20 修复了旧实现把 miss 折叠成「未锁定」的洞) | 40118 | @WalnutAdminGuardLockFree() |
顺序的隐含约束(改装配顺序前必须逐条核对):
- CAP 依赖 Risk:CAP 只在
request.risk已被前置 Risk 写入时工作;Risk 未运行或评估 异常时 CAP 直接放行。因此把 CAP 提到 Risk 前面等于关掉 CAP。 - Sign 依赖 JWT:已登录请求的双因子密钥需要
req.user.userId/sid;Sign 必须在 JWT 之后。 - MFA 依赖 JWT:
mfaSetup/mfaVerified来自 AT payload,JWT 之后才有req.user。 - Lock 依赖 JWT:锁定判定按
userId + deviceId。 - Risk 跑两次、CAP 跑两次是为了「登录前按设备/IP 判、登录后叠加用户维度」;
app.module.ts里 Risk/CAP 各注册了两个APP_GUARDprovider(同一个类、两个实例), 两轮共享request.risk。 - 方法级 Guard 在全局链之后:
@HasPermission/@HasRole/@RequireSensitive/ functional / throttle 都晚于 Sign 与 Lock。
Free 装饰器的实现都是 SetMetadata(Symbol(...), true),Guard 用 reflector.getAllAndOverride([handler, class]) 读取,handler 优先于 controller 类。 一个端点可以同时挂多个 Free(如 security/sign/* 五连 Free),但每个 Guard 只认自己的键。
req.isPostman 是开发态旁路:UserAgentMiddleware 只在 isDev 且 UA 含 PostmanRuntime 时置 true;Security / Device / CAP / Sign 四家会跳过。IP / Risk / MFA / Lock 不跳过。
1.2 权限与角色(方法级)
权限守卫 WalnutAdminGuardPermission,经 @WalnutAdminDecoratorHasPermission(permission, mode = 'and') 挂载:
- 没有权限元数据:告警并放行(装饰器使用错误)。
- 没有
req.user:40105。 - root 短路:
roleMode === 'combinable' && roleNames[0] === 'root',或roleMode === 'switchable' && currentRoleName === 'root',直接放行。 - visitor 特例:
switchable + visitor时只允许app:shared:ali:sts与所有以:list/:read结尾的权限串;数组按every判定。 - 其余用户读缓存
AUTH:PERMISSIONS:<userId>:<deviceId>:- 键缺失 → 40105(无懒重建);
- 单串:
includes; - 数组:默认
and(every)或显式or(some)。
- 缓存的唯一写入口是
GET /auth/permissions(SysMenuSharedService.getPermissions); signout 删除该键。缓存 TTL 现状见 P1-15。
角色守卫 WalnutAdminGuardRole(@WalnutAdminDecoratorHasRole)只读 AT payload 的 roleNames / currentRoleName,不走缓存;root 短路与 visitor 拒绝同上。数组 and 模式仅在 COMBINE 下真正判定;switchable + and 是误配组合 —— P3-32(2026-10-29)起直接抛 40106, 不再只打日志静默放行(全仓数组调用方都显式传 or,零破坏)。
visitor 拒绝守卫 WalnutAdminGuardVisitorDeny(@WalnutAdminDecoratorVisitorDeny): 方法级守卫,switchable + currentRoleName=visitor → 40105;未登录放行(交 JWT 守卫)。 当前挂在 GET /system/menu/tree(P2-20:菜单树含隐藏行与权限串,visitor 无导航需求 —— 导航来自 /auth/permissions)。
敏感操作守卫 WalnutAdminGuardSensitive(@WalnutAdminGuardRequireSensitive({ level, type })):要求已登录;Redis 键 sensitive_op:<userId>:<deviceId>:<level> 存在即放行 (TTL 15 分钟),否则 40119 并在 meta 里带 checkVerificationStatus 的结果 (是否需要验证、剩余秒数、可用方式、首选方式)。敏感级别的验证方式策略在 sensitive.config.ts 的 SECURITY_LEVEL_STRATEGIES;POST /security/sensitive/verify 成功后才写授权键。
functional 守卫(WalnutAdminGuardFunctional,经 @WalnutAdminDecoratorFunctionalGuard(KEY, detailKey?, options?)):读 APP:SETTING, 键缺失或 detail 缺失时放行,值 != 1 时 50301。这是「零配置默认开启、显式关闭才拦」 的语义。P1-17 起支持 { defaultClosed: true }:键/detail 缺失即 50301(Google 登录 暂时关闭用的就是这条 opt-in 语义)。
throttle 不是全局 Guard,只在挂了 @WalnutAdminDecoratorThrottle(...) 的端点生效 (CAP challenge/redeem、system/device/initial、OTP send)。tracker 默认取 realIp; OTP 的 tracker 是 identifier:ip。动态覆盖只改该端点的 limit/ttl,P1-10 起同步覆盖 blockDuration;P1-16(2026-10-28)修正了 OTP 的元数据键(装饰器 Symbol ↔ 守卫字符串 错位),OtpSettingService.sendLimit/sendTtl 自此真实生效,且动态 ttl 同步进 blockDuration。超限响应是 HTTP 200 + body.code = 42900(P3-1 裁决),Retry-After 头给出剩余秒数。
1.3 数据权限(行级 scope)
功能权限(上一节)决定「能不能调这个接口」,数据权限决定「这个接口能看到/改到哪些行」。 方案与取舍见 apps/docs/src/zh-CN/content/plans/data-scope-design.md §4(应用层 scope + 基类收口 + fail-closed 注册表;PG RLS 留作三期硬上限),部门维度的实施计划见 apps/docs/src/zh-CN/content/plans/data-scope-phase2-3-plan.md §1。
执行链:
JWT Guard(request.user)→ ALS 拦截器解析描述符 → handler → 基类仓库注入谓词apps/server/libs/guards/src/interceptors/request/als.interceptor.ts在请求进入 handler 前 调DataScopeService.resolveForUser(),把userId与描述符种进 ALS;apps/server/libs/repository/src/pg-base.repository.ts的list(在customize之后)、readById、updateByField、deleteSoft*统一andWhere/ scoped where 注入, 越权读/改/删一律 40401(不泄露行存在性);create对注册实体自动回填 owner(如app_demo.created_by);注册项显式开启fillDeptOnCreate时同时回填行级部门归属(app_demo.dept_id取创建者的sys_employee.dept_id,无职工行 → NULL)。归属列不接受 DTO 赋值,update 也会剔除。
六档与多角色(sys_role.data_scope,smallint;二期批 1 起):
| 值 | 模式 | 行级谓词 |
|---|---|---|
| 1 | ALL | 不注入(root 恒 ALL) |
| 2 | CUSTOM | dept 列 IN (sys_role_dept 部门清单)(不递归子树;空清单默认拒绝) |
| 3 | DEPT | dept 列 IN (user.dept_id)(dept 为 NULL → 默认拒绝) |
| 4 | DEPT_AND_CHILD | dept 列 IN (user.dept_id 的递归子树,含自身) |
| 5 | SELF | owner 列 = 当前用户 |
| 6 | DEPT_OR_SELF | owner 列 = 当前用户 OR dept 列 IN (子树) |
部门子树由 dept.basic.repository.findSubtreeById 的递归 CTE(CYCLE 环保护)展开; 注册表实体的 deptColumn 缺失时,2/3/4 档位默认拒绝(6 档位退化为仅 SELF,不泄露)。 一期 → 二期的数值重映射(旧 2 SELF → 新 5、旧 3 CUSTOM → 新 2)在 migrations/1793340000000-DataScopePhase2Dept.ts;CUSTOM 的「角色成员」语义已废除, 部门清单由 sys_role_dept 承载(角色 create/update 的 deptIds 整组替换,read 回显)。
行级部门归属(app_demo 示范):批 2 给 app_demo 加了 dept_id uuid NULL (FK → sys_dept RESTRICT + idx_demo_dept partial index)。部门系档位按该列过滤; NULL 行是「无部门归属」—— 只有 ALL 档位可见,SELF 仍可按 owner 看到自己创建的行, DEPT / DEPT_AND_CHILD / CUSTOM 不会包含它。admin 的角色表单把 CUSTOM 的部门清单 做成部门树多选(sys_role_dept),用户表单把 sys_user.dept_id 做成部门树单选; 两处数据源都是 GET /system/dept/tree。
多角色按 effective roleMode(SharedScopeResolverService,与权限计算同口径): COMBINE 取各角色部门集合的并集、任一 SELF 时结果保留本人腿(DEPT_OR_SELF);SWITCH 只看 currentRole;无启用角色 / 非法档位默认拒绝(恒假谓词)。root 契约角色恒 ALL; visitor 固定 SELF —— permission.guard 对 visitor 放行所有 :list / :read,不能让它 落到默认 ALL 读全量业务表。
注册表与 fail-closed(common/repository/data-scope.ts):
- 注册 = 强制过滤(
sys_user、sys_log_operate、sys_log_auth、app_monitor_user、sys_deleted、app_demo、sys_employee); - 未注册 = 显式豁免,豁免清单逐条写理由(全局共享表、登录/安全面系统表、组织维度表、 junction);
- fail-closed:已注册实体在 ALS 里没有描述符时直接抛 403,绝不当 ALL。非 HTTP 通道(cron / 脚本 / 测试)必须显式
ALSRequestService.runAsSystem()进入系统通道; - 机械守卫:注册表完整性单测(每张实体表必须注册或豁免)+ ESLint
no-restricted-syntax(已注册实体的.getRepository()直连必须显式豁免并写理由)。
端到端矩阵:apps/server/apps/api/test/api/data-scope-matrix.api-spec.ts 用 app_demo 作为靶表覆盖 M1-M10 —— 六档可见集合、越权 read/update 404、create 双回填、 多角色并集、visitor 仅本人、无角色登录被拒(空 roles_list 在 opaque 通道统一翻译为 40103)、角色改档位 / 换清单 / 清空清单后下一请求立即生效。
缓存与失效:AUTH:DATA_SCOPE:<userId>(不带 deviceId,TTL 与访问令牌同寿命)。 失效点:角色 dataScope/status 修改(按角色成员反查)、sys_role_dept 部门清单变更 (该角色成员)、部门移动(旧/新祖先链 + 被移动子树)、部门删除(删除前取的子树)、 用户角色整组替换 / 部门变更 / 删除 / 恢复 / 切换当前角色 / 角色模式(都只影响本人 —— CUSTOM 改为部门清单后,角色成员变化不再影响同角色其他成员)。角色删除端点当前不存在; 新增删除通道时必须先按 roleId 反查成员再失效。
2. 签名体系
2.1 签名原文
sign.guard.ts 的 buildSignRaw 按固定顺序拼接:
METHOD|PATH(去 /{prefix}/v{version})|扁平静态body排序串|timestamp=<ms>|nonce=<32hex>|ua=<User-Agent>METHOD大写。- 路径取
req.url(含 query 串),只去掉/{prefix}/v{version}前缀;去掉后为空串时用/。 - body 用
objectToPaths扁平化,键排序后拼k=v&k=v;空 body 为空串。 timestamp是毫秒;ua是原始User-Agent头,不做归一化。
2.2 单因子 / 双因子
| 场景 | 密钥 | 公式 |
|---|---|---|
未登录(req.user 为空) | 设备握手得到的 aesKey(base64url 字符串的 UTF-8 字节) | HMAC-SHA256(raw, aesKey) |
| 已登录 | HKDF-SHA256(ikm = SHA256(sessionKey), salt = aesKey, info = hkdfInfo, len = 32) | HMAC-SHA256(raw, derivedKey) |
hkdfInfo来自APP:SETTING的app.crypto.hkdfInfo,缺省walnut-admin-api-sign-v1; 握手第二步的响应会把它一起返回,客户端据此派生。- 服务端只存
SHA256(sessionKey)(AUTH:SESSIONS的authSessionKeyHash),HKDF 直接以 该 hash 作为 ikm(等价于SHA256(sessionKey)再派生);客户端持有原始 sessionKey。 - 已登录请求的双因子派生失败不再降级单因子(P0-2 修复):直接 40112。未登录请求仍走 单因子,登录/握手端点因此可用。
2.3 nonce、时间窗与 Sign Ticket
X-Timestamp与服务端时间差超过 5 分钟(300000 ms)→ 40112。X-Nonce必须是 32 位十六进制,否则 40112(格式校验先于签名比对)。- nonce 记
SECURITY:SIGN:NONCE:<nonce>,TTL 300 秒;重复 → 40112。 P2-11 修复后(2026-10-29):写入是单命令SET NX EX(AppTechCacheService.setIfAbsent), 写入失败即视为重复 —— 并发同 nonce 只有一路通过,不再有 GET 后 SET 的窄窗口。 - nonce 在签名比对之前写入:签名错误的请求也会消耗自己的 nonce。
SIGN_TICKET:- 缓存
SECURITY:SIGN:TICKET:<deviceId>存在且 cookie 存在但不相等 → 40112; - 缓存存在、cookie 缺失 → 接受并重发同一 ticket(自动补 cookie);
- 缓存缺失 → 用分布式锁(
APP:MURLOCK:SIGN:<deviceId>)生成 16 字节 base64url 新票, 写缓存与 cookie;因此首次请求不需要预置 ticket。 - 缓存与 cookie 的有效期都是 AT 有效期;每次校验成功都会重写缓存并重发 cookie。
- 缓存
- 设备 AES 密钥缺失(缓存过期/未握手)→ 40113;其余签名类失败统一 40112。 签名比对用
timingSafeEqual,长度不等直接 false。
3. CAP
- 挑战参数来自
app.global.capjs.config,缺省{ count: 10, size: 16, difficulty: 4, ttl: 3600000, throttleLimit: 4, throttleTtl: 60 }。 POST /security/cap/challenge创建挑战;POST /security/cap/redeem校验 PoW 解, 成功时写签名CAPJS_TOKENcookie(maxAge = ttl * 1000,即默认 1 小时)。 两者都是@WalnutAdminDecoratorFreeResponse():body 直接是 cap.js 协议形态,无{code,data}壳。- cap.js token 一次性:服务端
validateToken成功即删除;客户端再次兑换得到success: false,必须重新 challenge。 - 全局 CAP Guard 的触发条件:
request.risk.comprehensive.recommendation存在。- 不存在(Risk 未运行/无 deviceId/评估异常)→ 放行;
- 存在但 cookie 缺失或校验失败:
shouldChallenge为 true → 40116,否则 → 40117。
- 校验缓存键是
SECURITY:CAP_TOKEN:<deviceId>:<sha256(token) 前 16 hex>,TTL 为ceil(config.ttl / 1000)秒(P0-3 修复后:与设备 + token 双重绑定,毫秒转秒)。 缓存未命中时在APP:MURLOCK:CAP:<deviceId>锁内调AppCapJS.validateToken真验。 - 真验失败时
performCapVerification内部抛 40111 但被自身 catch 吞掉,最终返回 false, 客户端看到的是 40116/40117,而不是 40111(死分支现状登记为 P3-31;改 rethrow 或删除 属前端错误码契约决策)。 - 校验通过后
SecurityRiskChallengeStateService.markChallengeHandled把本次criticalFactors标记为已处理:键形如risk:challenge:<scope>:<值...>:<factor>(scope 由 factor 前缀决定:device / user-device / user / ip / location),TTL 24 小时。未处理的 critical factor 会让下一轮风险评估继续 要求挑战。
4. Device / Risk / IP
4.1 设备注册与指纹
- 注册端点
POST /system/device/initial(系统模块):Sign/JWT/CAP/Device Free, Throttle 4 次 / 60 秒,Risk 仍运行。 device_id = hash(X-Fingerprint, crypto.deviceIdKey);指纹是客户端自报的请求头, 设备身份因此是「自证 + 服务端加盐哈希」,不是硬件证明。- 同
device_id再注册是整体覆盖(设备名、屏幕、硬件信息、IP/位置历史等),device_name唯一冲突 → 40000(P3-2)。 - 注册时调外网 IP 地理 API(本地 IP 回
LOCAL);响应{ deviceId, locationInfo }, 并下发签名DEVICE_IDcookie;事务提交后写SYS_DEVICE:<deviceId>缓存。
4.2 Device Guard
- 取签名
DEVICE_IDcookie;缺失 → 40605。 SYS_DEVICE缓存未命中时回源sys_device(P2-2:DB 是locked/banned真源), 查无 → 40605;命中则回填缓存。- 设备当前 IP 与
realIp不同:在APP:MURLOCK:DEVICE:IP_CHANGE:<deviceId>锁内 double-check 后调updateIpAndLocation(写 IP/位置历史,最多 5 条;提交后回填缓存)。 该更新会调外网地理 API,IP 频繁变化时有外部依赖成本。 banned→ 40607;locked→ 40606(先判 banned)。- 设备锁定/封禁的写入口是
PATCH /system/device/:id/{lock,unlock,ban,unban}, DB 事务 + after-commit 缓存双写。
4.3 Risk
前置(pre-auth)与后置(post-auth)分别计算加权分,综合分是两者的 50/50 平均; 等级阈值 CRITICAL >= 0.8、HIGH >= 0.6、MEDIUM >= 0.3,其余 LOW。
| 阶段 | 因子(括号为权重) |
|---|---|
| pre-auth | IP 永久/临时黑名单(1.0 / 0.8)、DEVICE_NEW(0.3,critical)、DEVICE_RECENT(0.15,critical)、设备当前/历史 IP 黑名单(0.3 / 0.2)、DEVICE_BANNED(1.0)、DEVICE_LOCKED(0.5)、DEVICE_PRIVATE(1.0,critical)、LOCATION_HIGH_RISK_COUNTRY / LOCATION_VPN_OR_PROXY / LOCATION_JUMP(0.6 / 0.4 / 0.3,均 critical)、FAILED_LOGINS(0.5)、RATE_LIMIT_EXCEEDED(0.3) |
| post-auth | USER_DISABLED / USER_NO_PASSWORD / USER_NO_MFA(0.5 / 0.1 / 0.1)、USER_DEVICE_UNTRUSTED / TRUST_EXPIRED / LOCKED(0.6 / 0.4 / 0.4,均 critical)、USER_DEVICE_INACTIVE(0.2) |
模块权重:IP 0.25、Device 0.25、FailedLogin 0.2、Location 0.15、Rate 0.15;post-auth 为 User 0.2、UserDevice 0.3。
- critical factor 未处理时,推荐直接是
shouldChallenge: true;然后 HIGH/CRITICAL → 挑战, MEDIUM → 只记录,LOW → 无动作。 - 口径(P1-13,已修复,2026-10-28):风险只触发 CAP 升级,不做阻塞。
recommendation不再含shouldBlock(旧实现恒 false 的幻觉字段已从类型与消费点摘除), risk.guard 只写request.risk供 CAP guard 消费;其 catch 只吞未知异常,受控HttpException(如 40666 RiskTooHigh)原样放行到全局异常过滤器。 - 失败登录计数:键
risk:failed_login:<identifier>:<deviceId>(Redis Hash,TTL 15 分钟), 计数达到 5 次即调SharedIpService.addToTemporaryBlacklist,临时封 IP 30 分钟。 - 频率阈值(每分钟):IP 120、设备 120、用户 120;另有一条 IP 5 分钟 400。超出按对数曲线 计分;采集是 fire-and-forget,不阻塞请求。
4.4 IP 与部署前提
realIp由IpMiddleware用@supercharge/request-ip从请求取(首 header 优先), 同时写X-IP头与req.realIp。所有 IP 维度风控(黑名单、throttle tracker)都信任它。部署强约束(P0-5):生产 nginx 必须覆盖而非追加转发头:
nginxproxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr;不得使用
$proxy_add_x_forwarded_for(它会把客户端伪造值留在首位)。直连暴露时 客户端可任意伪造realIp,黑名单与限流都可被绕过或嫁祸。 2026-10-29 复核:deploy/nginx/conf.d/api.conf的/、frontend.conf的/api/、/socket/与静态location /全部为覆盖式($remote_addr)并带X-Real-IP;新增任何 后端入口都必须照此写。永久黑名单存在
APP:SETTING的app.global.ipBlackList(JSON 数组),写入走 setting repo 并触发配置缓存刷新;临时黑名单是 Redisrisk:ip_blacklist:<normalizedIp>, TTL 1800 秒,IPv6 的冒号会替换为下划线,本地 IP 统一127.0.0.1。黑名单路径:
WALNUT_ADMIN_PERMANENT_BLACKLIST_PATHS(/admin、/wp-admin、/.env等)。security.guard只覆盖已路由请求;未命中路由的扫描路径由BlacklistPathMiddleware(forRoutes('*'),用originalUrl)兜底:命中即把realIp写入永久黑名单并回 40610 (P1-11 修复)。guard 内部匹配前按 API 前缀 strip,常量本身是裸路径。
5. 接口表(security 模块)
统一响应壳 HTTP 200 + body.code;CAP 两个端点例外(FreeResponse,见 §3)。 「守卫链」只列与默认全链不同的部分。
| 方法 / 路径 | 守卫链 | 权限点 | 请求 → 响应 |
|---|---|---|---|
POST /security/sign/initial | Sign/CAP/JWT/Lock/Device/IP 全 Free(Risk 仍跑) | 无 | { rsaPubKey, force? } → 已缓存的公钥或 true |
POST /security/sign/aes-key | 同上 | 无 | — → { aesKey: <EncryptResponse 信封>, hkdfInfo } |
GET /security/rsa/public-key | Sign/JWT/CAP Free | 无 | — → 服务端 RSA 公钥 PEM 字符串 |
POST /security/cap/challenge | CAP/JWT/Device/Lock Free + SignFree + Throttle | 无 | — → { challenge, token, expires }(无壳) |
POST /security/cap/redeem | 同上 | 无 | { token, solutions } → { success, token?, expires? }(无壳),成功下发 CAPJS_TOKEN |
GET /security/sensitive/check | 全链(JWT 生效) | 无 | query level(枚举)、type(可选枚举) → { needsVerification, remainingSeconds?, availableMethods?, preferredMethod? } |
POST /security/sensitive/verify | 全链 + @WalnutTx() | 无 | { level, operationType?, method, credential } → true;成功后写 15 分钟授权键 |
补充(不属于 security controller,但同属安全面):设备注册 POST /system/device/initial(Device/Sign/JWT/CAP Free + Throttle)与设备锁定/封禁 PATCH /system/device/:id/{lock,unlock,ban,unban}(全链 + system:device:*)。
6. 已知行为注记
- P0-5(部署强约束):
realIp恒信任X-Forwarded-For;生产 nginx 必须覆盖转发头, 代码不做跳数校验(裁决见 DESIGN-ISSUES)。 - P1-10(已修复):动态 throttle 的
blockDuration与 ttl 同步覆盖;超限仍是 HTTP 200 + body.code 42900、Retry-After秒数。 - P1-11(已修复):黑名单路径判定拆到中间件,未命中路由的扫描路径也能 40610 并自动拉黑。
- P2-1(设计立场):
mfa.guard强制已登录非 visitor 用户mfaSetup=true,无开关;app.auth.mfa只影响敏感操作的方式数,不影响该 guard。 - P3-1(裁决接受):429 语义只存在于 body.code,不放行原生 HTTP 429。
- P3-8(豁免):CAP challenge/redeem 与 OAuth SSE 不走统一响应壳,属于协议/传输语义。
- P1-13(已修复,2026-10-28):风险层口径定为「只触发 CAP 升级、不做阻塞」——
shouldBlock字段摘除;risk.guard 不再自行拒绝请求,受控异常不再被自身 catch 吞掉 (40666 若未来由受控路径抛出可直达全局过滤器)。 - P1-15(新登记):
AUTH:PERMISSIONS缓存 TTL 被写成AT 秒数 * 1000,权限撤销/角色 调整在缓存层面可驻留约 1000 倍 AT 时长;permission.guard 只读该缓存。 - P1-16(新登记):OTP 发送端点的动态限流不生效 —— 装饰器写元数据的键是 Symbol,
OtpThrottleGuard用字符串'OTP_THROTTLE_SERVICE'读取,永远取不到 provider, 实际退回全局 throttle 配置。 - P2-11(已修复,2026-10-29):nonce 去重改为单命令
SET NX EX,并发同 nonce 恰一通过 (guard-matrix 新增Promise.all双请求用例)。 - P2-15(已修复,2026-10-28):永久 IP 黑名单的锚点行
app.global.ipBlackList由 seed 显式落盘(apps/server/db/seed/app_setting.json,值与读腿同为 JSON 字符串数组),check-seed新增机械判据防止未来再导出时丢行。 - P2-12(已修复,2026-10-28):踢下线(force-quit / admin-kick)在撤 RT 之外,事务 提交后按设备清
AUTH:SESSIONS/AUTH:PERMISSIONS/AUTH:MFA:VERIFIED;旧 AT 立即失效。 - P3-17(新登记):
ENDPOINTS.md把security/sensitive/verify的请求登记为{basic,action,type,code},与 DTO 实际字段{level,operationType,method,credential}不符。