Spring Security 实战:JWT 认证与权限控制

在微服务与前后端分离成为主流的今天,Spring Security 几乎是 Java 后端做认证与权限控制的事实标准。传统 Session 方案在横向扩容和无状态 API 面前越来越吃力,而基于 JWT(JSON Web Token) 的鉴权链路能让每个请求自带身份,网关与业务服务各取所需。本文用一套可运行的示例,讲清 Spring Security 6 下 JWT 从签发、校验到角色授权的完整落地。

一、为什么要从 Session 切到 JWT

Session 方案依赖服务端保存会话状态:用户登录后容器在内存或 Redis 里挂一份会话,后续请求靠 Cookie 里的 JSESSIONID 找回。问题在三点:第一,多实例部署必须做 Session 共享,否则请求被轮询到另一台机器就掉登录;第二,浏览器同源策略让移动端、小程序、第三方服务接入很别扭;第三,无状态 API 每一次都要回查会话,增加了一次网络跳转。

JWT 把”你是谁、能干什么”编码进签名令牌,客户端每次请求在 Authorization 头带上它,服务端用密钥验签即可,不再查库。配合我们之前落地的 Spring Cloud Gateway 网关 做路由与限流,安全层只管鉴权,职责清晰。本篇默认你已经跑在 Spring Boot 3 与 Java 21 虚拟线程 之上,Spring Security 6 的 API 与 5.x 差异较大,注意不要照搬老教程。

二、依赖与基础配置

Spring Boot 3 场景下,引入 starter-security 与 jjwt(负责令牌编解码)。BOM 已管理版本,这里只写坐标:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- JWT 编解码,0.12.x 起 API 变化较大 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.6</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.6</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.6</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

jjwt 0.12 之后把 Key 构建、解析的 API 做了拆分,secretKey 必须用 Keys.hmacShaKeyFor 生成,且长度不低于 256 位,否则运行期直接抛异常。下面给出令牌工具类。如果你还在用 0.11 的老写法 Jwts.builder().setSubject(),升级后会发现 setSubject 已改为 subject,方法链命名整体向 Jakarta 规范看齐,老代码需要批量替换。

三、JWT 工具类:签发与校验

把令牌的创建、解析、过期判断收敛到一个 Provider 里,业务代码不直接碰 jjwt 细节。注意密钥从配置读取,生产环境务必放配置中心或 KMS,不要硬编码到源码。

@Component
public class JwtTokenProvider {

    @Value("${app.jwt.secret}")
    private String secret;

    @Value("${app.jwt.expiration-ms:3600000}")
    private long expirationMs;

    private SecretKey key() {
        // 密钥需 >= 256 bit,用 Base64 解码更稳妥
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
    }

    public String generateToken(String username, List<String> roles) {
        Date now = new Date();
        Date exp = new Date(now.getTime() + expirationMs);
        return Jwts.builder()
                .subject(username)
                .claim("roles", roles)
                .issuedAt(now)
                .expiration(exp)
                .signWith(key())
                .compact();
    }

    public String parseUsername(String token) {
        return Jwts.parser()
                .verifyWith(key())
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getSubject();
    }

    public boolean validate(String token) {
        try {
            Jwts.parser().verifyWith(key()).build()
                 .parseSignedClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
}

四、把 Token 织入请求链:OncePerRequestFilter

核心思路是写一个过滤器,在每次请求进入 DispatcherServlet 之前,从请求头取出 Bearer Token,验签通过后把用户信息塞进 SecurityContextHolder。这一步做好了,后续 Controller 里就能用 @PreAuthorize 拿到身份。

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenProvider provider;

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain) throws IOException, ServletException {
        String header = req.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);
            if (provider.validate(token)) {
                String username = provider.parseUsername(token);
                UsernamePasswordAuthenticationToken auth =
                        new UsernamePasswordAuthenticationToken(
                                username, null, List.of());
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }
        chain.doFilter(req, res);
    }
}

五、SecurityFilterChain:放行与鉴权

Spring Security 6 用 SecurityFilterChain Bean 取代废弃的 WebSecurityConfigurerAdapter。重点是三件事:放行登录与公开接口、把 JWT 过滤器排在用户名密码过滤器之前、把会话策略设为无状态。

@Configuration
@EnableMethodSecurity   // 开启 @PreAuthorize 等注解
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationFilter jwtFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)   // 无状态 API 通常关闭
            .sessionManagement(s ->
                s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(a -> a
                .requestMatchers("/api/auth/login", "/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .addFilterBefore(jwtFilter,
                UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

六、角色与权限:@PreAuthorize 精细化控制

登录接口校验账号密码后签发令牌,真正的权限判断下沉到方法级别。下面这张表覆盖了最常见的 SpEL 表达式,按需组合即可。

表达式含义典型场景
hasRole(‘ADMIN’)拥有 ADMIN 角色后台管理接口
hasAuthority(‘user:read’)拥有具体权限点按钮级数据权限
authentication.name == #owner只能操作自己的资源用户只能删自己的订单
permitAll() / denyAll()全放行 / 全拒绝健康检查、内网接口

登录与受保护接口的写法如下,注意登录接口本身要 permitAll,否则陷入”没令牌不能登录”的死循环:

@RestController
@RequestMapping("/api")
public class UserController {

    @PostMapping("/auth/login")
    public Map<String,String> login(@RequestBody LoginReq req) {
        // 1. 用 UserDetailsService 校验账号密码
        // 2. 校验通过再签发令牌
        String token = provider.generateToken(req.username(), roles);
        return Map.of("token", token);
    }

    @PreAuthorize("hasRole('ADMIN')")
    @DeleteMapping("/admin/users/{id}")
    public void deleteUser(@PathVariable Long id) {
        // 只有 ADMIN 能进,非法请求在过滤器阶段即被 403
    }
}

七、刷新令牌与登出

访问令牌设短一点(比如 1 小时)降低泄露风险,再发一个存活更久的刷新令牌。刷新接口同样 permitAll,用旧刷新令牌换新的访问令牌。JWT 本身无状态、无法主动作废,登出一般有两种做法:前端直接丢弃令牌;后端维护一个短期黑名单(Redis 存失效令牌的 jti,TTL 与过期时间对齐)。对于大多数内部系统,令牌够短 + 改密即失效已足够,不必过度设计。

八、常见坑与排查

现象根因修复
启动报密钥长度不足secret 小于 256 bit用 32 字节以上并 Base64 编码
永远 403@EnableMethodSecurity 漏加补注解,确认角色带 ROLE_ 前缀
过滤器不生效addFilterBefore 顺序错排在 UsernamePasswordAuthenticationFilter 前
跨域 OPTIONS 被拦预检请求未放行放行 OPTIONS 或配置 CorsConfigurationSource

九、生产落地检查清单

检查项建议
密钥管理放配置中心 / KMS,禁止提交仓库
令牌有效期访问令牌 ≤ 1h,刷新令牌 ≤ 7d
传输安全全站 HTTPS,配合 Nginx 反向代理 终止 TLS
权限测试在 GitHub Actions 里跑安全用例,覆盖越权场景
异常可观测鉴权重试、限流接入 微服务治理 体系

十、JWT 与 OAuth2:什么时候该上授权框架

很多团队一上来就把 JWT 和 OAuth2 混为一谈。简单说,JWT 只是一种令牌格式,而 OAuth2 是一套授权协议,解决”第三方应用如何代表用户访问资源”。如果你只是自己的前后端之间做登录态,直接用本文的 JWT 方案就够;但当你需要”用微信/Google 账号登录””把接口开放给合作方””支持多种客户端委托授权”时,就该引入 OAuth2 授权服务器(如 Spring Authorization Server),把 JWT 当作它签发的令牌载体即可。

一个常见的反模式是”用 JWT 自己造一套 OAuth2″:前端存 refresh token 在 localStorage、后端手写授权码流程,最后既没拿到 OAuth2 的安全保证,又背上维护成本。该用框架时用框架,把精力放在业务权限模型上,才是更划算的做法。对于 微服务治理 场景下多服务共享身份的场景,推荐由网关统一校验 JWT、把解析出的用户信息通过请求头透传给下游,下游服务不再各自对接鉴权,避免重复建设与密钥扩散。

到这一步,一套可上生产的 Spring Security + JWT 鉴权骨架就搭好了:令牌签发收敛在 Provider,身份注入交给过滤器,路由与放行在 SecurityFilterChain 里声明,细粒度权限用 @PreAuthorize 收口。把它和网关、CI 安全测试串起来,Java 后端的认证授权才算真正闭环。

上一篇 Redis 缓存雪崩、击穿、穿透:三大故障排查与防护
下一篇 Bun 实战:全功能 JavaScript 运行时与极速工具链
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]