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() 写好了,但没有任何地方调用它

问题就出在最后一行。令牌是签发了,前端也老老实实带着, 但服务端从来没检查过 —— 谁都不带令牌,一样能进。

这类 bug 特别隐蔽的原因: 前端做得越完整,越不容易暴露。因为正常使用时前端总会带上令牌, 接口永远返回 200,一切看起来都正常。只有当你不带令牌去请求时, 才会发现它根本没拦。

怎么补

最省事也最不容易漏的做法,是在框架层统一拦截,而不是在每个 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
带一个伪造的 token401
正常登录后带真 token200
不带 token 调最敏感的写接口401,且确认副作用没有发生

最后一条特别重要。我第一次测的时候只看状态码,没检查设备到底有没有被锁定 —— 结果是 200,而且真的执行了。补完之后我又测了一次,这次不但状态码是 401, 也确认了副作用确实没有发生。

小结

这次教训是:鉴权是服务端的事,前端做的所有事情都只能算体验优化。 前端带不带 token、跳不跳登录页,对一个直接发 HTTP 请求的人来说毫无约束力。

另外,如果你手上有已经上线一段时间的项目,建议都花五分钟用 curl 不带凭证打一遍接口。这种问题的特点是「跑得越好越看不出来」, 等被别人发现就晚了。

← 返回首页