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 时就要想清楚的底色。




