在 Java 后端开发中,Spring Security 几乎是事实标准的安全框架。它既能用几行配置就给应用加上表单登录与密码保护,也能平滑演进为基于 JWT 的接口鉴权方案,支撑前后端分离的无状态架构。本文从默认安全行为讲起,一步步落地「数据库认证 + 方法级授权 + JWT 令牌」的完整链路,所有代码均可在 Spring Boot 3 直接运行。
一、为什么需要 Spring Security
一个没有防护的 Web 应用,任何人都可能直接调用管理接口、篡改数据。Spring Security 在 Servlet 过滤器链上统一拦截请求,提供认证(你是谁)、授权(你能做什么)和防护(CSRF、CORS、会话固定)三道防线。它与 Spring Boot 自动配置深度集成,开箱即可用。
- 认证:校验用户名密码、令牌、OAuth2 等凭证
- 授权:基于角色或表达式控制接口访问
- 防护:CSRF、CORS、点击劫持、会话管理
二、快速接入:默认表单登录
2.1 引入依赖与最小配置
只需一个 starter,Spring Boot 就会自动装配一条默认安全过滤器链,并为应用生成一个随机密码(控制台可见)。
<!-- pom.xml -->
<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>
2.2 自定义 SecurityFilterChain
实际项目中我们都会显式声明过滤器链,明确哪些路径放行、哪些需要认证,以及登录页与登出行为。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/actuator/health").permitAll()
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/login").permitAll())
.logout(logout -> logout.permitAll());
return http.build();
}
}
这段配置等价于「除公开路径外全部需要登录」,并启用了 Spring Security 自带的表单登录与登出。关于 Spring Boot 自动配置如何装配这些 Bean,可参考本站《Spring Boot 自动配置原理》。
三、基于数据库的认证
3.1 实现 UserDetailsService
默认的内存用户无法满足生产需求,我们需要从数据库加载用户,并实现 UserDetailsService。
@Service
public class JpaUserDetailsService implements UserDetailsService {
private final UserRepository repo;
public JpaUserDetailsService(UserRepository repo) {
this.repo = repo;
}
@Override
public UserDetails loadUserByUsername(String username) {
AppUser u = repo.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username));
return org.springframework.security.core.userdetails.User
.withUsername(u.getUsername())
.password(u.getPassword()) // 已加密存储
.authorities(u.getRoles().toArray(new String[0]))
.build();
}
}
3.2 密码加密:永远用 BCrypt
明文存储密码是严重安全事故。Spring Security 提供了 PasswordEncoder,BCrypt 自带盐值且自适应慢哈希,是当前推荐方案。
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
// 注册用户时加密
String hashed = passwordEncoder.encode(rawPassword);
user.setPassword(hashed);
四、方法级授权与角色控制
除了 URL 层面,Spring Security 还能在方法上做精细授权。开启 @EnableMethodSecurity 后,可用注解约束服务层。
@PreAuthorize("hasRole('ADMIN')")
public void deleteTenant(Long id) {
tenantRepo.deleteById(id);
}
@PreAuthorize("hasAuthority('ORDER:READ')")
public Order getOrder(Long id) {
return orderRepo.findById(id).orElseThrow();
}
注解里的 hasRole 会自动补上前缀 ROLE_,而 hasAuthority 匹配完整权限串,二者不要混用。
五、无状态 JWT 接口鉴权
前后端分离架构下,服务端通常不保持会话。做法是:用户登录后由后端签发 JWT,前端后续请求在 Authorization: Bearer 头携带令牌,后端用过滤器校验,全程无状态。
5.1 JWT 工具类
@Component
public class JwtUtil {
private final String secret = "change-me-base64-32bytes-min";
private final long ttl = 3600_000L; // 1h
public String issue(String username, List<String> roles) {
return Jwts.builder()
.subject(username)
.claim("roles", roles)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + ttl))
.signWith(Keys.hmacShaKeyFor(secret.getBytes()))
.compact();
}
public Claims parse(String token) {
return Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
.build().parseSignedClaims(token).getPayload();
}
}
5.2 登录接口签发 Token
@PostMapping("/api/login")
public Map<String,String> login(@RequestBody LoginReq req) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(req.username(), req.password()));
UserDetails u = (UserDetails) auth.getPrincipal();
String token = jwtUtil.issue(u.getUsername(),
u.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList());
return Map.of("token", token);
}
5.3 过滤器校验 Token
自定义 OncePerRequestFilter 拦截请求,解析 JWT 并设置 SecurityContext,再把登录接口放行。
public class JwtFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String h = req.getHeader("Authorization");
if (h != null && h.startsWith("Bearer ")) {
Claims c = jwtUtil.parse(h.substring(7));
var auth = new UsernamePasswordAuthenticationToken(
c.getSubject(), null, parseRoles(c));
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(req, res);
}
}
六、CORS 与静态资源放行
分离部署时前端域与后端域不同,需要显式配置 CORS,并放行登录、刷新等公开接口,避免被过滤器链拦截。
http
.cors(withDefaults())
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/login", "/api/refresh"))
.authorizeHttpRequests(a -> a
.requestMatchers("/api/login", "/api/public/**").permitAll()
.requestMatchers("/api/**").authenticated());
七、单元测试与安全校验
安全逻辑必须用测试兜底。借助 @WithMockUser 可以模拟登录用户,验证授权是否生效。
@Test
@WithMockUser(roles = "USER")
void userCannotDeleteTenant() {
assertThrows(AccessDeniedException.class,
() -> service.deleteTenant(1L));
}
八、常见误区与排障
| 现象 | 根因 | 修复 |
|---|---|---|
| 登录后仍为匿名 | UserDetails 密码未加密/编码器不匹配 | 统一使用同一 PasswordEncoder |
| hasRole 不生效 | 角色未加 ROLE_ 前缀 | 用 hasAuthority 或补齐前缀 |
| JWT 一直 401 | 时钟漂移 / 密钥不一致 | 校验服务器时间与服务间密钥 |
| OPTIONS 预检被拦 | CORS 未放行预检 | 放行 OPTIONS 或开启 withDefaults() |
九、落地清单
| 维度 | 表单登录方案 | JWT 无状态方案 |
|---|---|---|
| 会话 | 服务端保持 HttpSession | 无会话,令牌自包含 |
| 扩展 | 依赖会话复制/粘滞 | 天然水平扩展 |
| 适用 | 传统服务端渲染 | 前后端分离 / 移动端 |
| 登出 | 销毁 session | 令牌过期或加黑名单 |
从默认表单登录到 JWT 接口鉴权,Spring Security 的演进路径非常平滑:先用好 SecurityFilterChain 与 UserDetailsService 搭好认证地基,再切换到无状态令牌。配合 Spring Boot 自动配置与 JUnit5 安全测试,即可让接口既安全又可维护。




