前后端分离项目里接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: 604800000jwt.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/login | POST | {username, password} | {code, message, data: token} | 无需鉴权 |
| /api/user/me | GET | 无 | {code, message, data: {username, role}} | 需登录 |
| /api/admin/users | GET | 无 | {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请求的完整流转
把整个链路串起来看,一次"已登录用户访问受保护接口"的请求会经历这些步骤:
- 请求到达SecurityFilterChain,SecurityContextHolderFilter先尝试恢复上下文,由于是无状态请求,此时上下文为空。
- 到CorsFilter,对跨域请求做预检处理。
- 到JwtAuthenticationFilter,从Authorization头解析出Token,验签通过后从数据库加载用户,把认证信息放入SecurityContext。
- 到UsernamePasswordAuthenticationFilter,发现SecurityContext已有认证信息,跳过表单登录逻辑。
- 到AuthorizationFilter,根据URL放行规则判断,普通用户访问公共接口则直接放行,访问需要角色的接口则检查权限。
- 请求进入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请求返回403 | CSRF未关闭 | 看响应体是否是Security默认的403页面 | 关闭CSRF防护 |
| 前端跨域报错,后端无日志 | OPTIONS预检被拦截 | 浏览器Network里看预检请求的响应状态 | Security中放行OPTIONS,配置CorsConfigurationSource |
| 登录成功但访问接口返回401 | JWT过滤器没执行或没设置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报错帖,基本扫一眼就能判断问题出在哪一环了。