news 2026/10/16 7:07:55

Spring Boot 3 + Spring Security 6 前后端分离JWT认证实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot 3 + Spring Security 6 前后端分离JWT认证实战指南

前后端分离项目里接Spring Security,很多人的第一反应是找一篇老教程照着贴一遍,结果一跑起来全是意外:登录接口返回的不是JSON而是一段HTML重定向、POST请求被莫名其妙拦截成403、前端拿到的token后端怎么都不认、甚至还有一堆过时的配置类直接编译失败。我自己也踩过这一串坑,所以这篇就把Spring Boot 3.x + Spring Security 6.x这套组合在前后端分离场景下的接入过程完整写一遍,从过滤器链原理讲到SecurityConfig的每一行配置,再到登录接口、JWT校验过滤器、方法级鉴权和跨域处理,最后附一份我整理的真实踩坑排查清单。适合已经把Spring Boot基础跑通、想在项目里正式引入认证授权机制的开发者,照着走基本能一次跑起来。

1. 前后端分离的项目里,认证机制该怎么选

1.1 照搬Session方案的三个问题

很多早期的Spring Security教程都在讲Session + Cookie这套思路:用户提交表单登录,Security往HttpSession里存一份认证信息,浏览器靠Cookie自动携带SessionId,后面每次请求Security从Session里把用户身份捞出来。这套机制在服务端渲染的传统Web项目里很成熟,但放到前后端分离场景里会立刻出现三个问题。

第一个问题是前端不再依赖Cookie。今天的前端可能跑在别的域名、别的端口,甚至可能是小程序、App、桌面客户端,Cookie的自动携带行为在这些环境里并不统一。真要硬用Session方案,你得在前端手动管理Cookie,还要处理跨域时Credentials的复杂规则,光浏览器的SameSite策略就能折腾掉半天。

第二个问题是多实例部署。前后端分离的项目一般服务端会水平扩展成多台,Session默认存在单台服务器的内存里,用户在A节点登录,下次请求被负载均衡分到B节点,Session就丢了。要解决就去搞Session共享,再引入一套Redis存储方案,架构复杂度直接被抬高一个级别。

第三个问题是Spring Security默认的表单登录行为。formLogin模式下登录成功后默认会做重定向跳转,这在返回JSON的接口场景里非常别扭。你要么重写成功处理器,要么绕开这套机制,反正都得额外做适配。

1.2 JWT方案为什么是当前更省心的选择

JWT(JSON Web Token)方案的核心思路是把用户身份信息和过期时间签名进一段字符串里,后端校验时不需要查任何会话存储,只要验签通过,就信任Token里携带的身份。这个特性正好打中前后端分离的三个痛点:Token由前端自己保存、自己放进请求头携带,不依赖Cookie机制;服务端没有会话状态,多实例随便横向扩展;登录接口返回一个固定格式的JSON,前端拿到后存进localStorage或内存即可。

当然JWT不是银弹,它最大的缺点是"难以主动失效"。用户修改密码、管理员封禁账号之后,已经发出的Token在过期前依然可能有效。这个问题的解决办法后面第七节我会专门讲,先记住这个权衡即可。对大多数前后端分离的业务系统来说,JWT这套无状态认证方案的收益仍然明显大于成本。

1.3 这篇教程采用的技术栈与最终效果

本篇按当前主流版本组合来写:Spring Boot 3.2.x、Spring Security 6.2.x、JWT库使用jjwt的0.12.x版本。数据库访问用MyBatis-Plus做一个简单的用户表查询,重点放在Security这条链路上。

最终我们会实现这些能力:一个不做鉴权的登录接口,用户名密码校验通过后返回JWT;一个自定义JWT过滤器,每次请求自动从Authorization头解析Token并写入SecurityContext;一套放行规则和统一401/403返回结构;以及用@PreAuthorize注解做的管理员权限控制。写完你会看到,整个认证链条上的每一环都有明确职责,理解了之后配置Security基本不用再靠背。

2. 最小可运行工程准备:依赖、数据表与接口约定

2.1 后端依赖清单

先看一下pom.xml里的关键依赖,版本号我按实际可用的方式给出。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency>

这里提醒一下,jjwt从0.11.x开始就拆成了api、impl、jackson三个包,impl和jackson要加runtime作用域。网上很多老文章用的是0.9.x的单包写法,API完全不一样,如果你按老教程写会发现setSigningKey、setSubject这些方法全都没了。

application.yml里需要配置数据源和JWT密钥:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/demo_security?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true jwt: secret: "please-change-this-to-a-long-random-string-at-least-32-bytes" expire: 604800000

jwt.secret用来做HMAC签名,长度必须不少于32字节,短了启动时会直接抛异常。jwt.expire单位是毫秒,上面配置的604800000就是7天。

2.2 用户表结构与初始数据

用户表设计不用复杂,能说明问题就行,一个用户表足够演示认证和角色鉴权。

CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role VARCHAR(50) NOT NULL DEFAULT 'ROLE_USER', status TINYINT NOT NULL DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );

密码字段长度我留了100,BCrypt加密后的字符串长度通常60左右,留够余量。初始数据不要直接insert明文密码,BCrypt加密密码没有固定的可复用密文,每个密码每次生成的hash都不一样,所以我建议先临时写个main方法生成。

public class GeneratePassword { public static void main(String[] args) { System.out.println(new BCryptPasswordEncoder().encode("123456")); } }

把生成的密文替换进下面的SQL:

INSERT INTO sys_user (username, password, role) VALUES ('admin', '在这里粘贴BCrypt密文', 'ROLE_ADMIN'), ('user', '在这里粘贴BCrypt密文', 'ROLE_USER');

admin账号用于测试权限控制,user账号用于测试普通用户访问受限接口时的403效果。

2.3 前后端接口约定

前后端分离开发最怕各写各的,接口约定必须在联调前明确。这篇教程里的接口约定如下:

接口路径请求方法请求参数返回结果鉴权要求
/api/auth/loginPOST{username, password}{code, message, data: token}无需鉴权
/api/user/meGET无{code, message, data: {username, role}}需登录
/api/admin/usersGET无{code, message, data: 用户列表}需ADMIN角色

统一返回结构我习惯用最简的Result类,前端判断code为200时取data,401和403在全局处理器里直接返回同样结构。

public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 200; r.message = "success"; r.data = data; return r; } public static <T> Result<T> error(int code, String message) { Result<T> r = new Result<>(); r.code = code; r.message = message; return r; } }

这个结构虽然简单,但在前后端分离项目里能省掉大量口头沟通成本,后端所有接口保持同一套返回格式,前端拦截器判断逻辑就非常干净。

3. 搞懂过滤器链,配置不再靠背

3.1 Spring Security的本质是一堆Filter

很多人学Spring Security卡住,是因为直接跳进了配置代码里,却不知道这些配置最终会变成什么。Spring Security本质上就是一条Java Servlet标准的Filter调用链,你可以把它想象成机场安检通道:请求是乘客,Filter是安检口,有的查你有没有带票(身份认证),有的查你行李里有没有违禁品(权限校验),有的负责把你放行(放行规则)。每个请求进入后端后,先被这一串Filter逐层处理,最后才到达Controller。

Spring Boot启动时,spring-boot-starter-security会自动注册一个springSecurityFilterChain过滤器,它内部管理着几十个按顺序排列的Filter。认证和授权逻辑全分布在这些Filter里,我们要做的配置,本质上就是告诉Spring Security"哪些Filter要启用、哪些路径要放行、认证失败时怎么响应"。

3.2 SecurityFilterChain的关键节点都是谁

在Spring Security 6.x里,一个请求会依次经过多个关键过滤器,这里不罗列全部,只挑最影响理解的几个。

SecurityContextHolderFilter负责在请求进来时从Session或SecurityContextRepository里恢复认证上下文,这一步如果发现没有保存过认证信息,SecurityContext就是空的。CorsFilter负责跨域处理,它会读取我们配置的CorsConfigurationSource,对预检请求和非预检请求分别处理。CsrfFilter负责CSRF防护,默认启用,它会拦截所有非GET请求。UsernamePasswordAuthenticationFilter是表单登录的核心,它会把请求里的用户名密码封装成AuthenticationToken,交给AuthenticationManager去验证。ExceptionTranslationFilter是异常翻译中枢,它捕获后面Filter抛出的AuthenticationException和AccessDeniedException,转交给我们配置的AuthenticationEntryPoint和AccessDeniedHandler去生成响应。最后一个关键节点是AuthorizationFilter,它拿着SecurityContext里的认证信息去匹配授权规则,决定放行还是拒绝。

理解这几个Filter的职责,后面所有的配置就都有了解释。比如登录接口为什么能返回JSON而不是跳转到登录页?因为我们在配置里显式关闭了formLogin,又自己写了返回JSON的登录Controller。为什么自定义JWT过滤器要放在某个特定位置?因为我们需要在AuthorizationFilter判断之前就把身份信息塞进SecurityContext。

3.3 自定义认证过滤器为什么必须放在UsernamePasswordAuthenticationFilter之前

我们的JWT过滤器负责从请求头解析Token、设置SecurityContext,它的插入位置非常关键。使用addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class),就是让JWT过滤器的执行顺序排在表单登录过滤器前面。

这样设计的理由是:前后端分离场景下,请求里根本没有表单登录参数,如果让UsernamePasswordAuthenticationFilter先执行,它会尝试从request里提取username和password,发现没有或不对,可能直接抛异常或者继续放行,但请求就已经偏离了我们预期的处理路径。而我们的JWT过滤器先执行,只要Authorization头里有合法Token,就直接把认证信息塞进SecurityContext,后面的UsernamePasswordAuthenticationFilter看到SecurityContext里已有认证信息会自动跳过,最后AuthorizationFilter拿着这个身份去做授权判断,整个过程就非常顺畅。

换个角度看,这也是自定义认证逻辑和Security原生机制结合的通用套路:提前把身份放进SecurityContext,让Security的后续Filter和注解方法都基于这个身份工作。

4. 写SecurityConfig:放行规则、CSRF与全局401/403

4.1 完整配置类先跑通,再逐行拆

下面是最核心的配置类,Spring Security 6.x新写法里已经全面抛弃了WebSecurityConfigurerAdapter,直接用SecurityFilterChain Bean来定义规则。

@Configuration @EnableMethodSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .cors(Customizer.withDefaults()) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated() ) .exceptionHandling(ex -> ex .authenticationEntryPoint(this::handleUnauthorized) .accessDeniedHandler(this::handleForbidden) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } private void handleUnauthorized(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\",\"data\":null}"); } private void handleForbidden(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"message\":\"没有权限访问该接口\",\"data\":null}"); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @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.setExposedHeaders(List.of("Authorization")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } }

先把这份配置跑通,再逐行拆解,你会发现每一部分都对应着前面讲的某个Filter或某类需求。

4.2 为什么关CSRF、为什么STATELESS

.csrf(AbstractHttpConfigurer::disable)这一行在绝大多数前后端分离项目里都要写。CSRF攻击的核心场景是浏览器自动携带Cookie,攻击者诱导用户访问恶意页面,浏览器带着用户的Cookie发起跨站请求,服务端无法区分这个请求是不是用户本人发的。但我们用JWT时,Token放在请求头里由前端JavaScript手动携带,浏览器不会自动附加,CSRF攻击的路子基本就断了。所以关闭CSRF是安全的。反过来提醒一句,如果你为了某些特殊情况选择把JWT存在Cookie里自动携带,那CSRF防护必须保留,千万不能随手关掉。

.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))的意思是告诉Security:不要创建HttpSession,也不要从Session读取认证信息。配合JWT无状态方案,服务端不保存用户登录态,每次请求都靠Token自证身份。这个设置还有一种额外的好处,当服务端横向扩展成多实例时,不用担心Session同步问题。

4.3 PasswordEncoder:别再用MD5存密码了

PasswordEncoder是Security里负责密码校验的组件,登录认证时AuthenticationManager拿到请求里的明文密码,调用PasswordEncoder的matches方法去和数据库密文比对。

@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }

BCryptPasswordEncoder是最常用的实现,核心特点是每次加密同一个密码生成的hash都不同,因为内部自动加盐,验证时再从hash里提取盐来比对。数据库被拖走之后,攻击者无法直接通过彩虹表反查明文。这也是我在2.2节建表时强调密码要用BCrypt密文的原因。

建议彻底放弃MD5、SHA-256这类纯哈希方案,撞库攻击太容易了。如果想让安全性再上一个台阶,可以给BCryptPasswordEncoder传一个strength参数调高计算成本,默认10,对大多数业务系统已经够用。

4.4 CORS和预检请求为什么必须在Security层放行

前后端分离必然涉及跨域。前端跑在5173端口,后端跑在8080端口,浏览器会先发出OPTIONS预检请求确认服务器允许跨域,然后才发起真实请求。如果你不在Security层放行OPTIONS请求,预检请求会被前面的过滤器拦下来,前端在控制台看到的就是一串CORS报错,但后端日志里往往看不到任何有效日志,排查起来非常迷惑。

解决方案就是两件事同时做:配置CorsConfigurationSource,让CorsFilter能正确响应预检;在authorizeHttpRequests里显式放行OPTIONS请求。上面配置里的.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()就是干这个的。实践中漏掉第二件的人非常多,我见过好几个项目就因为这行没写,前端联调时怎么弄都过不去。

5. 认证链路完整实现:从查用户到发Token再到验Token

5.1 UserDetailsService:把数据库用户变成Security认识的用户

Spring Security认证过程中,真正核对用户名密码时,会调用UserDetailsService的loadUserByUsername方法。我们需要实现这个接口,把数据库里的用户记录包装成Security认识的UserDetails对象。

@Service @RequiredArgsConstructor public class UserDetailsServiceImpl implements UserDetailsService { private final SysUserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user = userMapper.selectOne( new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, username) ); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } if (user.getStatus() != 1) { throw new DisabledException("账号已被禁用"); } return User.withUsername(user.getUsername()) .password(user.getPassword()) .authorities(user.getRole()) .build(); } }

SysUser实体类字段与表对应,Mapper继承MyBatis-Plus的BaseMapper即可。这里的重点是authorities(user.getRole()),我们把数据库里的role字段作为权限字符串传了进去,字段值必须完整写成ROLE_ADMIN或ROLE_USER格式。如果表里存的已经带ROLE_前缀,直接传进来就行,后面用hasRole方法校验时会自动识别。

5.2 JwtUtils:生成Token和解析Token的封装

JWT的工具类封装成独立组件,登录时调用生成,过滤器里调用解析。使用jjwt 0.12.x版本时注意,API已经迁移成Jwts.builder()和Jwts.parser().verifyWith()这种方式。

@Component public class JwtUtils { private final SecretKey key; private final long expire; public JwtUtils(@Value("${jwt.secret}") String secret, @Value("${jwt.expire}") long expire) { this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.expire = expire; } public String generateToken(String username) { Date now = new Date(); return Jwts.builder() .subject(username) .issuedAt(now) .expiration(new Date(now.getTime() + expire)) .signWith(key) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .verifyWith(key) .build() .parseSignedClaims(token) .getPayload(); } }

JWT由三部分组成:Header、Payload、Signature。Header声明签名算法,Payload里放subject、issuedAt、expiration这些声明,Signature是前面两部分用密钥签名后的结果。客户端拿到Token后,任何篡改都会在服务端验签时失败。

一个容易被忽略的坑是Keys.hmacShaKeyFor要求密钥长度至少256位,也就是32字节。如果jwt.secret配短了,应用启动时就会报错。配置里的占位字符串有40多个字符,就是为了满足这个要求。

5.3 JwtAuthenticationFilter:每次请求如何被自动识别

这个过滤器是整个认证链路的入口,继承了OncePerRequestFilter,保证一个请求只执行一次。

@Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtils jwtUtils; private final UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); try { Claims claims = jwtUtils.parseToken(token); String username = claims.getSubject(); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } }

代码逻辑不复杂,但有几个细节值得说清楚。header.startsWith("Bearer ")是对前端约定好的Token携带格式,注意Bearer后面有一个空格,截取时用substring(7)正好跳过前缀。解析失败时我们没有直接返回401,而是清空SecurityContext后继续放行,让后面的AuthorizationFilter去处理,这样401的统一响应逻辑就集中在AuthenticationEntryPoint里,不会在过滤器里散落多处响应逻辑。

每次请求都调一次loadUserByUsername确实有数据库开销,但换来的是用户被封禁后Token立刻失效的能力。如果完全不去查库,纯靠解析Token就能通过认证,那封号就变成了摆设。

5.4 登录接口与全局异常处理

登录接口不走Security的表单登录,而是我们自己写一个Controller,用AuthenticationManager手动发起认证。

@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { private final AuthenticationManager authenticationManager; private final JwtUtils jwtUtils; @PostMapping("/login") public Result<String> login(@RequestBody LoginRequest request) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) ); String token = jwtUtils.generateToken(authentication.getName()); return Result.success(token); } }

LoginRequest就是一个包含username和password两个字段的简单DTO。AuthenticationManager会找到我们前面配置的AuthenticationProvider,调用UserDetailsService查询用户,再用PasswordEncoder比对密码,全链路自动完成。密码错误时抛BadCredentialsException,用户不存在时抛UsernameNotFoundException,我们需要在全局异常处理器里转成对应的返回JSON。

@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BadCredentialsException.class) public Result<Void> handleBadCredentials(BadCredentialsException e) { return Result.error(400, "用户名或密码错误"); } @ExceptionHandler(DisabledException.class) public Result<Void> handleDisabled(DisabledException e) { return Result.error(400, "账号已被禁用"); } }

这里再提供一个获取当前登录者的接口,方便前端在页面初始化时拉取用户信息:

@RestController @RequestMapping("/api/user") public class UserController { @GetMapping("/me") public Result<Map<String, Object>> me(Authentication authentication) { Map<String, Object> result = new HashMap<>(); result.put("username", authentication.getName()); result.put("role", authentication.getAuthorities()); return Result.success(result); } }

Controller方法参数里直接注入Authentication,Security会自动从SecurityContext里取出来,非常方便。

5.5 一次带Token请求的完整流转

把整个链路串起来看,一次"已登录用户访问受保护接口"的请求会经历这些步骤:

  1. 请求到达SecurityFilterChain,SecurityContextHolderFilter先尝试恢复上下文,由于是无状态请求,此时上下文为空。
  2. 到CorsFilter,对跨域请求做预检处理。
  3. 到JwtAuthenticationFilter,从Authorization头解析出Token,验签通过后从数据库加载用户,把认证信息放入SecurityContext。
  4. 到UsernamePasswordAuthenticationFilter,发现SecurityContext已有认证信息,跳过表单登录逻辑。
  5. 到AuthorizationFilter,根据URL放行规则判断,普通用户访问公共接口则直接放行,访问需要角色的接口则检查权限。
  6. 请求进入Controller,拿到Authentication对象返回业务结果。

这一条流转链路理解了,Spring Security在你眼里就不再是一堆配置,而是一条可以按需插入自定义逻辑的管道。后面遇到任何奇怪的问题,都可以沿着管道顺序去定位是哪一环出了问题。

6. 接口鉴权:用注解把权限判断写在离业务最近的地方

6.1 @PreAuthorize 与 hasRole、hasAuthority的区别

路径级别的anyRequest().authenticated()只能区分"是否登录",区分不了"是什么角色"。要精细控制接口权限,最好的办法是在Controller或Service方法上加Spring Security注解,权限判断逻辑就和业务代码放在了一起。

@RestController @RequestMapping("/api/admin") public class AdminController { @GetMapping("/users") @PreAuthorize("hasRole('ADMIN')") public Result<List<SysUser>> listUsers() { // 只有ADMIN角色能访问 return Result.success(userMapper.selectList(null)); } }

@EnableMethodSecurity这行配置就是用来启用方法级安全注解的,没有它,@PreAuthorize完全不生效。这一点说过多遍仍然有人踩坑,因为SecurityConfig里少写这行注解,方法上写再多@PreAuthorize都没反应。

hasRole('ADMIN')和hasAuthority('ROLE_ADMIN')在多数场景下效果一样,差别在于hasRole会自动给传入值拼接ROLE_前缀。所以如果用户权限是ROLE_ADMIN,用hasRole('ADMIN')最省事;如果权限是system:user:delete这种更细粒度的权限码,就要用hasAuthority('system:user:delete')。建议字符串常量统一管理,不要散落在代码里。

6.2 角色权限模型与前后端对接时的几个小坑

实际项目里角色和权限往往会拆成两张表,用户拥有角色,角色绑定权限,认证时把用户的角色和权限都解析成authorities卖进Authentication。这个模型在Security里完全支持,只要loadUserByUsername返回的UserDetails里authorities集合包含多个权限码即可。

和前端对接时有几个小坑值得记一下。第一个,前端不要把token存到sessionStorage之外的不可控位置,需要持久化就用localStorage,每次请求从存储里取出来放进Authorization头。第二个,前端收到401响应时必须清掉本地token并跳转到登录页,否则用户每次刷新页面还会带着过期token去打接口。第三个,前端要统一用axios拦截器注入Token,别在每个接口里手动拼请求头。下面是一个非常常见的axios拦截器写法:

service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => response.data, error => { if (error.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(error) } )

后端返回401和前端跳转登录页这两件事是前后端分离认证闭环的最后一公里,很多项目后端权限逻辑没问题,就是前端没有统一处理401,导致用户登录过期后的行为非常混乱。

7. 实战踩坑排查清单:从登录403到请求401逐个对表

7.1 高频故障对照表

把我在实践中反复遇到的高频问题整理成一张表,每个问题都是真实场景里出现过的。

现象常见原因排查方法解决方案
登录接口POST请求返回403CSRF未关闭看响应体是否是Security默认的403页面关闭CSRF防护
前端跨域报错,后端无日志OPTIONS预检被拦截浏览器Network里看预检请求的响应状态Security中放行OPTIONS,配置CorsConfigurationSource
登录成功但访问接口返回401JWT过滤器没执行或没设置SecurityContext在JwtAuthenticationFilter打断点确认过滤器的监听路径和addFilterBefore位置
携带Token访问仍401前端未正确携带Authorization头检查Network请求头是否包含Bearer Token在axios拦截器统一注入Token
启动报错key长度不足jjwt密钥小于32字节看异常信息是否关于signing key把jwt.secret配置加长
密码错误却查不到用户UserDetailsService执行异常看日志是否有UsernameNotFoundException检查用户表status字段和查询条件
方法注解不生效缺少@EnableMethodSecurity检查类上是否有该注解在SecurityConfig加上

7.2 一次"登录后访问接口仍401"的完整排查链路

这个问题的排查链路值得完整走一遍,因为它涉及的环节最多。

先说现象:登录接口成功返回了Token,前端把Token存了起来,但调用/api/user/me接口时后端返回401。

排查第一步,打开浏览器开发者工具的Network面板,查看本次请求的Headers。如果发现Authorization头根本没带,或值不是Bearer 一串token的格式,那就是前端的问题,优先检查axios拦截器有没有生效。如果Authorization头正确,进入后端排查。

排查第二步,在JwtAuthenticationFilter的doFilterInternal第一行打断点。如果断点没进去,说明过滤器没有注册到SecurityFilterChain里,检查SecurityConfig中是否有addFilterBefore那行代码。如果断点能进去,继续往后走。

排查第三步,看Token解析是否正常。常见坑是JWT里放了额外字段但解析时没取对,或者Token已经过期。把jwtUtils.parseToken单独拉出来测试一遍,看看是否抛出ExpiredJwtException。

排查第四步,确认SecurityContext里到底有没有写入Authentication。在SecurityContextHolder.getContext().setAuthentication(authentication)这行打条件断点,等到filterChain.doFilter执行之后,再看Controller里注入的Authentication是否为空。

按照这个顺序剥洋葱,绝大多数401问题都能定位到具体环节,而不是靠改配置瞎碰。

7.3 什么时候别硬上纯JWT:权衡与优化方向

最后聊一聊边界问题。纯JWT方案最难受的场景有三个:一是需要服务端主动踢人下线(例如用户改密码后让所有旧Token失效),二是需要实时控制单设备登录,三是希望审计所有活跃Token。

如果你遇到这三个需求,可以给JWT加一个"黑名单"机制:在Redis里维护一个失效Token集合或者Token版本号,JWT过滤器校验时先查一下Redis。这种方案本质上是给无状态Token加了一个有状态的辅助层,既有Token的扩展性,又能做到主动失效。另一个常见优化方向是引入Refresh Token机制,accessToken设置短过期时间(比如30分钟),refreshToken设置长过期时间(比如7天),前端在收到401时用refreshToken换新的accessToken。这两个方向是比较成熟的演进路径,等基础认证链路稳定之后再按需引入,不要一开始就套上全部机制。

我的切身体会是,认证方案没有绝对的最优,只有和业务匹配度的高低。大部分中小型前后端分离系统,JWT加Spring Security这套组合已经能覆盖绝大多数需求,把过滤器链和核心配置搞明白之后,你会发现自己再看到网上的Security报错帖,基本扫一眼就能判断问题出在哪一环了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 7:07:51

UE5集成WebView2内嵌浏览器:C++深度通信与D3D11纹理渲染实战

简介&#xff1a;本资源是一套基于Unreal Engine 5开发的轻量级在线浏览器完整源码实现&#xff0c;面向UE5中级开发者及C插件扩展学习者&#xff0c;解决在UE项目中嵌入现代Web交互能力的实际需求&#xff0c;适用于数字孪生界面、远程监控看板、内嵌帮助系统等场景。压缩包共…

作者头像 李华
网站建设 2026/10/16 7:07:17

Django新冠疫情数据分析系统毕业设计完整实战指南

又到了一年一度的毕业设计选题季&#xff0c;我身边陆续有人来问同一类问题&#xff1a;“基于XX框架做一个数据分析系统”这类题目到底能不能做&#xff1f;好不好通过&#xff1f;今天就用“django新冠疫情数据分析系统”这个题目作为例子&#xff0c;把这个课题从选题逻辑、…

作者头像 李华
网站建设 2026/10/16 7:07:05

零代码应用搭建实战:需求拆解、数据表设计与自动化避坑指南

零代码这个词&#xff0c;这几年听得耳朵起茧&#xff0c;但真把它用进业务里、做出一个让团队愿意天天打开的应用&#xff0c;又是另一回事。我自己落地过不少零代码项目&#xff0c;从库存管理到审批流再到数据看板&#xff0c;最大的体会是&#xff1a;零代码的价值不在“省…

作者头像 李华
网站建设 2026/10/16 7:07:05

迅雷下载提速全攻略:从P2P原理到NAT优化,告别几百KB/s

先说明一下&#xff1a;标题里这个“2026”&#xff0c;大概率是指软件版本跨度或者某段时间大家口口相传的“新版提速明显”&#xff0c;而不是真有什么神秘年份版。我用的是当前主流客户端版本&#xff0c;界面按钮以所见为准&#xff0c;原理和操作路径基本通用。如果你手里…

作者头像 李华
网站建设 2026/10/16 7:06:50

2、主板 - 计算机品牌排行榜

基于2026年全球主板出货量、市场占有率及用户综合口碑&#xff0c;当前电脑主板品牌排行榜如下&#xff0c;‌华硕以近3成的市场占有率稳居第一‌&#xff0c;微星、技嘉紧随其后&#xff0c;国产高性价比品牌在DIY市场增长势头强劲。 1. 华硕(ASUS) ‌市场表现‌&#xff1a;2…

作者头像 李华
网站建设 2026/10/16 7:06:09

一句话生成治愈系视频:AI文生视频的完整实操指南

我是在一个工作日的下午被逼到这一步的。电脑里的待办事项还没清干净&#xff0c;窗外倒先亮起了一片暖到不行的夕阳&#xff0c;树影被拉得很长。我顺手把心里那句“好治愈”翻译成了一句描述&#xff0c;粘到某个文生视频平台上&#xff0c;等它跑了一分钟&#xff0c;拉出来…

作者头像 李华