学完这篇,你能搭出一套前后端分离项目里真正跑得起来的认证授权骨架:登录签发 JWT、请求验签、接口按角色放行,并且清楚坑埋在哪。
第一步:引入依赖
Spring Boot 3.x 对应 Spring Security 6,pom.xml 加四样:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
第二步:写 JWT 工具类
JwtUtil 只要三个方法:生成、解析、取用户名。
private final SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
public String generate(String username, List<String> roles) {
return Jwts.builder()
.subject(username)
.claim("roles", roles)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 7200_000L))
.signWith(key)
.compact();
}
解析用 Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload()。
注意:密钥别写死在代码里,放 application.yml 或环境变量,且长度至少 32 字节(HS256 要求 256 位),短了 jjwt 直接抛 WeakKeyException。
第三步:实现 UserDetailsService 和登录接口
@Service
public class DbUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) {
User u = userMapper.findByUsername(username);
if (u == null) throw new UsernameNotFoundException("用户不存在");
return org.springframework.security.core.userdetails.User
.withUsername(u.getUsername())
.password(u.getPassword()) // 库里存 bcrypt 密文
.authorities(u.getRoles().stream()
.map(r -> "ROLE_" + r).toArray(String[]::new))
.build();
}
}
登录接口里交给 AuthenticationManager 认证,成功再签 token:
@PostMapping("/login")
public Map<String, Object> login(@RequestBody LoginDTO dto) {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(dto.username(), dto.password()));
return Map.of("token", jwtUtil.generate(auth.getName(), rolesOf(auth)));
}
注意:hasRole("ADMIN") 会自动补 ROLE_ 前缀,所以 authorities 里必须存 ROLE_ADMIN。存成 ADMIN 的话规则永远匹配不上,排查起来很费时间。
第四步:写 JWT 过滤器
继承 OncePerRequestFilter,读 Authorization: Bearer xxx,验签通过就塞进 SecurityContext:
public class JwtFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
FilterChain chain) throws ServletException, IOException {
String h = req.getHeader("Authorization");
if (h != null && h.startsWith("Bearer ")) {
try {
Claims claims = jwtUtil.parse(h.substring(7));
var auth = new UsernamePasswordAuthenticationToken(
claims.getSubject(), null,
((List<String>) claims.get("roles")).stream()
.map(SimpleGrantedAuthority::new).toList());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (JwtException e) {
SecurityContextHolder.clearContext(); // 过期或篡改,当匿名处理
}
}
chain.doFilter(req, resp);
}
}
第五步:配置 SecurityFilterChain
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 打开 @PreAuthorize
public class SecurityConfig {
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/api/auth/**", "/error").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.exceptionHandling(e -> e
.authenticationEntryPoint((q, s, ex) -> writeJson(s, 401, "未登录或 token 失效"))
.accessDeniedHandler((q, s, ex) -> writeJson(s, 403, "权限不足")))
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
注意:过滤器里抛的异常不会被 @RestControllerAdvice 捕获,因为它不在 Controller 调用链上。401/403 必须靠 authenticationEntryPoint 和 accessDeniedHandler 自己返回 JSON,否则前端收到的是 Spring 默认的 HTML 错误页。
第六步:方法级细粒度授权
@EnableMethodSecurity 之后就能在 Service 上写 SpEL:
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
public UserVO get(Long userId) { ... }
「只能改自己的帖子」这类数据级权限,要么在 SpEL 里判,要么在 service 里统一取 SecurityContextHolder.getContext().getAuthentication().getName() 比对。