1. 前后端分离下引入SpringSecurity之前的三个决策问题我在接手一个基于SpringBoot3的新项目时第一个要考虑的事情并不是怎么写业务代码而是安全框架怎么落。市面上可选的无非是SpringSecurity、Shiro或者干脆自己手写拦截器。考虑到项目的长期维护、社区活跃度以及和Spring生态的契合度选SpringSecurity基本没什么悬念但真正麻烦的是如何在前后端分离的架构里把它用得顺手。SpringSecurity在传统服务端渲染项目中非常好理解。请求进来经过过滤器链如果没有登录就跳转到一个登录页登录成功后把用户信息丢进Session后续请求通过Cookie里的JSESSIONID识别身份。这套机制在服务端渲染时代很成熟但到了前后端分离前端是Vue、React这类单页应用后端只暴露JSON接口问题就全变了。第一个问题前端不认服务端跳转。如果Security发现未登录就302到登录页前端拿到的是一个重定向而不是JSON这没法用。正确的做法是让Security返回401状态码加一段JSON由前端统一拦截处理。第二个问题Session不再合适。前后端分离后前端和后端通常分开部署Session在单实例下还能用一旦后端多做几个副本Session共享就得引入Redis等外部依赖整体复杂度上去了。更麻烦的是移动端App并没有Cookie的概念天然不适配Session方案。替代方案一般是Token实践中用得最多的又是JWT。第三个问题CSRF防护是不是该关掉。CSRF攻击依赖浏览器自动携带Cookie这个特性如果我用了JWT放在Authorization头里CSRF的威胁模型就不存在了默认开启反而会拦截掉正常请求。所以前后端分离场景下要把它关掉。搞清楚这三个问题之后SpringSecurity在前后端分离下的改造思路就非常清晰了。总结下来就两句话把默认的登录方式从表单改成接口认证加Token把默认的会话管理改成无状态。下面我会从环境准备开始一步步把整个方案落地。2. SpringBoot3加Security 6的环境准备与版本差异2.1 JDK版本与技术栈要求SpringBoot3和之前的2.x版本有一个本质区别它强制要求JDK17及以上。这一点我在第一次搭建项目时就踩过坑当时机器上还装着JDK8依赖一拉下来就直接编译不过报错信息指向jakarta.servlet后来才意识到是SpringBoot3把整个Java EE的命名空间从javax迁移到了jakarta。所以第一步先把JDK升级到17或更高版本。如果你正在用IntelliJ IDEA记得Project Structure里的SDK和Java版本都要同步改包括Maven或者Gradle的JVM配置。这一步没做好后面的代码怎么写都起不来。另外SpringBoot3对应的SpringSecurity版本是6.x。相比5.xSecurity6的配置API有很大变化网上搜到的很多旧教程是Security5的写法直接复制会碰到一堆过时甚至编译错误的方法。比如WebSecurityConfigurerAdapter这个老配置类在Security6里已经被彻底移除了配置方式变成了组件化注册SecurityFilterChain。2.2 Maven依赖引入用Maven管理依赖的话只需要加入一个Security的StarterSpringBoot会自动装配安全机制。同时建议加上SpringWeb和Lombok一个是基础Web能力一个是简化实体类代码实测下来能省不少事。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency依赖引入之后这时候启动项目会发现所有的接口都被保护起来了访问任意路径都会看到一个默认的登录页或者401。这就是Security自动装配的效果后面要做的事情就是把它改造成符合前后端分离需求的样子。注意如果项目里还需要接入Knife4j来生成接口文档依赖方面也要选对版本。SpringBoot3对应的是knife4j-openapi3-jakarta-spring-boot-starter而不是老版本的knife4j-spring-boot-starter。前者基于OpenAPI3规范适配了jakarta命名空间直接用老版本会在启动时报类找不到的错误。2.3 理解Security6的过滤器链机制在动手写代码之前我觉得有必要花一点时间理解SpringSecurity的执行机制不然配置类里的那一堆lambda表达式很容易让人一头雾水。SpringSecurity的核心是一个过滤器链它由若干个过滤器组成请求进来后按照顺序经过这些过滤器每个过滤器只干一件事。有的负责构造认证信息有的负责校验CSRF有的负责权限判断。当整个过滤器链执行完成后请求才会到达Controller层。Security6的开放配置方式是把过滤器的默认行为配置到一个SecurityFilterChain组件里。你可以理解成有一串默认的过滤器我们通过HttpSecurity这个配置对象来增减、调整它们的行为。比如我们要关闭CSRF就把这环摘掉我们要在认证前检查JWT就自定义一个过滤器插到链中合适的位置。3. 核心准备工作用户模型、认证服务与JWT工具类3.1 用户表结构与UserDetailsService实现既然要接入Security用户的加载过程就要交给框架来管理。Security定义了一个UserDetailsService接口业务系统只需要实现它告诉框架给我一个用户名我返回这个用户的账号、密码、角色等信息。框架拿到之后会自己完成密码比对我们不需要手动做比对逻辑。用户表我一般这么设计字段名类型说明idbigint主键usernamevarchar登录名passwordvarcharBCrypt加密后的密码nicknamevarchar昵称rolevarchar角色标识如ADMIN、USERstatustinyint状态1启用0禁用created_timedatetime创建时间对应的实体类不用多说了重点是实现UserDetailsService时怎么把数据库用户转换成Security认识的UserDetails对象。Service public class UserServiceImpl implements UserDetailsService { Resource private UserMapper userMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user userMapper.selectByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } if (user.getStatus() 0) { throw new DisabledException(账号已禁用); } return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .roles(user.getRole()) .build(); } }这里有一点要特别提醒loadUserByUsername方法返回的UserDetails中的密码必须是加密后的密文不能是明文。因为Security框架拿到密文之后会用PasswordEncoder去和前端提交的明文做比对。如果数据库中存的是明文要么报错要么永远比对不通过。3.2 BCrypt密码加密与DelegatingPasswordEncoder关于密码加密Security6默认使用的是BCryptPasswordEncoder。它的特点是同一个密码每次加密的结果都不一样因为BCrypt算法内部会随机生成盐值并把这些盐值包含在最终结果里验证时从密文中提取盐值再比对。相比MD5这种老算法安全性高得多。注册新用户时一般通过PasswordEncoder的encode方法生成密文再入库。实现在容器中声明一个PasswordEncoder的Bean即可Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }我在实际项目中见过有人为了方便测试直接在前端传了一个明文密码然后后端又用了NoOpPasswordEncoder。这种做法非常危险一旦数据库泄露所有账号密码直接暴露。生产环境千万不要图省事绕过加密省下来的那点开发时间后面可能用几倍的代价来还。3.3 JWT工具的封装思路JWT本质上是一段包含用户信息且经过签名防篡改的字符串。通常包含三部分头部、载荷、签名。头部声明算法和类型载荷放业务数据比如用户ID、用户名、过期时间签名用密钥生成用于验证令牌是否被篡改。我封装的JwtUtils主要提供三个方法生成token、解析token、校验token是否过期。生成时把用户ID和用户名放进去过期时间一般设24小时实际项目有更高安全需求就缩短并配合RefreshToken机制这里先不做展开。Component public class JwtUtils { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private long expire; public String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expire * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } public boolean isTokenExpired(String token) { return parseToken(token).getExpiration().before(new Date()); } }使用JWT的时候有几个细节需要提前注意。JWT是无法在服务端主动失效的它的有效性只取决于签名和过期时间所以如果系统有封禁用户的需求光靠JWT是不行的需要额外的黑名单机制配合。另外如果项目里配置了多个微服务实例各实例必须共享同一个secret否则A服务签发的token在B服务上会解析失败。生产环境的secret建议放到配置中心统一管理。4. 认证过滤器和SecurityConfig整套方案的心脏4.1 自定义JWT认证过滤器接下来是这个方案里最核心的自定义组件JwtAuthenticationTokenFilter。它的职责很单一从请求头中读取token解析成功后把用户信息放进SecurityContext里。这样后续的过滤器就能判断当前用户是谁有什么权限。这个过滤器需要继承OncePerRequestFilter保证一个请求只执行一次。里面大致逻辑是从Authorization头中取token如果没有就放行让后面的过滤器去处理如果有则解析token从token中取出用户名再去数据库查一次完整用户信息查到了就构建一个Authentication对象放进SecurityContextHolder。Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { Resource private JwtUtils jwtUtils; Resource private UserServiceImpl userService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (StringUtils.hasText(authHeader) authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims jwtUtils.parseToken(token); String username claims.getSubject(); if (username ! null SecurityContextHolder.getContext().getAuthentication() null) { UserDetails userDetails userService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { // token无效时不设置认证信息后续会走401逻辑 } } filterChain.doFilter(request, response); } }这里有个关键点需要解释为什么查了一遍UserDetailsService还要检查SecurityContextHolder里的Authentication是否为空。因为有可能会话中已经有认证信息了重复设置反而可能导致旧的认证信息被覆盖产生不必要的麻烦。这种写法也符合Security框架的设计习惯。4.2 SecurityConfig配置类的详细解析配置类是整套方案的粘合点。前面写了用户服务、写了JWT工具、写了过滤器但如果不把它们在配置类里关联起来Security框架根本不知道这些组件的存在。下面是完整配置类我会对每一段配置说明它的作用Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { Resource private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers( /api/auth/login, /api/auth/captcha, /doc.html, /webjars/**, /v3/api-docs/**, /favicon.ico ).permitAll() .anyRequest().authenticated() ) .exceptionHandling(handler - handler .authenticationEntryPoint((request, response, authException) - { response.setContentType(application/json;charsetutf-8); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); }) .accessDeniedHandler((request, response, accessDeniedException) - { response.setContentType(application/json;charsetutf-8); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.getWriter().write({\code\:403,\msg\:\权限不足\}); }) ) .authenticationProvider(authenticationProvider()) .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider provider new DaoAuthenticationProvider(); provider.setUserDetailsService(userService); provider.setPasswordEncoder(passwordEncoder()); return provider; } }这段配置有几个细节必须说清楚。第一requestMatchers替代了老版本里的antMatchers。Security6之后原来的antMatchers方法被移除了换成requestMatchers。如果不小心用了旧写法代码直接编译不过。permitAll意味着这些路径不需要认证就能访问登录接口、文档地址、静态资源都放这里。第二exceptionHandling里配置的两个处理器非常关键。authenticationEntryPoint处理的是未认证的请求也就是401场景accessDeniedHandler处理的是已认证但没有权限的场景也就是403场景。前后端分离下这两个处理器必须自定义否则Security会按默认的页面跳转逻辑来这不符合接口化需求。第三addFilterBefore方法。这句话表示把我们自定义的JWT过滤器插到UsernamePasswordAuthenticationFilter之前执行。也就是说请求会先经过我们的过滤器尝试解析token解析成功就放行后面的认证过滤器一看已经认证过了就直接跳过。这个顺序很重要放错位置会导致token解析了但认证信息没生效。4.3 无状态会话和跨域配置的联动上面的配置里有一行.sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))它的作用是告诉Security不要创建HttpSession也不要从HttpSession中读取认证信息。这行配置是前后端分离的核心少了它JWT方案和Session方案会同时生效产生一些很奇怪的交互问题。同时现在的项目基本都会遇到跨域请求。如果前端和后端不在同一个域名或者端口浏览器会拦截跨域响应需要后端配置CORS。Security的跨域配置和SpringMVC的配置是两套体系只配了SpringMVC的CORS还不够因为Security的过滤器链会先于SpringMVC执行跨域请求可能在Security这一层就被拦住了。推荐在SecurityConfig里加上这一段并把CORS处理器配置到Security链路中Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOriginPatterns(List.of(*)); config.setAllowedMethods(List.of(GET, POST, PUT, DELETE, OPTIONS)); config.setAllowedHeaders(List.of(*)); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }然后把上面的配置类中http链路的开头加上.cors(cors - cors.configurationSource(corsConfigurationSource()))即可。注意setAllowedOriginPatterns和setAllowedOrigins是有区别的。如果开了allowCredentials(true)用setAllowedOrigins(*)会报错而setAllowedOriginPatterns可以支持通配所以推荐用它。5. 登录接口与权限控制的完整实现5.1 登录接口的实现逻辑配置都接好后就轮到业务层实现登录接口了。登录接口的路径对应前面permitAll里放行的/api/auth/login。这个接口接收用户名和密码调用AuthenticationManager完成校验校验成功后签发JWT返回给前端。RestController RequestMapping(/api/auth) public class AuthController { Resource private AuthenticationManager authenticationManager; Resource private JwtUtils jwtUtils; PostMapping(/login) public Result login(RequestBody LoginDTO loginDTO) { UsernamePasswordAuthenticationToken token new UsernamePasswordAuthenticationToken(loginDTO.getUsername(), loginDTO.getPassword()); Authentication authentication authenticationManager.authenticate(token); UserDetails userDetails (UserDetails) authentication.getPrincipal(); String jwt jwtUtils.generateToken(userDetails.getUsername()); return Result.success(jwt); } }这里有个思想上的转变要说明白在传统Security的登录流程中提交表单这件事是框架帮你做的你不需要自己写Controller。但在前后端分离方案中我们完全绕开了框架默认的登录入口所以要手动调用AuthenticationManager来完成账号密码校验这件事。如果校验失败认证管理器会抛出BadCredentialsException你可以统一捕获返回用户名或密码错误。5.2 如何在Controller里获取当前登录用户前端拿到token后每次请求都会带上Authorization头。经过JWT过滤器后SecurityContextHolder里已经有了用户信息。在业务代码里有很多地方需要知道当前登录用户是谁我一般用下面两种方式获取。第一种是直接从SecurityContextHolder里取Authentication authentication SecurityContextHolder.getContext().getAuthentication(); String username authentication.getName();这里能不能getName拿到用户名的前提是在JWT过滤器构建UsernamePasswordAuthenticationToken时传入了UserDetails对象否则getName拿到的会是null或者anonymousUser。第二种是SpringMVC支持的参数注入方式加上AuthenticationPrincipal注解后直接拿到UserDetails对象GetMapping(/me) public Result getMe(AuthenticationPrincipal UserDetails userDetails) { return Result.success(userDetails.getUsername()); }这种方式更优雅但要注意AuthenticationPrincipal注入的是Authentication对象的principal属性。我们前面构建过滤器时存入的是org.springframework.security.core.userdetails.User对象所以这里可以直接转型。5.3 方法级权限控制有时候接口层面的放行和拦截还不够需要做到方法级别。比如某些接口只允许管理员调用或者某个业务接口只有特定角色可以用。这时就需要开启方法级权限控制。在配置类中已经加了EnableMethodSecurity注解这之后就可以在方法上使用注解了GetMapping(/admin/user/list) PreAuthorize(hasRole(ADMIN)) public Result listUser() { return Result.success(userService.listAll()); } GetMapping(/user/profile) PreAuthorize(hasAnyRole(ADMIN, USER)) public Result profile() { return Result.success(userService.getProfile()); }hasRole(ADMIN)和hasAuthority(ADMIN)的区别经常有人搞混。简单来说hasRole内部会自动加上ROLE_前缀去匹配也就是说数据库或者UserDetails里角色字段存的是ADMIN用hasRole(ADMIN)就能匹配如果用hasAuthority则必须存的是ROLE_ADMIN才匹配得到。所以用哪种方式取决于你存角色信息的时候带没带前缀两边保持一致就行。5.4 Knife4j接口文档在安全体系中的放行配置现在很多项目都会集成Knife4j来生成接口文档而Security默认拦截所有请求如果不对文档相关路径做放行开发环境想看个接口文档都进不去。SpringBoot3 Knife4j的集成依赖如下dependency groupIdcom.github.xiaoymin/groupId artifactIdknife4j-openapi3-jakarta-spring-boot-starter/artifactId version4.4.0/version /dependency按照Knife4j的文档需要放行的路径一般是/doc.html文档主页面/webjars/**webjars资源/v3/api-docs/**OpenAPI3的JSON数据源/swagger-resources/**Swagger的资源配置这些路径就是前面SecurityConfig中permitAll配置里写的那几项。因为Knife4j本身是一个纯前端的静态资源访问加上OpenAPI数据源的获取都不涉及业务认证逻辑所以放行是安全的。但放行仅限测试环境的意识要有生产环境如果不需要对外暴露文档还是建议把这几项从permitAll中移除。6. 常见问题排查与实战填坑记录6.1 未登录请求返回空白页而不是JSON这个问题几乎每个刚切换SpringBoot3的人都会遇到。明明配置了authenticationEntryPoint但请求未登录接口时返回的居然是一段HTML错误页面而不是自定义的JSON结构。排查思路是看看是不是Security自动装配的默认表单登录和HTTP Basic认证还在生效。如果你没有在SecurityFilterChain中明确禁用formLogin和httpBasicSecurity会默认启用它们当请求未认证时表单登录逻辑会先响应重定向导致自定义的authenticationEntryPoint没有被调用。解决办法是在http链路中显式关闭这两个默认机制.formLogin(form - form.disable()) .httpBasic(basic - basic.disable())关闭之后未认证请求才会走我们配置的authenticationEntryPoint返回401状态码加JSON数据。6.2 授权头带上了token但还是返回401这个坑还挺隐蔽的症状是前端登录正常请求其他接口也带了Authorization头但后端始终提示未认证。排查时我先确认了token本身没有过期然后检查过滤器中解析token的代码最后发现原因出在过滤器没有正常执行。什么情况下过滤器不会执行就是JwtAuthenticationTokenFilter这个类没有被Spring扫描到比如没有加Component注解或者包扫描范围没有覆盖到过滤器所在的位置。另一种常见情况是拦截到的请求路径没有匹配上配置。比如前端请求的是/api/user/info但Security配置里放行的是/api/auth/**而其他路径都需要认证。如果过滤器链整体没生效所有接口都会变成匿名访问或者直接401这个要看日志确认过滤器到底执行了没有。建议在过滤器入口加一行日志这样定位问题快很多。6.3 BCryptPasswordEncoder导致登录报错Encoded password does not look like BCrypt报错信息本身已经说得很清楚了框架拿到的password不是有效的BCrypt格式字符串。出现这个问题的场景通常是两种一是数据库里存的密码是明文二是工具类中手动生成的密码没有经过BCrypt加密。解决方案也很直接重新生成密文。用PasswordEncoder的encode方法为每个人生成新的密文并更新到数据库。这里提醒一下如果系统里有导入历史数据的功能密码字段在导入时一定要统一做加密处理别图方便塞明文后面登录必定报错。6.4 SpringBoot3项目启动失败提示Servlet类不存在如果项目本身用的是Servlet容器比如SpringBoot默认的Tomcat在SpringBoot3下必须使用jakarta.servlet包下的接口。如果你的代码或者第三方依赖里还在引用javax.servlet启动就会报NoClassDefFoundError。这种问题大多是依赖版本没对齐造成的。比如一些老的第三方SDK还在javax命名空间下和SpringBoot3的Tomcat10.1不兼容。解决思路是找到兼容jakarta版本的第三方库切换过来或者放弃基于Servlet的集成方式改用WebFlux这类非Servlet技术栈。6.5 权限注解不生效方法级拦截没有任何反应有时候配置好了PreAuthorize注解也加了EnableMethodSecurity但访问接口时注解就是不生效。这个问题通常出在过滤器链顺序上。如果SecurityFilterChain没有被Spring容器正确加载HTTP层面的拦截可能还在生效但方法级安全是由AOP动态代理实现的两者是不同维度的东西。排查建议首先确认SecurityFilterChain是否生效可以访问一个配置为permitAll的路径看是否正常然后确认Controller的Bean是否被Spring管理最后检查EnableMethodSecurity是否放在了配置类上。这三步走完基本能定位到问题。6.6 SecurityContext在异步线程中丢失这个问题在业务比较复杂的项目里很常见Controller层开了一个异步线程去执行耗时的任务异步线程里通过SecurityContextHolder.getContext()拿用户信息发现是null。原因是SecurityContextHolder默认使用的是ThreadLocal认证信息只保存在当前线程中。异步线程是另外的线程自然拿不到主线程里的数据。解决方案有几个一个是任务的入参显式传递用户信息另一个是使用SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL)让子线程继承主线程的上下文还有一个是使用DelegatingSecurityContextExecutor包装线程池。从代码可维护性角度我更推荐第一种方案也就是显式传参。用户信息在调用异步任务时作为参数传进去这样代码结构更清晰也不会因为线程复用产生上下文串数据的问题。6.7 前后端联调时的跨域与预检请求联调阶段Firebug或者浏览器开发者工具里经常会看到CORS报错。这个需要分两层来处理。第一层是后端要允许跨域也就是前面讲的CorsConfigurationSource配置。第二层是前端要在请求头中明确标注Content-Type为application/json的POST请求会触发OPTIONS预检。如果OPTIONS请求被Security拦截了那么真实的POST请求也不会发出。在Security配置里OPTIONS请求方法建议直接放行.authorizeHttpRequests(auth - auth .requestMatchers(HttpMethod.OPTIONS, /**).permitAll() ... )这里放行OPTIONS不会带来安全问题因为预检请求本身不会携带业务数据只是浏览器在正式请求前发送的探测信息。写在最后的一些个人体会这套方案我在两个实际项目中完整落地过第一次花了差不多两天时间调通第二次半天就搞定了。回头看最大的障碍其实不是代码本身而是对Security过滤器链工作机制的理解。一旦搞懂了各个过滤器的职责和顺序配置类里的每一行代码都有了明确的意义排查问题时也知道该从哪里下手。如果按照本文的思路去实践我建议先不要在旧项目上改而是开一个最小的SpringBoot3工程把用户服务、JWT工具、过滤器、配置类依次加进去跑通一个登录接口再逐步扩展到业务中。小项目上排错成本低掌握了之后再迁移到老项目就会顺畅很多。另外生产环境使用JWT时务必配置HTTPS否则token在传输过程中被劫持的风险依然存在。