前后端分离做久了,大家大概率都会遇到同一个问题:登录状态到底怎么维持?Cookie + Session 方案不是不能用,但跨域、集群、App 端适配、单点登录扩展,每一个都够你折腾半天。所以我现在做新项目,基本直接上 SpringSecurity + token 认证,token 载体优先选 JWT。这篇文章就结合我最近整理的一套 SpringSecurity 模板,聊聊如何实现 token 认证,从过滤器链路、登录接口,到 token 失效、续签,以及那些真正坑过我的细节。不管你是刚接触 SpringSecurity 的初学者,还是想搭建统一认证基础的中级开发,下面的内容应该都能让你少走不少弯路。
1. 为什么前后端分离项目都改用 token 认证
1.1 Session 方案的局限在哪里
传统的 Web 项目大多是服务端渲染,前端页面跟后端接口跑在同一个域名下,Session 方案很自然:用户登录后,服务端把用户信息存进 HttpSession,再通过 Set-Cookie 把 JSESSIONID 发给浏览器,后续请求浏览器自动带上这个 Cookie,服务端一比对就恢复身份。这套机制在单体时代是没问题的,但是前后端分离之后,麻烦就来了。
第一是跨域问题。前端跑在 8080,后端跑在 9090,接口调用天然跨域,那么 Cookie 携带、CORS 配置里关于 credentials 的设置,处理起来总是容易出问题。第二是集群扩展。服务端 Session 默认存在单机内存里,一旦部署多台服务器,负载均衡把请求分到另一台机器,Session 就丢了,你得上 Session 共享、引入 Redis 或者 Spring Session,又增加一套运维复杂度。第三是客户端适配。App 端根本没有原生 Cookie 管理机制,移动端和 Web 端共用一套后端时,Session 方案往往要额外封装。
1.2 Token 认证:把状态交给客户端
Token 认证的思路是:服务端登录成功后生成一段凭证返回给客户端,客户端每次请求时把它放在请求头里带回来,服务端校验通过就放行。因为服务端不再保存会话状态,所有身份信息都被编码在 token 里,所以在集群下、跨域下、多端适配下都比 Session 更舒服。无状态、扩展性强、前后端解耦,这是它被大规模采用的核心原因。
而 JWT(JSON Web Token)是目前最常用的 token 载体。它由 Header、Payload、Signature 三部分组成,Base64Url 编码后用密钥签名。服务端只需要用同一个密钥验证签名,就能确认 token 没有被篡改,再读取 Payload 里的用户信息即可。这个设计特别适合分布式系统:任何服务节点只要拿到密钥,就能独立完成认证,不需要共享 Session 存储。当然无状态也不是没有缺点,token 签发之后,在过期之前是没法主动作废的,这就是后面我们要解决的失效问题。
2. 项目初始化与依赖选型
2.1 版本组合怎么选
我这套示例使用的是 Spring Boot 2.7.18,对应的 Spring Security 是 5.8,Java 版本用 8 或 11 都可以。为什么不直接上 Spring Boot 3?因为 3.x 把 javax 命名空间迁移到了 jakarta,很多老项目迁移成本不小,而且网上大量资料还是基于 javax 写的,新手看着容易混乱。这里我先用 2.7 讲清楚核心机制,你以后迁 3.x,主要就是改 import,整体思路完全一样。
JWT 库我建议用 jjwt 0.11.5,它是目前 Java 生态里使用最广泛的 JWT 实现库。它的 API 在 0.10 之后变化比较大,如果你搜到旧博客用的是Jwts.parser().parseClaimsJws,现在要换成Jwts.parserBuilder().build(),这个细节后面代码里能看到。
2.2 引入核心依赖
新建一个 Spring Boot Web 项目后,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>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>jjwt-api是编译时需要调用的 API,jjwt-impl和jjwt-jackson运行时才需要,所以后两个 scope 设为 runtime。如果缺了jjwt-jackson,解析 JWT 时可能报 Jacksons 相关 ClassNotFound,这算是一个小坑。
数据库和 ORM 根据你的情况选,这里用 Spring Data JPA + MySQL 就可以了。然后配置 application.yml:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root jpa: hibernate: ddl-auto: update show-sql: true jwt: secret: a-very-long-secret-key-that-must-be-at-least-256-bits-long expire-hours: 24 header: Authorization prefix: "Bearer "注意jwt.secret,jjwt 的Keys.hmacShaKeyFor要求密钥长度至少 32 字节(256 bit)。如果你写个"my-secret"这种短字符串,启动一解析就会抛WeakKeyException。这个配置本身不是安全配置的最高标准,但作为模板没问题。
3. SpringSecurity 核心配置:无状态 + 自定义过滤器
3.1 SecurityFilterChain 配置要点
SpringSecurity 从 5.7 版本开始推荐使用SecurityFilterChain组件式配置。旧版的WebSecurityConfigurerAdapter已经废弃,不要再用。我的核心配置类长这样:
@Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final RestAuthenticationEntryPoint authenticationEntryPoint; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, RestAuthenticationEntryPoint authenticationEntryPoint) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; this.authenticationEntryPoint = authenticationEntryPoint; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .cors(cors -> {}) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .antMatchers("/api/auth/login", "/error").permitAll() .antMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .exceptionHandling(ex -> ex.authenticationEntryPoint(authenticationEntryPoint)) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } }三个关键点:
- 关闭 CSRF。因为 token 认证的防御思路不再是 Cookie 自动携带,CSRF 攻击面大幅下降,关闭是为了让 POST 接口不被默认拦截。
- 会话策略设为
STATELESS。SpringSecurity 不再创建 HttpSession,这是无状态认证的关键。 - 自定义
JwtAuthenticationFilter必须加在UsernamePasswordAuthenticationFilter之前。这个执行顺序很重要,否则请求刚从过滤器链进入,还没到鉴权阶段,身份就已经丢了。
3.2 自定义 Token 过滤器到底做了什么
过滤器是整个 token 认证流程的核心。它本质上做一件事:尝试从请求头里拿到 token,校验通过后,把用户信息放进SecurityContextHolder。这样后续的鉴权机制才能识别出“你已登录”。代码实现如下:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsService userDetailsService) { this.jwtTokenProvider = jwtTokenProvider; this.userDetailsService = userDetailsService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String token = jwtTokenProvider.resolveToken(request); if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token) && SecurityContextHolder.getContext().getAuthentication() == null) { String username = jwtTokenProvider.getUsername(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } chain.doFilter(request, response); } }用OncePerRequestFilter而不是普通 Filter,是因为一次请求经过内部转发时,普通 Filter 可能被执行多次,而OncePerRequestFilter保证一个请求只执行一次,避免身份被重复设置。
我加了一个SecurityContextHolder.getContext().getAuthentication() == null的判断。这个判断主要是防止过滤器链中已经存在认证信息时被覆盖,比如在同一个请求里先后经过了其他认证过滤器。很多初学者抄代码会漏掉这个判断,但漏掉的后果在 SSO 或复杂过滤器链场景里会更明显。
validateToken返回 false 时怎么办?我这里选择了继续放行,而不是直接抛出异常。因为 SpringSecurity 的原则是:过滤器不负责决定“该不该拒绝请求”,它只负责把能认证的认证好;后续authorizeHttpRequests发现没有认证信息,自然会触发AuthenticationEntryPoint,返回 401。这样职责更清晰。
3.3 异常处理与未认证响应
默认的 SpringSecurity 认证失败响应是一段 HTML,前端根本没法解析。所以我实现了AuthenticationEntryPoint,统一返回 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\":\"login expired or unauthorized\"}"); } }AuthenticationEntryPoint只处理“未认证”的情况。如果用户已经登录但权限不够,那会走AccessDeniedHandler,返回 403。这两者不要搞混。新手排查时看到 403,一般就是角色权限配置不对,而不是 token 失效。
4. 登录接口与令牌签发实现
4.1 用户信息加载与密码校验
SpringSecurity 的AuthenticationManager需要一个UserDetailsService来加载用户信息。我的实现大致是这样的:
@Service public class UserDetailsServiceImpl implements UserDetailsService { private final UserRepository userRepository; public UserDetailsServiceImpl(UserRepository userRepository) { this.userRepository = userRepository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("user not found")); return new SysUserDetails(user); } }SysUserDetails实现UserDetails,把数据库里的角色转换成GrantedAuthority:
public class SysUserDetails implements UserDetails { private final User user; public SysUserDetails(User user) { this.user = user; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return user.getRoles().stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName())) .collect(Collectors.toList()); } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }这里默认数据库里的密码是 BCrypt 加密后的。SpringSecurity 在认证时会调用PasswordEncoder.matches比较明文密码和密文,如果数据库里存的是明文,认证永远失败。
4.2 登录接口代码与执行流程
登录接口本身很简单,核心是把 SpringSecurity 的认证流程串起来:
@RestController @RequestMapping("/api/auth") public class AuthController { private final AuthenticationManager authenticationManager; private final JwtTokenProvider jwtTokenProvider; public AuthController(AuthenticationManager authenticationManager, JwtTokenProvider jwtTokenProvider) { this.authenticationManager = authenticationManager; this.jwtTokenProvider = jwtTokenProvider; } @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest request) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword() ) ); SecurityContextHolder.getContext().setAuthentication(authentication); String token = jwtTokenProvider.generateToken(authentication); return ResponseEntity.ok(new LoginResponse(token, "Bearer")); } }流程是:
- 前端提交用户名和密码。
AuthenticationManager调用UserDetailsService加载用户,再用PasswordEncoder校验密码。- 校验失败抛异常,由全局异常处理器转成业务提示。
- 校验通过后,
authentication对象里就包含了用户信息和权限集合。 - 用
JwtTokenProvider生成 token 返回前端。
注意我这里没有用UsernamePasswordAuthenticationFilter,因为它默认拦截/login并依赖表单请求参数。我们手动调用AuthenticationManager更灵活,也适合前后端分离。
4.3 Token 工具类:生成与解析的细节
JwtTokenProvider是封装 JWT 所有操作的公共类。初始化时构建密钥,生成 token 时写入用户名和角色列表:
@Component public class JwtTokenProvider { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire-hours}") private int expireHours; private SecretKey key; @PostConstruct public void init() { this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(Authentication authentication) { String username = authentication.getName(); List<String> roles = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); Date now = new Date(); Date expiration = new Date(now.getTime() + expireHours * 3600L * 1000L); return Jwts.builder() .setSubject(username) .claim("roles", roles) .setIssuedAt(now) .setExpiration(expiration) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public String resolveToken(HttpServletRequest request) { String bearer = request.getHeader("Authorization"); if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { return bearer.substring(7); } return null; } public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } public String getUsername(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody() .getSubject(); } }有几个细节要特别注意。
setSubject只放用户名是刻意为之,不要把敏感信息、大对象放进 JWT。因为 JWT 的 Payload 只是 Base64Url 编码,不是加密,任何人拿到 token 都能解码看到内容。你如果往里面塞手机号、身份证号,等于把敏感信息裸奔给了客户端。
resolveToken只认标准的Authorization: Bearer xxx结构。header 名是Authorization,前缀是Bearer,注意后面有一个空格。前端如果自定义 header 名,不要放到Authorization里,会跟浏览器的某些机制冲突。
validateToken里捕获了JwtException和IllegalArgumentException。如果密钥不对、token 被篡改、token 过期,都会抛出对应异常,这里统一当作无效 token 处理。
5. 从请求到放行:一次完整认证流程拆解
5.1 认证流程中的角色与时机
很多初学者把“登录认证”和“接口鉴权”混为一谈,实际上一次带 token 的请求在 SpringSecurity 里经历的顺序是固定的:
- 浏览器或客户端携带
Authorization: Bearer token访问接口。 - 请求进入 SpringSecurity 过滤器链,各个过滤器按顺序执行。
JwtAuthenticationFilter最先解析 token,校验通过后把用户信息放进SecurityContextHolder。AuthorizationFilter根据SecurityConfig里的规则判断当前请求是否需要登录、需要什么角色。- 如果未认证,
AuthenticationEntryPoint返回 401。 - 如果已认证但权限不足,
AccessDeniedHandler返回 403。 - 认证通过,请求到达目标 Controller,业务代码正常执行。
SpringSecurity 的核心思路是“过滤链叠加”。你不需要在每个 Controller 里判断用户是否登录,过滤链已经帮你兜底了。这就是框架最大的价值:安全横切面被统一管理。
5.2 方法级权限如何与 token 中的角色配合
除了 URL 级别的拦截,我还会在接口方法上用注解做细粒度控制。@EnableGlobalMethodSecurity(prePostEnabled = true)已经开启了对@PreAuthorize的支持:
@RestController @RequestMapping("/api/order") public class OrderController { @GetMapping("/list") @PreAuthorize("hasAnyRole('USER', 'ADMIN')") public List<Order> list() { return orderService.list(); } @DeleteMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") public void delete(@PathVariable Long id) { orderService.delete(id); } }这里的角色判断依据就是 token 里rolesclaim 转成的GrantedAuthority。所以生成 token 时把角色写进 JWT 是必要的。如果用户角色在 token 有效期内被调整了,旧 token 依然持有旧角色,这是无状态认证的一个天然局限,很多权限变更严格的项目会因此增加一层服务端校验,比如每次请求读 Redis 确认角色版本号。
5.3 过滤器执行顺序的坑
如果你把JwtAuthenticationFilter加错位置,可能出现请求到了 Controller 时SecurityContextHolder里依然是空的,或者权限不起作用。我的建议是永远放在UsernamePasswordAuthenticationFilter之前。
还有一个容易被忽略的点:SecurityContextHolder默认的ThreadLocal策略在异步线程里会失效。例如在 Controller 里通过@Async开启新线程,那个子线程读不到登录用户信息,因为 ThreadLocal 不跨线程传递。解决方案要么不在异步线程里读取当前用户,要么显式传递用户参数,不要依赖SecurityContext。
6. Token 失效、续签与常见问题排查
6.1 Token 过期与主动失效方案
JWT 过期只依赖exp字段,所以最简单的失效机制就是设置合理的过期时间。我通常会把 access token 的过期时间设为 1 到 24 小时,具体看业务对安全性的要求。如果要做到“修改密码后旧 token 立即失效”“管理员封号后 token 立即失效”,那就必须引入黑名单机制。
黑名单的常见实现是 Redis:把需要作废的 token 的唯一标识jti存进 Redis,并设置过期时间等于剩余有效期。每次请求先检查该 token 是否在黑名单里,如果在就拒绝。这样做的好处是保留 JWT 无状态特性的同时,把“主动失效”的能力补了回来。代价是每增加一次 Redis 查询,本质又变回了“有状态”。
另一种更简单的方案是加用户密码版本号:修改密码时把版本号加一,生成 token 时把版本号放进 claim,校验时对比数据库里的版本号。这个方案不用 Redis,但每次请求要查一次数据库。
6.2 刷新 token 与滑动续签
业务上常常需要 token 续签。我推荐双 token 方案:一个短时效的 access token,用于正常请求,有效期 15 分钟到 2 小时;一个长时效的 refresh token,只用于调刷新接口,有效期 7 天到 30 天。客户端在 access token 失效后,拿 refresh token 请求刷新接口,换一个新的 access token。
刷新接口的实现很简单:
@PostMapping("/refresh") public ResponseEntity<?> refresh(@RequestBody RefreshRequest request) { String refreshToken = request.getRefreshToken(); if (jwtTokenProvider.validateToken(refreshToken)) { String username = jwtTokenProvider.getUsername(refreshToken); UserDetails userDetails = userDetailsService.loadUserByUsername(username); Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); String newToken = jwtTokenProvider.generateToken(authentication); return ResponseEntity.ok(new LoginResponse(newToken, "Bearer")); } return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); }注意 refresh token 不要跟 access token 使用同样的密钥和 claim 规则,否则一个 access token 丢了,别人也能用它刷新。更安全的做法是为 refresh token 单独使用不同的audienceclaim 和密钥,甚至专门部署刷新服务。对于大多数中小项目,至少要在 token 里加一个tokenType字段做区分。
另一种滑动续签方案是:每次请求都检查剩余有效期,如果少于某个阈值,就生成一个新 token 放在响应头里返回。这个方式能减少刷新接口调用,但会导致大量 token 派发,不好追踪,我一般在小规模内部系统才用。
6.3 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 登录接口直接 403 | CSRF 未关闭,或 SpringSecurity 拦截了/login | 在 SecurityConfig 中csrf.disable(),并放行登录路径 |
| 登录成功后接口仍然 401 | 请求头没带 token、token 过期、过滤器没解析到 | 检查前端 Header 名称与前缀,检查 token 有效期 |
| 密钥太短抛 WeakKeyException | JWT secret 不足 32 字节 | 将 secret 换成长度大于 32 的随机字符串 |
| 解析 token 报 JWT signature does not match | 本地产生的 token 被篡改或密钥不一致 | 确认 encrypt/decrypt 用的密钥一致 |
| 明明有 token 但拿不到用户信息 | 过滤器没执行 或 SecurityContext 被清理 | 检查过滤器是否已成功注册,是否放入了UsernamePasswordAuthenticationToken |
| 返回 403 而不是 401 | 已登录但无对应角色 | 检查 URL 权限配置和@PreAuthorize角色前缀 |
| 改动密码后旧 token 仍有效 | JWT 无状态导致无法主动失效 | 引入 Redis 黑名单或用户版本号机制 |
| 跨域请求带 token 还是被拦截 | CORS 配置未处理 Authorization 头 | 在 CORS 配置中exposedHeaders("Authorization")并允许对应请求头 |
| 使用 redis 后刷新 token 报空 token | 前端刷新接口没带新 token | 统一管理 access token 和 refresh token 的存储位置 |
每一条我都实际踩过或帮同事排查过,最容易被忽视的是 CSRF 和前缀拼写。CSRF 不关,POST 请求默认全部 403;前缀大小写不对,过滤器startsWith("Bearer ")匹配失败,身份自然就丢了。
6.4 一点个人体会
把 SpringSecurity + JWT 的模板沉淀下来之后,我最大体会是:不要一上来就抄配置,先弄懂过滤器链的执行顺序和SecurityContextHolder的工作原理。SpringSecurity 的源码看起来复杂,但它把认证、授权、过滤、异常处理分得非常清楚。你只要把“自定义过滤器负责认证,SecurityConfig 负责规则,EntryPoint 负责未认证结果”这三件事理清楚,后面的功能扩展,比如多因素认证、OAuth2 客户端、单点登录,基本都是往过滤器链里加东西而已。
最后再分享一个小技巧:生产环境下不要用 JWT 的 HS256 对称密钥,尽量用 RS256 非对称密钥,私钥在认证服务,公钥在业务服务。这样多个业务服务不用共享同一个对称密钥,安全性也更好。不过那是另一个话题了,希望这篇文章能帮你把 SpringSecurity 的 token 认证这条路顺利走通。