多来买:Gateway 鉴权与身份透传实战
多来买:Gateway 鉴权与身份透传实战
核心问题:Gateway 怎样判断路径是否需要登录、恢复正式身份并透传临时身份
事实来源:server-gateway、user-api、common-util 当前源码
证据口径:代码已确认;未读取 Nacos 中实际 authUrls 与路由配置,未发送真实请求
用户服务负责建立会话,Gateway 负责在每次入口请求上解释会话。当前过滤链先匹配受保护路径,再从 Header 或 Cookie 读取 Token,查询 Redis、比较 IP,最后将认证得到的 userId 和客户端临时标识 userTempId 写入下游请求。
1. 受保护请求的端到端时序
1.1 携带 Token 访问受保护接口
1.2 匿名身份与正式身份
userId 与 userTempId 的可信等级不同:
- userId 理论上应来自 Redis 中的有效会话。
- userTempId 本来就由客户端生成或保存,只用于匿名数据归属,不等价于认证身份。
2. Gateway:路径是否需要登录
2.1 外部配置入口
AuthFilter 通过属性 authUrls.url 取得一个路径模式。
运行中调用:
boolean isNeedLogin = judgeNeedLoginByUrl(path, authUrl);实际匹配方法:
private boolean judgeNeedLoginByUrl( String path, String authUrl) { PathPattern pattern = parser.parse(authUrl); PathContainer container = PathContainer.parsePath(path); return pattern.matches(container);}代码出处:
- 路径:duolaimall-gateway/src/main/java/com/cskaoyan/mall/gateway/filter/AuthFilter.java
- 类与方法:AuthFilter#judgeNeedLoginByUrl
当前运行入口没有使用同类中保留的 AntPathMatcher 方法 judgeNeedLogin。
2.2 单模式边界
authUrl 是一个 String,代码没有:
- 读取 List;
- 按逗号拆分;
- 遍历多个模式;
- 显式维护白名单。
因此当前实现从代码层只确认“一个 PathPattern 字符串”。
真实 Nacos 中配置了什么,本轮没有读取,不能确认覆盖了哪些接口。
2.3 登录接口为何必须是公开路径
AuthFilter 对所有经过 Gateway 的请求执行。
如果 /user/login 被 authUrls.url 匹配为需要登录,就会形成循环:
- 用户没有 Token;
- 登录接口要求已有登录态;
- Gateway 返回未登录;
- 用户永远无法建立会话。
所以登录路径必须不匹配受保护模式。
这项要求来自鉴权逻辑,但当前 Nacos 值未验证。
3. Gateway:读取并验证会话
3.1 主过滤流程
源码节选:
- 路径:duolaimall-gateway/src/main/java/com/cskaoyan/mall/gateway/filter/AuthFilter.java
- 类与方法:AuthFilter#filter
String path = request.getURI().getPath();
boolean isNeedLogin = judgeNeedLoginByUrl(path, authUrl);
String userId = hasLogin(request);
if ("-1".equals(userId)) { return out( response, ResultCodeEnum.ILLEGAL_REQUEST );}
if (userId == null && isNeedLogin) { return out( response, ResultCodeEnum.LOGIN_AUTH );}顺序非常重要:
- 先判断路径是否需要登录;
- 仍然对请求调用 hasLogin;
- IP 异常优先拦截;
- 只有“未登录且路径受保护”才返回未登录;
- 其余情况继续构造下游请求。
因为 hasLogin 在公开路径上也会执行,公开请求若携带了一个存在但 IP 不匹配的 Token,同样会被判为非法请求,而不是自动降级成匿名请求。
3.2 Token 提取优先级
源码节选:
String token = request.getHeaders().getFirst("token");
if (StringUtils.isBlank(token)) { HttpCookie cookie = request.getCookies().getFirst("token");
if (cookie != null) { token = cookie.getValue(); }}
if (StringUtils.isBlank(token)) { return null;}优先级是:
非空 Header token > Cookie token > 未登录如果 Header 中存在一个无效 Token,同时 Cookie 中存在有效 Token,代码不会回退到 Cookie,因为只有 Header 为空时才读取 Cookie。
3.3 Redis 查询与 IP 比对
源码节选:
- 类与方法:AuthFilter#hasLogin
String key = UserConstants.USER_LOGIN_KEY_PREFIX + token;
RBucket<UserLoginInfoDTO> bucket = redissonClient.getBucket(key);
UserLoginInfoDTO loginInfo = bucket.get();
if (loginInfo == null) { return null;}
String ip = loginInfo.getIp();String userId = loginInfo.getUserId();
String nowIp = IpUtil.getGatwayIpAddress(request);
if (!ip.equals(nowIp)) { return "-1";}
return userId;返回值被复用成三种状态:
| hasLogin 返回值 | 含义 |
|---|---|
| null | 未携带 Token、Key 不存在或已过期 |
| ”-1” | 会话存在,但当前 IP 与登录 IP 不一致 |
| 其他非空字符串 | Redis 中保存的 userId |
“-1” 是哨兵值,不是用户对象或枚举。
如果未来数据库可能存在 ID 为 -1 的用户,就会和异常状态冲突;当前 BaseEntity 使用自增 Long,未发现这种实际数据证据。
更清晰的长期设计可以返回专门的认证结果对象或枚举,但当前代码确实使用字符串哨兵。
4. Gateway 鉴权分支真值表
4.1 路径、Token 与 IP
| 是否受保护 | Token / Redis 状态 | IP 状态 | 当前结果 |
|---|---|---|---|
| 否 | 无 Token | 不适用 | 匿名放行 |
| 否 | Token 对应 Key 不存在 | 不适用 | 匿名放行 |
| 否 | 有有效会话 | 一致 | 携带 userId 放行 |
| 否 | 有有效会话 | 不一致 | 返回业务码 204,拦截 |
| 是 | 无 Token | 不适用 | 返回业务码 208,拦截 |
| 是 | Token 对应 Key 不存在 | 不适用 | 返回业务码 208,拦截 |
| 是 | 有有效会话 | 一致 | 携带 userId 放行 |
| 是 | 有有效会话 | 不一致 | 返回业务码 204,拦截 |
4.2 Header 与 Cookie
| Header token | Cookie token | 实际使用 |
|---|---|---|
| 空 | 空 | 未登录 |
| 有效 | 空 | Header |
| 空 | 有效 | Cookie |
| 有效 A | 有效 B | Header A |
| 无效 A | 有效 B | 仍使用 Header A,不回退 |
| 纯空白 | 有效 B | Cookie B |
4.3 userTempId
| Header userTempId | Cookie userTempId | 透传结果 |
|---|---|---|
| 空 | 空 | 不新增临时身份头 |
| 有值 A | 空 | 写入 A |
| 空 | 有值 B | 写入 B |
| 有值 A | 有值 B | Header A 优先 |
userTempId 没有 Redis 校验和 IP 校验,这是匿名购物车标识,不应被当成登录认证结果。
5. Gateway:身份头透传
5.1 写入 userId
源码节选:
- 路径:duolaimall-gateway/src/main/java/com/cskaoyan/mall/gateway/filter/AuthFilter.java
- 类与方法:AuthFilter#filter
ServerHttpRequest.Builder builder = request.mutate();
if (StringUtils.isNotBlank(userId)) { builder.header("userId", userId);}只有 hasLogin 返回非空 userId 时,Gateway 才主动写入认证身份。
5.2 写入 userTempId
String userTempId = request.getHeaders().getFirst("userTempId");
if (StringUtils.isBlank(userTempId)) { HttpCookie cookie = request.getCookies() .getFirst("userTempId");
if (cookie != null) { userTempId = cookie.getValue(); }}
if (StringUtils.isNotBlank(userTempId)) { builder.header("userTempId", userTempId);}最后必须把变更后的 request 放回 exchange:
ServerHttpRequest newRequest = builder.build();
ServerWebExchange newExchange = exchange.mutate() .request(newRequest) .build();
return chain.filter(newExchange);如果只构造 newRequest,却仍把原 exchange 传给 chain.filter,下游就看不到新 Header。当前代码正确地传递了 newExchange。
6. 配置、风险与运行边界
Gateway 的仓库内 application.yml 只能确认服务会从 Nacos 导入公共配置和服务专属配置。当前源码依赖:
authUrls.url:一个 PathPattern 字符串;- Redis / Redisson 连接参数;
- Gateway 路由、前缀和路径改写规则;
- 服务注册与发现。
这些外部值本轮均未读取,因此不能根据 Controller 路径反推出最终公网路径,也不能宣称所有敏感接口已被 authUrls.url 覆盖。
7. 短复习点
- 路径匹配使用一个
PathPattern字符串,不是代码内维护的多模式列表。 - Token 优先从 Header 读取,Header 为空时才读取 Cookie;无效 Header 不会回退有效 Cookie。
hasLogin用 null、"-1"和 userId 字符串表达三种结果。- userId 来自 Redis 会话;userTempId 来自客户端,只能作为匿名购物车标识。
- 下游必须接收
newExchange,否则 Gateway 新增的 Header 不会被转发。
8. 关键源码导航
| 阅读顺序 | 文件 | 关键位置 | 作用 |
|---|---|---|---|
| 1 | duolaimall-gateway/src/main/java/com/cskaoyan/mall/gateway/filter/AuthFilter.java | filter | 鉴权主流程与请求变更 |
| 2 | 同上 | judgeNeedLoginByUrl | PathPattern 匹配 |
| 3 | 同上 | hasLogin | Token、Redis 与 IP 校验 |
| 4 | duolaimall-user/user-api/src/main/java/com/cskaoyan/mall/user/dto/UserLoginInfoDTO.java | DTO | Gateway 读取的会话值 |
| 5 | duolaimall-common/common-util/src/main/java/com/cskaoyan/mall/common/util/IpUtil.java | getGatwayIpAddress | Reactive 请求 IP 提取 |
9. 本章总结
Gateway 完成的是基础认证和身份恢复;订单归属、地址归属等资源级授权仍由业务服务负责。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!