JWT 鉴权只写在前端,等于没有鉴权
前段时间给自己的一个系统做收尾,顺手测了一下接口,结果发现了一件挺吓人的事: 不带任何凭证就能拿到全部数据,还能执行写操作。 而这个系统上线已经几个月了,登录、鉴权、401 跳转,表面上一样不缺。
事情是怎么发现的
当时在排查另一个问题,想确认某个接口返回什么,就顺手用 curl
打了一下。忘了带 Authorization 头,本以为会吃个 401,
结果直接返回了 200 和一整页数据。
换了个更敏感的接口试:
curl -s http://example.com/api/admin/users
# 返回 200,以及完整的管理员账号列表
到这一步已经可以确认:鉴权根本没生效。 于是我又试了写操作:
curl -s -X POST http://example.com/api/control/lock
# HTTP 200
# {"status":"CONFIRMED","devices":1}
也就是说,任何知道这个地址的人,都可以远程执行本系统最敏感的操作。
为什么会这样
翻了一遍代码,找到了原因。整个链路其实是「做了一半」:
| 环节 | 状态 | 说明 |
|---|---|---|
| 登录签发 JWT | ✅ 做了 | JwtService.issue() 正常生成令牌,带过期时间 |
| 前端携带令牌 | ✅ 做了 | axios 拦截器自动加 Authorization: Bearer ... |
| 前端处理 401 | ✅ 做了 | 清 token、提示过期、跳登录页,挺完善 |
| 服务端校验令牌 | ❌ 没做 | JwtService.parse() 写好了,但没有任何地方调用它 |
问题就出在最后一行。令牌是签发了,前端也老老实实带着, 但服务端从来没检查过 —— 谁都不带令牌,一样能进。
怎么补
最省事也最不容易漏的做法,是在框架层统一拦截,而不是在每个
Controller 里各写一遍判断。以 Spring Boot 为例,加一个
HandlerInterceptor:
@Component
@RequiredArgsConstructor
public class JwtAuthInterceptor implements HandlerInterceptor {
private final JwtService jwtService;
@Override
public boolean preHandle(HttpServletRequest req,
HttpServletResponse res,
Object handler) throws Exception {
// 跨域预检不带凭证,放行
if (HttpMethod.OPTIONS.matches(req.getMethod())) return true;
String header = req.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
return reject(res, "未登录或登录已过期");
}
Claims claims = jwtService.parse(header.substring(7).trim());
if (claims == null) {
return reject(res, "登录凭证无效或已过期");
}
req.setAttribute("auth.userId", claims.getSubject());
return true;
}
private boolean reject(HttpServletResponse res, String msg) throws Exception {
res.setStatus(401);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("{\"code\":401,\"message\":\"" + msg + "\"}");
return false;
}
}
然后注册到统一的路径前缀上:
@Configuration
@RequiredArgsConstructor
public class WebAuthConfig implements WebMvcConfigurer {
private final JwtAuthInterceptor jwtAuthInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtAuthInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/login"); // 登录本身不能拦
}
}
几个容易做错的地方
1. 豁免清单要逐条写理由
每一条 excludePathPatterns 都等于对公网开放一个口子。
加的时候问自己一句「这条为什么可以不登录」,答不上来就别加。
2. 静态资源和 API 要分开考虑
管理端的 HTML/CSS/JS 通常不在 /api/** 下面,所以不会被拦 ——
这是对的,未登录的人应该能看到登录页。但要确认的是:
页面上任何一个数据请求都会拿到 401,这样前端跳登录的逻辑才会触发。
3. 别忘了 OPTIONS
浏览器发跨域预检请求时不会带 Authorization。
如果把它一起拦了,所有跨域请求都会失败,而且报错信息很难看懂。
4. 返回 401,不要返回 200 + 错误码
有些项目喜欢统一返回 {"code": 401} 但 HTTP 状态是 200。
这样前端的 axios 拦截器走不到 error 分支,401 自动跳转就失效了。
怎么验证补好了
至少测这四种情况,缺一不可:
| 请求 | 预期 |
|---|---|
| 不带 token 调业务接口 | 401 |
| 带一个伪造的 token | 401 |
| 正常登录后带真 token | 200 |
| 不带 token 调最敏感的写接口 | 401,且确认副作用没有发生 |
最后一条特别重要。我第一次测的时候只看状态码,没检查设备到底有没有被锁定 —— 结果是 200,而且真的执行了。补完之后我又测了一次,这次不但状态码是 401, 也确认了副作用确实没有发生。
小结
这次教训是:鉴权是服务端的事,前端做的所有事情都只能算体验优化。 前端带不带 token、跳不跳登录页,对一个直接发 HTTP 请求的人来说毫无约束力。
另外,如果你手上有已经上线一段时间的项目,建议都花五分钟用
curl 不带凭证打一遍接口。这种问题的特点是「跑得越好越看不出来」,
等被别人发现就晚了。