Spring Security 认证授权与 JWT 实战

Spring Security 是 Java 后端事实上的安全框架,它把认证(你是谁)与授权(你能做什么)拆成两条清晰链路。本文用 JWT 无状态方案,给出一套可直接落地的 Spring Security 实战配置,覆盖登录、方法级控权与 OAuth2 对接。

一、认证与授权:先别混为一谈

很多团队把”登录”和”权限”当成一回事,结果权限模型越写越乱。认证(Authentication)回答”你是谁”——校验账号密码、拿到主体身份;授权(Authorization)回答”你能做什么”——拿着身份去匹配权限规则。Spring Security 把这两件事拆成 AuthenticationManager 与 AccessDecisionManager 两条独立链路,理解这一点,后面所有配置才有抓手。常见反模式是把权限判断写死在 Controller 里:一旦业务方法被内部调用绕过 Controller,权限就形同虚设。正确做法是在”领域方法”这一层做拦截,无论谁来调用都会被同一套规则约束。

举个例子:用户登录成功后,Spring Security 会把用户信息封装进 SecurityContext,后续每个请求都从中取身份;而方法上的 @PreAuthorize(“hasRole(‘ADMIN’)”) 则在授权阶段拦截。我们在Spring Boot 3 升级踩坑实录里提到过 Jakarta 命名空间的变化,Security 6 之后配置方式也随之一起迁移,老项目的 WebSecurityConfigurerAdapter 已被废弃。

二、核心链路:一条过滤器链

Spring Security 的本质是一条 Servlet 过滤器链:请求进来先过 UsernamePasswordAuthenticationFilter 做登录,再过 AuthorizationFilter 做鉴权,中间还有 CSRF、CORS、异常翻译等过滤器。我们只需要声明一个 SecurityFilterChain Bean,就能把默认行为改成自己想要的。

过滤器的执行顺序

过滤器链不是随意排列的:认证过滤器必须排在授权过滤器之前,否则请求还没确认身份就被拿去鉴权,必然返回 401。Spring Security 用 Order 值管理这套顺序,自定义过滤器用 addFilterBefore 精确插到某个锚点之前最稳妥,不要一股脑 addFilter 让它排到链尾。调试权限问题时,先把过滤器顺序打印出来对照,往往能省下半天瞎猜。

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())          // 无状态 JWT 场景关闭 CSRF
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .addFilterBefore(jwtAuthFilter, AuthorizationFilter.class);  // 注入 JWT 校验
        return http.build();
    }
}

注意 CSRF 只在”有会话、靠 Cookie 自动携带凭证”的场景才需要。本文走 JWT 无状态方案,每个请求自带 Authorization 头,浏览器不会自动重放,因此可以安全关闭 CSRF。若你的前端仍用 Cookie 会话,CSRF 绝不能省,防御手段可参考前端安全实战:XSS 与 CSRF 防御

三、JWT 无状态登录实战

JWT 把”身份 + 过期时间 + 签名”打包成一个令牌,服务端不再保存会话,横向扩容轻松。代价是令牌一旦签发无法主动吊销,所以过期时间要短,敏感操作可叠加刷新令牌。下面是一个最小可用的 JWT 工具类。

public class JwtUtil {
    private final Key key = Keys.hmacShaKeyFor(secret.getBytes());

    public String generate(String username, List<String> roles) {
        return Jwts.builder()
            .subject(username)
            .claim("roles", roles)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + 1000 * 60 * 30)) // 30 分钟
            .signWith(key, Jwts.SIG.HS256)
            .compact();
    }

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

登录接口只负责校验密码并签发令牌,密码比对建议交给 DaoAuthenticationProvider,不要自己手写 MD5。生产环境的密钥必须放进配置中心或环境变量,绝不可硬编码,服务器侧密钥管理参见服务器安全加固:SSH、防火墙与 Fail2ban

刷新令牌如何设计

JWT 无法主动吊销,所以访问令牌通常只给 15–30 分钟。要兼顾体验,就再发一个有效期更长的刷新令牌:访问令牌过期后,前端拿刷新令牌去换新的。关键是刷新令牌要可吊销——在服务端存一张”已签发刷新令牌”表,用户登出或疑似被盗时直接作废。更稳妥的做法是轮换式刷新:每次用旧刷新令牌换新的同时,旧令牌立即失效,这样即使刷新令牌泄露,攻击者也抢不过正在使用的用户。

@PostMapping("/api/public/login")
public Map<String, String> login(@RequestBody LoginReq req) {
    Authentication auth = manager.authenticate(
        new UsernamePasswordAuthenticationToken(req.username(), req.password()));
    UserDetails u = (UserDetails) auth.getPrincipal();
    String token = jwtUtil.generate(u.getUsername(),
        u.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList());
    return Map.of("token", token);
}

四、方法级授权:@PreAuthorize 精细控权

URL 级别的 hasRole 只能管到粗粒度,真正的业务权限要在方法上声明。@PreAuthorize 支持 SpEL 表达式,可以拿到当前用户、方法参数甚至调用数据库做判断。记得开头配置里要加 @EnableMethodSecurity。

@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) { ... }

// 只能操作归属自己的资源
@PreAuthorize("#order.owner == authentication.name")
public Order getOrder(@P("order") Order order) { ... }

// 表达式引用 Bean,做动态数据权限
@PreAuthorize("@rbac.canAccess(authentication, #deptId)")
public List<User> listByDept(Long deptId) { ... }

权限判断如果涉及数据库,别在注解里写复杂 SQL,把逻辑抽成一个 @Component Bean 用 @rbac.canAccess(…) 引用即可,既好测试又避免表达式膨胀。涉及数据层访问时,事务与 N+1 问题同样要留意,可参考MyBatis 与 JPA 性能优化实战

权限逻辑要可测试

方法级权限最容易出”看起来对了其实漏了”的事故,所以必须写测试。Spring Security 提供 @WithMockUser 注解,能在单元测试里模拟一个带指定角色的用户直接调方法,断言有权限的能过、没权限的抛 AccessDeniedException。把这些测试接进GitHub Actions 流水线,每次提交都回归一遍,比靠 Code Review 肉眼检查可靠得多。

五、OAuth2 资源服务器:对接第三方登录

如果要做微信、GitHub 第三方登录,正确姿势是把本站当成 OAuth2 资源服务器:自己不校验密码,只校验授权服务器签发的 JWT。改动很小,加一段配置即可。

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com        # 自动拉取 JWKS 公钥
          # 或显式指定 jwk-set-uri: https://auth.example.com/oauth2/jwks
          audiences: api://fsdata

资源服务器模式下,Spring Security 会自动用授权服务器的公钥校验签名、解析 scope 与受众,失败直接返回 401。你只需在方法上用 hasAuthority(“SCOPE_read”) 控权,无需关心令牌怎么签发。

授权服务器怎么选

中小团队不必自建授权服务器,直接用成熟方案更省心:Keycloak 可私有化部署、完全开源;Auth0 / 阿里云 IDaaS 等托管服务开箱即用,连登录页和短信验证都帮你做好。选型时重点看三件事——是否支持标准 OIDC、能否对接你现有的用户表、令牌吊销与审计是否完善。自建授权服务器的坑远比想象多,除非有强合规要求,否则不建议从零造轮子。

六、三种方案怎么选

方案状态横向扩容吊销适用场景
Session+Cookie有状态需共享存储即时传统单体后台
JWT 自签发无状态天然友好难(靠短过期)前后端分离 API
OAuth2 资源服务器无状态天然友好由授权服务器控第三方/单点登录

七、上线前自检清单

  • 密码必须用 PasswordEncoder 加密存储,禁止明文或弱哈希。
  • JWT 密钥长度 ≥ 256 位,存配置中心,不进代码仓库。
  • 无状态接口关闭 CSRF;有会话接口务必保留并配合 SameSite。
  • 所有写接口加方法级 @PreAuthorize,不要只靠 URL 拦截。
  • 令牌过期时间控制在分钟级,敏感操作叠加二次校验。
  • 全局异常统一返回 401/403,不泄露堆栈与内部路径。
  • HTTPS 是唯一前提,证书配置见Nginx 配置 SSL 证书;发布接入GitHub Actions CI/CD做安全扫描。

Spring Security 看着配置多,其实主线很清晰:认证拿到身份、授权匹配规则、过滤器链串起两者。把 JWT 无状态登录、方法级 @PreAuthorize 与 OAuth2 资源服务器这三块吃透,绝大多数后端权限需求都能稳稳落地。安全不是上线后的补丁,而是写第一行 Controller 时就要想清楚的底色。

上一篇 Docker 镜像瘦身实战:多阶段构建与层优化
下一篇 分库分表实战:ShardingSphere 海量数据架构