前后端分离做权限控制,核心就四个字:认证、授权。可真正落地的时候,不少人被Spring Security这套框架折腾得够呛——配置文件写了一大堆,启动倒是正常,一调接口就各种401、403,甚至过滤器链执行顺序都没搞明白。我之前在某个管理后台项目里从Session方案迁到Spring Security + JWT,前后踩了将近一周的坑,这篇文章就把整个改造思路和关键代码完整复盘一遍,也把联调阶段最容易出问题的细节一并讲清楚。
这套方案适合谁看?正在搞前后端分离,后端是Spring Boot,需要给接口加上登录认证和角色权限控制的同学。不管你是刚从单体项目转过来,还是已经写了个demo但没跑通,这篇文章都尽量用能直接“抄作业”的方式讲透。
1. 为什么前后端分离之后,Session方案突然不香了
1.1 Session方案在分离场景下的三个硬伤
传统单体项目里,登录之后服务端把用户状态存进HttpSession,生成一个JSESSIONID扔回浏览器Cookie。浏览器每次请求自动带上这个Cookie,服务端一比对就知道是谁。这套逻辑在前后端不分离的时代几乎无懈可击。
可一旦切成前后端分离,问题就浮出来了。先说跨域,前端跑在localhost:8080,后端跑在localhost:9090,两边端口不同,就是跨域。Cookie在跨域请求里的处理非常麻烦,你得配CORS、配credentials,前端axios还得开withCredentials: true。某些浏览器对第三方Cookie的限制一收紧,这套方案直接失效。
第二点是服务端状态存储。Session是存在服务端的,用户一多,Session占内存还是小事,后端想横向扩容、多实例部署的时候就尴尬了。A机器存了用户的Session,请求被负载均衡转发到B机器,B机器一脸茫然。你当然可以用Redis统一存Session,让所有实例共享同一份数据,但这就等于给Session方案再套了一层基础设施的复杂度。
第三点是移动端适配。App、小程序、甚至第三方系统对接,根本不吃Cookie这套。它们更习惯在请求头里带一个Token,简单直接。总不能每个端都去单独适配Cookie的存取逻辑吧。
1.2 “无状态”不是指不用登录,而是指服务端不做状态
JWT方案的核心思路,是把用户的身份信息和权限信息做成一段自包含的Token,发给客户端保管。服务端不再保存任何登录状态,每次请求只需要校验Token本身是否合法。
这里要特别澄清一个误区:无状态认证,不是说不用登录了。登录动作依然有,认证过程依然要走,只是在登录成功之后,服务端不再记录“谁谁谁在线”,而是把用户信息打包成Token交给前端。后续接口请求,前端把Token带上,后端校验签名没问题,就认你是谁。有点像演唱会门票,验过了就把人放进场,场馆不会专门记一份“谁进来了”的名单。
JWT本身由三部分组成:Header、Payload、Signature。Header声明签名算法和Token类型;Payload放用户信息,比如用户名、用户ID、角色列表、过期时间;Signature是服务端用密钥对前两部分做的签名。客户端拿到Token之后,可以发现里边的信息,但没法篡改,因为一旦动过Payload,签名就对不上了。
1.3 JWT的结构与信任模型
把JWT的格式拆开看,实际是一串.0分隔的字符串,类似eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.signature。前两段是Base64Url编码的内容,任何人都能解码看到,所以千万别把密码、身份证这类敏感信息塞进JWT里。第三段是关键,服务端用密钥把前两段内容做HMAC-SHA256签名,这个密钥只有服务端知道。
信任模型建立在“签名即真实”之上。客户端篡改Payload,签名就对不上;签名密钥泄露,整个认证体系就废了。所以密钥的管理非常关键,不要硬编码在代码里,放到配置文件或环境变量中,并且定期轮换。
2. Spring Security的过滤器链:JWT过滤器到底该插在哪
2.1 过滤器链:Spring Security实际就是一条“安检通道”
Spring Security的底层机制是Servlet过滤器链。你配置的每一个安全规则,最终都会落到一条专门的过滤器链上。请求进来之后,会在链上依次经过各个过滤器,有人负责识别当前登录用户是谁,有人负责判断这个URL要不要登录,有人负责处理权限不足的异常,全部通过才行。
这条链上有几个“固定岗位”得心里有数。SecurityContextPersistenceFilter负责从Session里恢复SecurityContext信息;UsernamePasswordAuthenticationFilter专门处理表单登录请求,从请求参数里取出用户名密码做认证;FilterSecurityInterceptor负责最后的授权判断。默认情况下,Spring Security是按表单登录模式帮你把这些过滤器配置好的。
2.2 自定义JWT过滤器的插入位置与利弊
到了前后端分离场景,表单登录那套逻辑基本用不上。我们需要做的,是写一个自己的过滤器,专门从请求头里读取Token,校验通过后把用户信息塞进SecurityContext。这个过滤器不需要“取代”谁,而是插在链上合适的位置。
我的选择是把自己写的JwtAuthenticationFilter放在UsernamePasswordAuthenticationFilter之前。原因很简单:请求进来,先经过JWT过滤器完成身份识别,后面再走到授权过滤器时,SecurityContext里已经有用户信息了。如果放在太靠后的位置,某些前置过滤器可能已经开始做匿名认证或者抛出异常了,逻辑会变得混乱。
这里有个容易踩的细节:自定义过滤器推荐继承OncePerRequestFilter,而不是直接实现Filter接口。OncePerRequestFilter保证在一次请求中过滤器只执行一次,内部转发、异步分发这些场景下不会重复执行。
2.3 SecurityContext:认证信息在请求链路里怎么传递
SecurityContext有一个全局持有者,叫SecurityContextHolder。默认策略是把SecurityContext存在ThreadLocal里。也就是说,当前请求线程在处理期间,你往SecurityContextHolder塞进去的Authentication对象,可以在后续的Controller、Service层随时取出来。
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();如果当前用户匿名,这个Authentication是个匿名令牌;如果已认证,就能从中拿到用户名、权限列表。
实际使用中要注意,这个ThreadLocal在请求结束后要清理掉,否则线程池复用线程时可能串数据。Spring Security的过滤器链结束时会自动清理,但如果你自己在某个地方直接手动写了ThreadLocal或者拦截器,一定要在finally块里清除。我之前在一个项目里就是过滤器里塞了上下文,结果清理逻辑被跳过,偶尔出现登录用户变成别人的诡异bug。
3. 登录认证的完整落地:从TokenProvider到SecurityConfig
3.1 TokenProvider:生成、解析、校验一个都不能少
先写一个TokenProvider组件,负责JWT的创建、解析和校验。这里我用的库是jjwt,稳定且API清晰。密钥通过配置文件注入,至少256位,过期时间不建议设太长,结合业务场景一般4到8小时比较合适。
@Component public class TokenProvider { private final SecretKey key; private final long expiration; public TokenProvider(@Value("${jwt.secret}") String secret, @Value("${jwt.expiration:3600000}") long expiration) { this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.expiration = expiration; } public String createToken(Authentication authentication) { UserPrincipal principal = (UserPrincipal) authentication.getPrincipal(); String authorities = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(",")); Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); return Jwts.builder() .setSubject(principal.getUsername()) .claim("uid", principal.getId()) .claim("roles", authorities) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } public Claims getClaims(String token) { return Jwts.parserBuilder().setSigningKey(key).build() .parseClaimsJws(token).getBody(); } }有几个细节想单独拿出来说。第一,setSubject放用户名,claim里放用户ID和角色列表,但别放敏感信息。第二,validateToken里catch异常要同时覆盖JwtException和IllegalArgumentException,前者是签名校验失败、过期这类JWT自身异常,后者是Token为空或格式不对的情况。第三,如果你用的jjwt是0.12.x版本,parserBuilder()已经改名成parser(),我在迁移项目时踩过这个API变化的坑。
3.2 JwtAuthenticationFilter:从Header到SecurityContext
TokenProvider准备好之后,写过滤器。过滤器的职责很纯粹:请求带上Token,就尝试解析并放入SecurityContext;没带Token或者Token非法,什么都不做,直接放行,让后面的匿名认证逻辑去处理。
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final TokenProvider tokenProvider; public JwtAuthenticationFilter(TokenProvider tokenProvider) { this.tokenProvider = tokenProvider; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) { Claims claims = tokenProvider.getClaims(token); String username = claims.getSubject(); List<GrantedAuthority> authorities = Arrays.stream( claims.get("roles").toString().split(",")) .filter(StringUtils::hasText) .map(SimpleGrantedAuthority::new) .toList(); UserPrincipal principal = new UserPrincipal( Long.parseLong(claims.get("uid").toString()), username, null); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(principal, token, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; } }这里我故意没有在过滤器里查数据库,而是直接从Token的Claims里重建用户信息。因为JWT本身就是自包含的,用户名、ID、角色都打包在里了,再查一次库反而违背了无状态设计的初衷。如果你的业务对用户状态实时性要求很高——比如用户被禁用后要立刻失效——那可以选择在过滤器里查库校验用户状态,代价是多一次数据库查询。
UserPrincipal是我自定义的一个用户主体类,实现了UserDetails接口。也可以直接用Spring的User,但自定义的好处是能扩展字段,方便后续在业务代码里直接拿到用户ID,不必每回都从Claims里parse。
3.3 SecurityConfig:无状态策略与放行清单
配置类是整个方案的骨架,很多人在这里出错。Spring Boot 2.7配Spring Security 5.7的写法如下:
@Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final RestAuthenticationEntryPoint authenticationEntryPoint; private final RestAccessDeniedHandler accessDeniedHandler; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, RestAuthenticationEntryPoint authenticationEntryPoint, RestAccessDeniedHandler accessDeniedHandler) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; this.authenticationEntryPoint = authenticationEntryPoint; this.accessDeniedHandler = accessDeniedHandler; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .cors().and() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/auth/login").permitAll() .antMatchers("/api/public/**").permitAll() .antMatchers("/api/admin/**").hasRole("ADMIN") .antMatchers("/api/user/**").hasAnyRole("USER", "ADMIN") .anyRequest().authenticated() .and() .exceptionHandling() .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler) .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } }三处必须说明。第一,csrf().disable()。前后端分离用Token做认证,CSRF防御本身意义不大了,因为CSRF攻击的核心是伪造Cookie让浏览器自动带上,而我们压根不用Cookie传凭证。第二,SessionCreationPolicy.STATELESS。如果不显式设置,Spring Security默认还会创建Session,无状态方案就白做了。第三,如果你用Spring Security 6.x或Spring Boot 3.x,authorizeRequests和antMatchers已经被废弃,要换成authorizeHttpRequests和requestMatchers,别拿老代码直接复制编译不过。
AuthenticationManager的Bean在登录接口里要用,它是AuthenticationProvider的入口,负责执行真正的用户名密码校验。
登录接口代码如下:
@RestController @RequestMapping("/api/auth") public class AuthController { private final AuthenticationManager authenticationManager; private final TokenProvider tokenProvider; public AuthController(AuthenticationManager authenticationManager, TokenProvider tokenProvider) { this.authenticationManager = authenticationManager; this.tokenProvider = tokenProvider; } @PostMapping("/login") public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest loginRequest) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword()); Authentication authentication = authenticationManager.authenticate(authenticationToken); String token = tokenProvider.createToken(authentication); return ResponseEntity.ok(new LoginResponse(token, authentication.getName())); } }authenticationManager.authenticate()会触发UserDetailsService的loadUserByUsername方法,再交给DaoAuthenticationProvider比对密码。密码加密推荐用BCryptPasswordEncoder,这个Bean必须声明,否则启动会报错。
4. 授权鉴权:路由规则、方法注解与权限模型
4.1 三层权限控制:接口路由、方法注解、业务数据
权限控制在项目里实际上有三个层级。
第一层是URL路由级,在SecurityConfig里通过antMatchers或requestMatchers配置。比如/api/admin/**只允许ADMIN角色访问,/api/user/**登录用户就能访问。这层是粗粒度的门禁,先拦住一大片。
第二层是方法注解级,在Controller方法或者Service方法上加@PreAuthorize。这层粒度更细,比如同样是用户管理接口,查询列表需要ADMIN,而查询自己有USER就行。启用方式是在配置类上加@EnableGlobalMethodSecurity(prePostEnabled = true)。
@GetMapping("/admin/users") @PreAuthorize("hasRole('ADMIN')") public List<UserVO> listUsers() { return userService.listUsers(); } @GetMapping("/profile") @PreAuthorize("hasAuthority('USER') or hasAuthority('ADMIN')") public UserVO getProfile() { // ... }第三层是业务数据级。比如订单系统里,普通用户只能看自己的订单,Admin可以看所有人的。这一层注解解决不了,必须在Service层根据当前登录用户做数据范围过滤。
UserPrincipal currentUser = (UserPrincipal) SecurityContextHolder.getContext() .getAuthentication().getPrincipal(); Long userId = currentUser.getId();4.2 hasRole与hasAuthority:ROLE_前缀的坑
@PreAuthorize里的hasRole和hasAuthority,是新人最容易混淆的地方。区别就一个:hasRole('ADMIN')实际判断的是ROLE_ADMIN这个权限,框架会自动拼上ROLE_前缀;而hasAuthority('ADMIN')判断的就是字面量ADMIN,不会动你的字符串。
这意味着两个陷阱。第一,如果你的用户权限数据里存的是ADMIN而不是ROLE_ADMIN,那么用hasRole写出来的判断永远过不了。第二,如果你往用户权限里塞了ROLE_USER,然后用hasAuthority('USER')去判断,同样匹配不上。
我在一个实际项目里选用的规则是:数据库角色统一存不带前缀的标识,比如ADMIN、USER,在查询用户权限时统一加上ROLE_前缀构造SimpleGrantedAuthority,然后授权规则全部用hasRole表达。这样语义清晰,配置集中在UserDetailsService里,后续加角色不会被前缀问题绊住。
4.3 401和403的分工:认证失败与权限不足要分开处理
默认情况下,未登录访问受保护接口,Spring Security会返回401并尝试重定向到登录页。权限不足则返回403。前后端分离后,重定向行为必须禁用,改成返回JSON,前端根据状态码决定跳转逻辑。
自定义两个处理器,一个处理认证异常,一个处理授权异常。
@Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未认证或登录已过期\"}"); } } @Component public class RestAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"message\":\"无权访问\"}"); } }为什么这块值得单独花篇幅?因为我见过不少团队只在SecurityConfig里配了httpBasic或formLogin,联调时前端收到的是HTML错误页,axios拦截器里拿不到JSON响应,排查半天才找到根因。把401和403的响应格式统一成JSON,前后端联调效率会高很多。
5. 前后端联调阶段,我埋过的那些坑
5.1 Authorization请求头与跨域预检
Token放在Authorization请求头是前后端分离的主流做法,格式固定为Bearer <token>。前端axios封装时,一般在拦截器里统一追加请求头。
axios.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = 'Bearer ' + token; } return config; });跨域预检是联调时第一个高频坑。当前端请求后端接口时,如果请求头里带了Authorization,浏览器会先发一个OPTIONS预检请求,确认服务器允许这个请求头,然后才发正式请求。如果SecurityConfig里没放行OPTIONS请求,预检就会撞上登录校验,直接401,浏览器报跨域错误。
解决办法是在SecurityConfig里放行OPTIONS,同时开启CORS支持。
http.cors().and().csrf().disable()再配一个CorsConfigurationSource,允许的前端域名、请求头、方法都显式列出来。
@Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(List.of("http://localhost:8080")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("Authorization", "Content-Type")); configuration.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; }5.2 退出登录后Token依然有效怎么办
JWT的退出登录和Session方案很不一样。Session方案里服务端把Session删了,人就退了;JWT方案里Token在客户端手里,服务端根本不存,想“删”都没地方删。假如有人拿到了一个还没过期的JWT,退出登录之后照样能拿去调接口。
解决思路大概有三条。最省事的办法是前端退出登录时丢弃本地Token,服务端什么都不做。缺点很明显:泄露出去的Token没法撤回。
稳妥一点的是维护一个Token黑名单,退出时把Token的jti或过期内码存进Redis,过滤器里校验Token合法性时先查黑名单,命中就拒绝。代价是引入Redis依赖,但换来了主动撤回的能力。
如果业务有更强的安全要求,还可以做Token版本号。Redis里存userId到Token的映射,每次登录生成新Token就覆盖旧版本,接口请求时对比一下,版本不一致就踢下线。这个方案能顺手解决并发登录控制的问题。
5.3 过期时间设为多久合适
Token过期时间是个平衡题。设太短,用户体验差,每隔半小时就要重新登录一次;设太长,Token泄露后的风险窗口增大。我在实际项目里给普通管理系统的建议是8小时,加上前端在响应拦截器里检测401后自动跳转登录页,用户感知基本无感。
如果想做得更顺滑,可以引入刷新Token机制:一个短期有效的Access Token用于业务请求,一个长期有效的Refresh Token用于静默续期。这套机制有效但复杂,小项目不建议一开始就上,先跑通基础方案再说。
5.4 并发登录与强制下线的思路
前后端分离的JWT方案天然适合多端同时在线,因为每个设备各自持有一份Token。但有些业务场景要求同一账号只允许一个设备在线,比如某些后台管理系统。
做法不复杂,登录时生成唯一会话ID,Redis里存一份userId对会话ID的映射,过滤器里校验Token后拿会话ID跟Redis比对,不一致就拒绝。这样新设备登录后,旧设备的Token立即失效,前端下次请求拿到401,就会被拦截器踢回登录页。
如果要求“后面登录的人把前面的人踢下线”,写法稍微调整:登录时把新的会话ID覆盖到Redis,即可实现后者挤掉前者的效果,退出的那一方会收到401。
最后,说点实际的心得
这套方案我在上个项目里已经稳定跑了半年多,生产环境单日百万级请求,过滤器链路没出过性能问题。真正让我感到有价值的,其实不是JWT本身,而是把Spring Security的过滤器链、认证授权机制彻底梳理清楚了。之前总觉得Spring Security是个黑盒,配置能跑就行;现在遇到问题敢直接扒源码看执行流程了。
如果你准备在项目里落地这套方案,我个人的建议是分步走:第一周先跑通登录和Token放行,不急着做细粒度权限;第二周再把角色、路由、注解三层控制加上;最后处理退出、并发这些边角需求。别想着一次到位,权限这东西,方案再漂亮,业务场景一变照样要调整。代码里留好扩展点,比什么都重要。