多来买:鉴权安全边界与演进
多来买:鉴权安全边界与演进
核心问题:当前鉴权实现在哪些边界上可信,哪些只是候选风险,应该怎样安排演进优先级
事实来源:user、gateway、common 及下游身份读取代码
证据口径:代码已确认;风险未做运行复现,演进方案不是当前已实现能力
认证链路的价值不只在于“能登录”,还在于明确谁可以制造正式身份、身份在什么条件下失效、下游能信任什么。本文把错误响应、IP 绑定、下游读取、异常与并发边界集中到一处,避免与登录和 Gateway 主流程重复。
1. 身份可信边界
当前代码在“有效会话存在”时会用 Redis userId 写入 Header。
但是在“公开接口 + 无有效会话”场景中,它没有主动删除客户端原本提交的 userId Header。
由于 request.mutate 会保留原始 Header,代码存在以下候选风险:
GET <公开路径> HTTP/1.1userId: <客户端伪造值>若该请求被匿名放行,并且下游公开接口直接信任 userId Header,就可能把伪造值当成身份。
受保护路径在 userId 为 null 时会被拦截,所以风险主要落在“公开但会根据可选 userId 改变行为”的接口。
更稳健的边界通常是:
- 进入 Gateway 后先移除外部 userId。
- 只有会话验证成功后才重新写入服务端恢复的 userId。
- 通过网络隔离或服务间认证防止绕过 Gateway 直连业务服务。
这是从当前 Header 处理分支得出的候选风险,本轮没有构造请求复现。
2. Gateway 鉴权失败响应
2.1 out 方法
源码节选:
- 路径:duolaimall-gateway/src/main/java/com/cskaoyan/mall/gateway/filter/AuthFilter.java
- 类与方法:AuthFilter#out
Result<Object> result = Result.build(null, resultCodeEnum);
byte[] bits = JSONObject.toJSONString(result) .getBytes(StandardCharsets.UTF_8);
DataBuffer wrap = response.bufferFactory().wrap(bits);
response.getHeaders().add( "Content-Type", "application/json;charset=UTF-8");
return response.writeWith(Mono.just(wrap));两个业务码来自 ResultCodeEnum:
| 业务码 | message | 触发条件 |
|---|---|---|
| 204 | 非法请求 | Redis 会话 IP 与当前 IP 不一致 |
| 208 | 未登陆 | 受保护路径没有有效会话 |
这里的 204 是 JSON 中的业务 code,不等同于 HTTP 204 No Content。
2.2 HTTP 状态码边界
out 方法没有调用 response.setStatusCode。
因此,当前客户端主要依赖 Result.code 区分鉴权错误;实际 HTTP 状态是否保持默认值,本轮没有运行确认。
不能把当前实现描述成“Gateway 返回 HTTP 401”,因为代码里没有设置 401。
这正是本项目与很多 JWT 教程实现的一个明显差异。
3. IP 绑定
3.1 登录阶段如何取 IP
UserController 调用:
String ip = IpUtil.getIpAddress(request);Servlet 版本的选择顺序可以概括为:
x-forwarded-for -> Proxy-Client-IP -> WL-Proxy-Client-IP -> request.remoteAddr -> 如果是 IPv4 回环地址,则尝试本机网卡地址 -> 多代理时取逗号前第一段3.2 Gateway 阶段如何取 IP
Gateway 调用:
String nowIp = IpUtil.getGatwayIpAddress(request);Reactive 版本还会依次检查:
- HTTP_CLIENT_IP;
- HTTP_X_FORWARDED_FOR;
- X-Real-IP;
- 最后使用 remoteAddress。
登录端和网关端的提取规则并非完全相同。
3.3 IP 绑定带来的收益
如果攻击者只拿到了 Token,但请求来源 IP 与登录 IP 不同,Gateway 会返回非法请求。
这为 Token 泄漏增加了一道使用条件。
3.4 IP 绑定的边界
| 场景 | 可能影响 |
|---|---|
| 移动网络切换、家庭宽带重拨 | 合法用户 IP 改变,Token 可能被拒绝 |
| 多级代理头格式差异 | 登录端与 Gateway 解析结果可能不同 |
| 转发头可由公网客户端直接伪造 | IP 绑定的可信度下降 |
| 登录端与 Gateway 使用不同 Header 顺序 | 同一请求链可能得到不同字符串 |
| 第一段 IP 带空格 | 当前两处代码没有统一 trim |
| Gateway remoteAddress 为空 | 末级读取可能出现异常 |
| Redis 中 ip 字段为空 | ip.equals 可能出现空指针 |
生产环境通常需要由受信任代理统一清洗并重写转发头,应用只信任约定好的来源。
当前仓库没有部署拓扑和可信代理配置证据,因此不能确认 IP 绑定在真实网络中的有效性。
4. 下游如何读取身份
4.1 AuthContext 不是 ThreadLocal
源码:
- 路径:duolaimall-common/common-util/src/main/java/com/cskaoyan/mall/common/util/AuthContext.java
- 类:AuthContext
public static String getUserId( HttpServletRequest request) { String userId = request.getHeader("userId");
return StringUtils.isEmpty(userId) ? "" : userId;}
public static String getUserTempId( HttpServletRequest request) { String userTempId = request.getHeader("userTempId");
return StringUtils.isEmpty(userTempId) ? "" : userTempId;}AuthContext 只是请求头读取工具:
- 不保存 ThreadLocal;
- 不查询 Redis;
- 不验证 Token;
- 缺失时返回空字符串,不返回 null。
因此它依赖一个前提:请求确实经过可信 Gateway,而且 userId Header 已经过正确清洗与重建。
4.2 购物车的实际消费入口
源码节选:
- 路径:duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/controller/CartController.java
- 类与方法:CartController#cartList
String userId = AuthContext.getUserId(request);
String userTempId = AuthContext.getUserTempId(request);
List<CartInfoDTO> cartList = cartService.getCartList( userId, userTempId );这就是身份透传与购物车合并链路的连接点:
- 登录用户有 userId;
- 游客可能只有 userTempId;
- 刚登录用户可能同时携带两者;
- CartService 再决定正式购物车和临时购物车如何合并。
4.3 秒杀的实际消费入口
PromoController 的多个受保护接口调用 AuthContext.getUserId。
例如获取秒杀下单码时,代码从请求头取得 userId,再把它用于生成用户相关下单码。
这说明 Gateway 的 userId 不只是展示信息,而会进入后续业务标识计算。
因此,身份头的可信来源和服务是否可绕过 Gateway 是整个系统的重要安全边界。
5. 配置入口
5.1 仓库内 application.yml 能确认什么
user-service 与 server-gateway 都声明:
- spring.application.name;
- dev profile;
- Nacos discovery;
- Nacos config;
- 服务专属配置导入;
- common.yaml 导入。
脱敏后的结构语义如下:
spring: application: name: <service-user 或 server-gateway> profiles: active: dev cloud: nacos: discovery: server-addr: <由本机或环境配置> config: server-addr: <由本机或环境配置> file-extension: yaml config: import: - nacos:<service-name>-<profile>.yaml - nacos:common.yaml这是用途示意,不复制仓库中的具体地址、账号或密码。
5.2 本链路依赖的外部属性
| 属性语义 | 消费位置 | 用途 |
|---|---|---|
| authUrls.url | AuthFilter | 单个受保护路径模式 |
| spring.redis.host / port / password | RedissonConfig | 创建单机 RedissonClient |
| hutool.snowflake.workerId | HutoolIdUtil | 雪花节点标识 |
| hutool.snowflake.datacenterId | HutoolIdUtil | 雪花数据中心标识 |
| Gateway routes | Nacos 专属配置 | 将入口请求路由到 user-service |
| 数据源配置 | user-service 外部配置 | 查询 user_info |
本轮没有读取 Nacos 中的实际值,也没有连接任何外部基础设施。
6. 安全、并发与异常边界
6.1 已确认实现与边界矩阵
| 维度 | 当前实现 | 能解决什么 | 不能证明或存在的边界 |
|---|---|---|---|
| 密码存储 | 无盐 MD5 后查库 | 不直接用明文条件查询 | 抗离线爆破能力弱 |
| Token | 雪花 ID 字符串 | 通常唯一、生成快 | 不是高熵安全随机令牌 |
| 会话 | Redis RBucket,TTL 2h | 可集中失效、自动过期 | 每次请求依赖 Redis |
| 退出 | 删除当前 Token Key | 服务端立即失效 | 只读 Header,GET 改状态 |
| IP 绑定 | 登录 IP 与请求 IP 精确相等 | 降低异地盗用可能 | 代理、NAT、移动网络边界 |
| 路径保护 | 一个外部 PathPattern | 集中判断受保护路径 | Nacos 值与覆盖范围未验证 |
| 正式身份 | Gateway 写 userId | 下游免查 Redis | 公开匿名请求未清除原 userId |
| 临时身份 | Header/Cookie 透传 | 支持游客购物车 | userTempId 本身不可信 |
| 错误响应 | Result 业务码 | 前端统一解析 | 未显式设置 HTTP 401 |
| 多端登录 | 每个 Token 独立 Key | 可同时保留多会话 | 没有单点登录或全端退出 |
| 会话续期 | 无 | 固定有效期简单 | 活跃用户也不会滑动续期 |
| 输入校验 | 直接解引用 DTO | 代码短 | null、空白、格式均未显式校验 |
| 数据唯一性 | selectOne | 期待唯一匹配 | 仓库没有唯一索引证据 |
| CORS | 任意 Origin Pattern、方法、Header,允许凭据 | 开发联调方便 | 生产边界过宽 |
| 服务访问边界 | 依赖 Gateway 透传头 | 入口逻辑集中 | 未发现部署隔离证据 |
6.2 Redis 不可用时会怎样
Gateway 的 hasLogin 直接调用 bucket.get,代码中没有:
- 本地会话缓存;
- Redis 异常降级;
- 超时后的匿名回退;
- 熔断或隔离逻辑;
- 对受保护接口的备用认证源。
安全上不应该在 Redis 异常时默认放行。
当前异常最终如何呈现还取决于 Gateway 全局异常处理,本轮没有运行验证。
6.3 登录并发
多个并发登录请求之间没有共享用户级锁,也没有按 userId 覆盖旧会话。
正常情况下,每个请求取得不同雪花 ID并写不同 Key,互不覆盖。
真正需要关注的是雪花节点配置是否唯一。如果不同实例错误使用同一节点标识并产生相同 ID,后写入会覆盖同名 Key。
这是一项配置层候选风险,不是当前已复现问题。
6.4 Redis 会话与数据库状态不同步
会话建立后,Gateway 只读取 Redis,不再查询 user_info。
如果用户账号随后被禁用、删除或权限变化:
- 当前 Token 仍可能在 TTL 内有效;
- 除非业务主动删除会话 Key;
- 或下游每次重新校验账号状态。
当前 UserInfo 没有看到登录状态字段参与认证,仓库也没有全局强制下线链路证据。
6.5 全局异常处理
user-service 扫描 common 包,common-service 中 GlobalExceptionHandler 会把普通 Exception 转成 Result.fail。
这意味着空请求体、passwd 为 null、selectOne 多结果等异常有机会被统一业务响应接管。
但它会调用 e.printStackTrace,且当前文档没有运行确认具体 HTTP 状态与序列化结果。
不能把“存在异常处理器”写成“所有异常场景已验证可控”。
7. 当前候选风险清单
下面只记录静态代码中有明确触发机制的候选风险,不代表已经运行复现。
P0:身份可信边界
- 公开匿名请求没有主动移除外部 userId Header,下游若信任该值可能接收伪造身份。
- 是否能绕过 Gateway 直接访问业务服务没有部署证据。
- authUrls.url 的真实模式未读取,不能证明所有敏感接口均受保护。
- authUrls.url 当前只按单个字符串解析,没有多模式列表逻辑。
P1:安全与会话一致性
- 密码使用无盐 MD5。
- Token 使用雪花 ID,不是密码学安全随机值。
- IP 解析信任多个转发头,但没有可信代理名单或统一清洗证据。
- Gateway 支持 Cookie Token,logout 只支持 Header Token。
- 会话固定两小时且不续期。
- 同一用户可以保留多个会话,没有全端退出。
- Redis 会话建立后不会自动感知用户状态变化。
- CORS 对来源、方法和 Header 较宽,同时允许凭据。
P2:工程契约与异常表达
- 登录 DTO 复用字段过多,且没有 @Valid 或空值校验。
- 响应 nickName 实际取 UserInfo.name,字段语义不一致。
- 七天 USERKEY_TIMEOUT 常量未被登录逻辑使用,容易误读。
- UserConstants 与 RedisConst 重复声明登录常量。
- Header 名称在多处直接写字符串,USER_LOGIN_TOKEN_HEADER 未实际复用。
- 网关错误只写业务码,未显式设置 HTTP 401。
- hasLogin 用 “-1” 字符串表达 IP 异常,状态类型不够明确。
- PathPattern 每次请求重新解析,错误配置可能直接影响过滤流程。
- 登录端与 Gateway 的 IP 提取规则不完全一致。
- 当前没有自动化测试覆盖登录、过期、Cookie、伪造 Header 与代理场景。
8. 如果未来演进,优先级如何判断
本文不实施代码修复,只从现有边界给出可讨论的演进顺序。
第一层:先封住身份边界
- Gateway 先移除外部 userId,再按有效会话重新写入。
- 敏感接口只允许通过 Gateway 或采用服务间认证。
- 对 authUrls 的受保护路径建立集中、可审查的配置与测试。
第二层:提升凭据安全
- 使用高熵随机 Token,或引入明确设计的认证令牌方案。
- 用 BCrypt、Argon2id 或 PBKDF2 代替 MD5。
- 加入登录限流、失败审计和账号状态校验。
- 明确 Header 与 Cookie 的唯一契约;如果用 Cookie,需要定义安全属性与 CSRF 策略。
第三层:完善会话治理
- 明确固定 TTL 还是滑动 TTL。
- 明确单设备、多设备、踢下线和全端退出语义。
- 建立 userId 到 Token 的反向索引或会话集合。
- 设计账号禁用后的会话失效机制。
- 设计 Redis 高可用与认证失败时的安全策略。
这些是设计讨论,不属于当前项目已实现功能。
9. 四条短复习点
- 认证只回答“请求对应哪个用户”,订单、地址等资源归属仍需下游授权。
- 外部 userId 是客户端可控输入;可信身份必须由 Gateway 验证会话后重新写入。
- IP 绑定能降低异地盗用概率,但会受代理、NAT 和移动网络变化影响。
- 改进优先级应先封住身份边界,再升级密码和 Token,最后完善会话治理与可用性。
10. 关键源码导航
| 文件 | 关键位置 | 本篇关注点 |
|---|---|---|
duolaimall-gateway/src/main/java/com/cskaoyan/mall/gateway/filter/AuthFilter.java | filter、hasLogin、out | Header 信任、错误响应、Redis 与 IP 分支 |
duolaimall-common/common-util/src/main/java/com/cskaoyan/mall/common/util/IpUtil.java | Servlet / Gateway 两套方法 | 转发头与来源 IP 解释 |
duolaimall-common/common-util/src/main/java/com/cskaoyan/mall/common/util/AuthContext.java | getUserId、getUserTempId | 下游读取请求头 |
duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/controller/CartController.java | 身份读取入口 | 正式购物车与临时购物车 |
duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/controller/PromoController.java | 身份读取入口 | 秒杀业务使用 userId |
11. 本章总结
本文中的 P0/P1/P2 是静态审查优先级,不代表漏洞已经被运行利用;是否能绕过 Gateway 还取决于部署网络边界。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!