Spring Boot + JWT实现登录与接口权限控制:核心代码与避坑指南
做Java后端开发的朋友肯定绕不开Spring Boot接口权限控制这件事。凡是系统需要登录就离不开登录接口和JWT令牌这套组合。很多教程只给代码不讲取舍和坑真上了生产环境就出各种奇怪问题。这篇文章不聊空泛的理论直接把Spring Boot JWT实现登录与接口权限控制的完整思路、核心代码、以及我实际踩过的那些坑一次讲完。适合手头有项目要接登录鉴权、或者想把JWT从“听说过”变成“会用”的开发者照着落地就行。登录接口谁都会写但把Token签发、拦截校验、角色授权、过期续签串成一个完整的权限体系才是真正拉开差距的地方。下面我按自己的项目经验从整体方案开始讲再逐步落到代码上。1. 整体设计与思路拆解1.1 为什么用JWT而不是Session先说传统方式。早期Java Web项目基本是Session Cookie用户登录后服务端存一份Session数据把SessionId通过Cookie塞回浏览器下次请求带过来就能认出用户。单体小项目这么干很顺畅可一旦项目进入分布式部署问题立刻显现用户第一次请求落到A机器Session在A上第二次请求被负载均衡转发到B机器B上没有这份Session就会把用户踢回登录页。要做Session共享就得引入粘性会话、Redis保存Session或者Session集群同步每一套都够折腾。JWT是另一条路线。它把“用户是谁、角色是什么、过期时间”等信息打包成一串令牌用服务端密钥签名后交给客户端。服务端不存任何会话数据以后请求只要带上令牌校验签名和有效期就能确认身份。因为这个特性JWT天然无状态服务节点水平扩容时不用同步任何会话数据多端网页、小程序、APP也都好接。我当时把项目从Session切到JWT后最直观的感受是“加机器不用考虑登录态问题了”这种省心很难用代码量衡量。当然JWT也不是银弹。它最大的缺点是服务端没法主动让令牌失效只能等过期所以后面要聊续签和黑名单方案。我的建议是单体、纯内部系统、会话模式运行得很好可以继续用Session一旦要考虑前后端分离、分布式、开放API尽早切JWT别等Session的坑一个接一个踩出来再改。1.2 登录认证的完整流程一套完整的JWT登录流程我习惯拆成七步来给团队新人讲客户端把用户名和密码提交到登录接口。服务端查数据库拿到用户记录用BCrypt校验密码是否一致。校验通过后用用户ID、用户名、角色等信息生成JWT令牌。服务端把令牌返回给客户端前端存到localStorage或内存状态里。后续每次请求前端自动在请求头里加上Authorization: Bearer 。服务端通过拦截器拦截所有受保护接口解析并校验JWT的签名与过期时间。校验通过就把用户信息放到当前线程的ThreadLocal里Controller直接取用校验失败返回401前端收到后跳回登录页。这里有几个细节我特别提醒。Token放请求头而不是Cookie主要为了避开Cookie跨域限制和CSRF问题这是前后端分离项目里的标准姿势。Authorization头前面的Bearer前缀是业内习惯很多开源SDK也默认按这个格式解析建议保持一致。第6步的拦截器要覆盖所有需要登录的接口但登录接口本身必须放行否则用户连登录都登不进去。1.3 技术选型拦截器方案和Spring Security的取舍Java生态里做接口权限控制最常被问到的就是“你怎么不用Spring Security”。Spring Security确实是功能最全的权限框架支持OAuth2、方法级安全、过滤链扩展大厂规范项目用得多。但它的问题是配置庞大、学习曲线陡我只想实现“登录JWT角色校验”这个量级的功能为了几个接口引入整套Security反而把复杂度拉满了。我在这套方案里用的是Spring Boot原生HandlerInterceptor加自定义Jwt工具类。核心原因有三点第一拦截器能拿到HandlerMethod对象方便做注解式授权比如给管理员接口打一个RequireRole(ADMIN)就能控制访问第二代码链路短从请求进来到Token校验、角色判断、放行全部逻辑在自己手里出了问题好排查第三依赖轻不引入Security自动过滤器不干扰项目里已有的配置。不是说Spring Security不好而是要看场景。如果你们系统已经重度使用Security的认证体系或者需要对接OAuth2、CAS这类复杂认证协议那直接学Security。如果是普通业务系统或者想先把JWT这套机制吃透拦截器方案完全够用而且从这套代码切到Security时JwtUtil和登录接口基本还能复用。2. 核心细节解析与实操要点2.1 JWT令牌结构等于“带印章的纸条”JWT看起来是一长串乱码实际用点号分成三段例如eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI4IiwidXNlcm5hbWUiOiJ6aGFuZ3NhbiJ9.0xW_0ZpjXV8GmDqQVvMvSs93Y13ZWhLGHHKAH0yDzwU第一段Header声明签名算法HS256和令牌类型第二段Payload放业务数据比如userId、username、role、过期时间第三段Signature是服务端用密钥对前两段内容加签名算法算出来的。整个过程可以类比成“写了一张纸条再盖一个服务端专属的章”。纸条本身谁都能看但任何人改动纸条内容章就对不上服务端一验就知道被篡改了。这里有个特别容易踩的误区JWT是签名不是加密。Payload是用Base64URL编码的拿到令牌解码就能看到里面的内容敏感信息绝对不能放进去。很多初学者顺手把手机号、住址写进claimsToken一旦泄露这些信息就全暴露了。要记住JWT保证的是“不可篡改”而不是“不可见”。安全性靠的是第三段签名所以密钥必须只保存在服务端绝不能出现在前端代码、配置仓库或者日志里。2.2 密码加密BCrypt带来的安全感密码存储这块我看到太多老项目用MD5。MD5是摘要算法同一输入永远得到同一输出黑客拿着彩虹表和弱密码字典几分钟就能批量还原明文基本等于没加密。现代密码存储的标准是加盐哈希BCrypt就是其中使用最广泛的实现。BCrypt有两个核心优势一是自动带随机盐同一个密码每次生成的哈希都不同从根本上防彩虹表二是计算成本可调可以人为提高迭代次数让暴力破解的速度慢到不可接受。Spring Boot生态里可以用spring-security-crypto这个独立模块里面提供了BCryptPasswordEncoder只引这一个模块不会触发Security的自动过滤器非常清爽。实际项目用法分两个场景。注册的时候user.setPassword(encoder.encode(rawPassword))存进去的就是BCrypt哈希登录的时候encoder.matches(rawPassword, user.getPassword())返回true就通过。matches方法会自动从已存哈希里取出盐重新计算对比上层不用关心盐怎么存、怎么取这就是BCrypt最省心的地方。2.3 密钥与过期时间的正确配置姿势JWT安全的核心是密钥但我见过不少项目把密钥写成固定字符串常量甚至每次启动随机生成。随机生成的后果很严重服务一重启所有已签发Token的签名都校验不过线上用户集体掉线重新登录体验极其糟糕。硬编码的坑在于一是密钥太短容易被爆破二是代码一旦泄露Token等于可以被任意伪造。正确做法是把密钥放到application.yml或者配置中心通过环境变量按环境替换。密钥要选足够长的随机字符串至少32字节HS256算法才安全。我一般用60字节以上的随机串并且配合密钥轮换机制比如设计成双密钥配置新旧并行一段时间再切换。过期时间按业务场景区分管理后台的安全要求高我通常给15到30分钟普通C端App或者小程序可以放宽到7天左右但越长风险越大务必配合续签机制平衡体验。另外要明确一点JWT里的exp是硬性过期时间生成之后就改不了没有“自动续期”这种内置能力。业务上要求“用户一直在操作就别让他下线”就得自己做续签逻辑这个我在后面实操章节会给一个轻量方案。2.4 认证和授权要分清接口权限控制这件事很多人把认证和授权混在一起。认证是证明“你是谁”授权是确认“你能干什么”。JWT解决的是认证这一层让服务端确认请求来自一个已登录、令牌未被篡改的用户。但认证通过不等于所有接口都能访问。比如用户中心和用户管理都有用户数据普通用户只能看自己的管理员能操作所有账号这就是授权要管的事。设计接口权限时我会按两层来规划第一层是所有受保护接口必须登录拦截器校验JWT第二层是管理类接口必须满足角色或权限码要求通过注解在方法上声明拦截器取到注解后对比当前用户的角色。这样设计的好处是权限控制点集中看代码的人能清楚知道每个接口开放到什么程度。后面实际操作里我会把注解授权的完整代码写出来。3. 实操过程与核心环节实现3.1 项目结构和依赖版本我基于Spring Boot 2.7来写Maven工程。核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency特别说明一个坑不要引入完整的spring-boot-starter-security否则Security的自动过滤器会默认保护所有接口你还没写任何配置系统就先拦一道和自定义拦截器逻辑叠加反而容易乱。spring-security-crypto只是提供密码加密工具不会触发过滤器链。持久层我用MyBatis-Plus主要是演示方便你换成JdbcTemplate、JPA都没问题JWT部分完全不受影响。3.2 数据库设计、实体与持久层用户表设计得很精简但字段含义明确CREATE TABLE t_user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(64) NOT NULL COMMENT 登录名, password VARCHAR(128) NOT NULL COMMENT BCrypt哈希, role VARCHAR(32) NOT NULL DEFAULT USER COMMENT 角色, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;对应实体类public class User { private Long id; private String username; private String password; private String role; private Integer status; // getter/setter }UserMapper继承MyBatis-Plus的BaseMapper不需要手写SQLMapper public interface UserMapper extends BaseMapperUser { }application.yml里把数据源和JWT参数配好server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jwt: secret: change-this-to-a-long-random-base64-string-at-least-64-bytes-2024 expire: 1800 # 秒30分钟初始化数据的时候密码字段存的是BCrypt哈希不是明文。最简单的办法是写一个临时接口或者用测试代码调用BCryptPasswordEncoder生成别把明文密码直接塞进去。3.3 统一返回体与登录接口项目里我习惯用统一的Result对象封装返回值前端解析逻辑才统一。一个精简版本public class Result { private Integer code; private String msg; private Object data; public static Result success(Object data) { Result r new Result(); r.code 200; r.msg success; r.data data; return r; } public static Result error(Integer code, String msg) { Result r new Result(); r.code code; r.msg msg; return r; } // getter/setter }登录Controller代码如下RestController RequestMapping(/api/auth) public class AuthController { Resource private UserMapper userMapper; Resource private JwtUtil jwtUtil; Resource private BCryptPasswordEncoder encoder; PostMapping(/login) public Result login(RequestBody LoginRequest request) { User user userMapper.selectOne( new LambdaQueryWrapperUser().eq(User::getUsername, request.getUsername())); if (user null || !encoder.matches(request.getPassword(), user.getPassword())) { return Result.error(400, 用户名或密码错误); } if (user.getStatus() ! 1) { return Result.error(403, 账号已被禁用); } String token jwtUtil.createToken(user.getId(), user.getUsername(), user.getRole()); return Result.success(token); } GetMapping(/me) public Result me() { User current UserContext.getUser(); return Result.success(current); } }关于登录失败的提示我故意不区分“用户不存在”和“密码错误”防止攻击者通过接口批量探测哪些账号存在。这个问题在开放注册系统里尤其要注意。登录成功后后端不主动存Token所有状态都在Token本身里这样才能享受无状态带来的扩容便利。3.4 JwtUtil签发与解析的重点细节JwtUtil封装了Token的签发和解析是整个权限体系的核心工具Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; // 秒 private SecretKey secretKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String createToken(Long userId, String username, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expire * 1000L)) .signWith(secretKey(), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(secretKey()) .build() .parseClaimsJws(token) .getBody(); } }这里有几个细节值得强调。第一HS256的密钥必须大于32字节否则启动时会直接抛WeakKeyException很多人配置了一看启动失败就懵其实换个长密钥就好。第二Subject字段我放的是userId不是用户名因为userId在业务里是唯一的用户名理论上可能被修改一旦修改就会导致旧Token里的用户名对不上当前用户。第三设置过期时间时注意单位换算expire配置的是秒生产代码里千万别把1800直接当成毫秒用否则Token两秒不到就失效了。3.5 拦截器实现认证的统一入口定义用户上下文UserContext用ThreadLocal保存当前请求的用户信息public class UserContext { private static final ThreadLocalUser HOLDER new ThreadLocal(); public static void set(User user) { HOLDER.set(user); } public static User getUser() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }再说拦截器本身JwtInterceptor逻辑分四步先看是不是HandlerMethod再取Authorization头并去掉Bearer前缀接着解析校验Token最后把用户塞进UserContextComponent public class JwtInterceptor implements HandlerInterceptor { Resource private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } if (!(handler instanceof HandlerMethod)) { return true; } String header request.getHeader(Authorization); if (header null || !header.startsWith(Bearer )) { writeUnauthorized(response, 未登录或Authorization头缺失); return false; } String token header.substring(7); try { Claims claims jwtUtil.parseToken(token); User user new User(); user.setId(Long.valueOf(claims.getSubject())); user.setUsername(claims.get(username, String.class)); user.setRole(claims.get(role, String.class)); UserContext.set(user); return true; } catch (ExpiredJwtException e) { writeUnauthorized(response, 登录已过期); return false; } catch (Exception e) { writeUnauthorized(response, 无效令牌); return false; } } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } private void writeUnauthorized(HttpServletResponse response, String msg) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\ msg \}); } }然后通过WebMvcConfigurer注册拦截器只拦API路径并放行登录接口Configuration public class WebMvcConfig implements WebMvcConfigurer { Resource private JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login); } }第一行放行OPTIONS是为了配合跨域这个问题在常见问题里会详细说。afterCompletion里一定要clear掉ThreadLocal否则Tomcat线程池复用会导致用户信息串到下一个请求这是线上最容易出现的隐蔽Bug。3.6 接口授权用注解划分角色权限认证完成后再补授权这一层。自定义一个注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String value(); }在需要管理员权限的接口上标注GetMapping(/admin/delete) RequireRole(ADMIN) public Result deleteUser(RequestParam Long id) { // 删除逻辑 }然后在JwtInterceptor的preHandle里在Token校验通过之后增加角色判断RequireRole requireRole ((HandlerMethod) handler).getMethodAnnotation(RequireRole.class); if (requireRole ! null) { String role UserContext.getUser().getRole(); if (!requireRole.value().equals(role)) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\msg\:\无权限访问\}); return false; } }这套思路的扩展性很好。如果后续权限细分到操作级别可以把token里的role换成权限码列表拦截器里再统一做包含判断。接口层面的权限控制和JWT认证解耦新增接口时按需标注注解即可不用每个接口里重复写角色判断逻辑。3.7 登录态续签与退出登录方案JWT过期时间到了就是到了服务端没法改。为了兼顾安全和体验我推荐滑动有效期方案在拦截器里发现Token剩余有效期低于某个阈值时签发一个新Token放到响应头前端统一处理替换。JwtUtil里加一个按现有Claims重新签发的方法public String renewToken(Claims claims) { return createToken( Long.valueOf(claims.getSubject()), claims.get(username, String.class), claims.get(role, String.class) ); }在拦截器preHandle里增加判断long remain claims.getExpiration().getTime() - System.currentTimeMillis(); if (remain 10 * 60 * 1000L) { String newToken jwtUtil.renewToken(claims); response.setHeader(X-New-Token, newToken); }前端在HTTP响应拦截器里检查X-New-Token有就用它替换本地Token。这样用户只要还在活跃操作登录态就一直续着真离开了足够久Token自然过期重新登录也没毛病。退出登录这块要面对JWT的先天弱点服务端不能主动撤销Token。最简单的方案是前端删除本地Token然后跳登录页但这只对“正常退出”有效攻击者手里已经拿到的Token在过期前依然能用。安全性要求高的系统建议引入Redis做黑名单生成Token时把它的唯一标识jti存到Redis并设置相同过期时间拦截器校验时查到黑名单就拒绝请求退出登录时写入黑名单即可。这个方案会引入Redis依赖但换来的是真正可控的“退出即失效”。4. 常见问题与排查技巧实录4.1 登录成功但接口永远401这个是我被问过最多的问题。九成原因是前端请求头格式不对要么没带Authorization头要么少了Bearer前缀和空格要么Token字符串夹带了引号或多余空格。服务端解析时严格按照header.startsWith(Bearer )来判断任何偏差都会落到“无效令牌”分支。排查思路很直接打开浏览器开发者工具看发起请求的Network面板找到对应的请求检查Request Headers。如果是Authorization: Bearer eyJ...这种格式就没问题否则去改前端请求封装。如果请求头和格式都对那大概率是后端密钥或解析逻辑的问题可以在拦截器preHandle里临时打印日志把进入时的原始请求头打出来一眼就能定位。4.2 拦截器返回401时中文乱码我自己第一次写拦截器返回JSON时也踩过这个坑。response.getWriter().write(登录已过期)前端拿到的是一串问号。原因很简单response没有指定UTF-8编码默认用了ISO-8859-1。统一的处理方式是在所有返回错误响应的地方设置ContentTyperesponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\登录已过期\});把这段封装成拦截器的私有方法所有写401、403的地方都走同一个方法就不会出现某处漏写编码导致乱码的情况。如果想更规范建议把JSON序列化换成Jackson或Fastjson避免手动拼接JSON字符串出错。4.3 跨域导致鉴权失效的真相前后端分离项目最常见的跨域坑我来还原一下场景前端在8080端口后端在8081端口浏览器发请求前会先发一个OPTIONS预检请求询问服务端允许的跨域方法、请求头等。问题在于OPTIONS请求一般不会带Authorization头如果拦截器不放行这个预检请求直接被401拒了真正的业务请求根本发不出去。解决办法就一行代码在preHandle方法最前面加if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; }同时后端要配置好CORS允许Authorization这个自定义头。如果用了Spring的CorsFilter过滤器优先级也要注意不要让CORS配置被拦截器挡住。4.4 服务重启后用户集体掉线遇到“每次发版所有用户都得重新登录”的情况基本可以断定密钥是随机生成的。有些开发看到网上教程写Keys.secretKeyFor(SignatureAlgorithm.HS256)以为每次调用动态生成更安全其实这个方法每次启动都会生成一个全新的随机密钥以前签发的Token全部作废。修正方案就是我在密钥配置里强调的把jwt.secret固定到配置文件里环境变量按环境替换。这里再补充一点dev、test、prod三个环境的密钥应不同防止有人拿测试环境的密钥去伪造生产环境的Token。密钥轮换也是必须考虑的长期动作建议预留一个新旧密钥并存的扩展点切密钥时平滑过渡。4.5 Token里的敏感信息与常见JWT漏洞JWT的Payload只是Base64URL编码不是加密。这个认知没建立起来后面所有安全讨论都谈不上。有人把手机号、身份证放进去Token一旦泄露等于裸奔。正确做法是claims只放userId、username、role这类必要信息其余数据接口按需查询。热词里提到的“jwt漏洞总结”有几种典型攻击值得知道algnone攻击攻击者把Header里的算法改成none后重新伪造Token如果服务端校验不严格签名就变成空字符串了。用jjwt库并且setSigningKey解析库本身就不会接受none算法但要小心别只依赖库默认行为。弱密钥爆破HS256的密钥太短离线暴力破解成本很低。密钥必须长、随机、高熵。算法混淆攻击攻击者把签名算法从HS256改成RS256尝试用服务端公钥当作HMAC密钥来签名。修法是在解析时明确使用本地配置的密钥不要从Token里读取算法来决定用哪把钥匙。日志泄密生产环境日志里打印完整Token等于把用户钥匙公开在日志平台上。排查问题可以打Token前几位和用户ID绝不要整串打出来。4.6 ThreadLocal会引发空指针ThreadLocal在拦截器里保存用户信息如果不在afterCompletion里执行removeTomcat的工作线程会被复用下一个请求就可能读到上一个请求残留的用户数据。这个问题表现起来很隐蔽可能是偶发的权限异常、数据串号。记住一条铁律在afterCompletion里无条件调用UserContext.clear()。另一个相关坑是异步线程。Controller里用线程池异步处理任务时子线程拿不到UserContext里的用户信息因为ThreadLocal不跨线程传递。解决办法有两个方向一是在提交任务时把userId作为参数显式传下去二是自定义一个线程池装饰器在任务执行前设置UserContext执行后再清除。第一种方案更直观实际情况里我优先用它。4.7 常见问题速查表现象可能原因解决建议接口全部401前端请求头格式错误确认是“Bearer token”HEADER里无多余空格只有登录接口正常拦截器拦截路径过广用excludePathPatterns放行登录接口登录后第一次请求就401密钥配置未固定在application.yml固定jwt.secret管理接口普通用户能访问缺少授权逻辑用RequireRole注解在拦截器中判断跨域项目接口401OPTIONS被拦截preHandle中放行OPTIONS请求用户退出后Token仍有效JWT无法主动撤销引Redis黑名单或缩短过期时间ThreadLocal用户串号没有清除线程变量afterCompletion里必须remove异步线程拿不到用户ThreadLocal不跨线程显式传递userId作为参数返回JSON中文乱码ContentType没指定UTF-8统一写错误响应方法重启服务全部掉线密钥每次启动随机生成配置固定jwt.secret我自己从Session迁到JWT已经几年了印象最深的不是代码难写而是无状态这个特性带来的工程化收益新节点随时加登录态不会挂。但它也不是银弹密钥轮换、敏感信息收敛、过期策略这些必须在架构设计阶段想好而不是上线后遇到问题再补。实际项目里我还会建议团队沉淀一套统一响应体和错误码把鉴权失败、权限不足、参数错误区分开前端收到后处理逻辑会非常清晰。如果你正打算给自己的项目加登录鉴权不妨先把这套拦截器加JWT的方案跑通再考虑是不是升级到Spring Security那套更重的体系。