第 7 章:权限模块
学习目标
- 集成 Spring Security + JWT 实现登录认证
- 实现 Access Token + Refresh Token 双 Token 机制
- 实现基于
@PreAuthorize的权限校验
一、Spring Security 配置
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // 开启 @PreAuthorize
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> {})
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // 登录放行
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/actuator/health/**").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception {
return cfg.getAuthenticationManager();
}
}关键点:
SessionCreationPolicy.STATELESS:无状态(前后端分离必选)requestMatchers("/api/auth/**").permitAll():登录接口放行- 其他接口必须
.authenticated()
⚠️ 坑 1:不要在生产环境关闭 Spring Security!很多同学觉得"我手动校验 token 了"就把 Security 关掉。结果忘了 CORS、CSRF、Header 处理,API 直接被各种诡异问题折磨。
二、JWT 工具类
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-expire:7200}")
private Long accessExpire;
@Value("${jwt.refresh-expire:2592000}")
private Long refreshExpire;
private SecretKey key;
@PostConstruct
public void init() {
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
throw new IllegalArgumentException("JWT 密钥至少 32 字节");
}
this.key = Keys.hmacShaKeyFor(bytes);
}
public String generateAccess(Long userId, String username, List<String> roles) {
long now = System.currentTimeMillis();
return Jwts.builder()
.issuer("taskflow")
.subject(String.valueOf(userId))
.claim("username", username)
.claim("roles", roles)
.claim("type", "access")
.issuedAt(new Date(now))
.expiration(new Date(now + accessExpire * 1000))
.signWith(key, Jwts.SIG.HS256)
.compact();
}
public String generateRefresh(Long userId) {
long now = System.currentTimeMillis();
return Jwts.builder()
.issuer("taskflow")
.subject(String.valueOf(userId))
.claim("type", "refresh")
.issuedAt(new Date(now))
.expiration(new Date(now + refreshExpire * 1000))
.id(UUID.randomUUID().toString())
.signWith(key, Jwts.SIG.HS256)
.compact();
}
public Claims parse(String token) {
return Jwts.parser()
.verifyWith(key)
.requireIssuer("taskflow")
.build()
.parseSignedClaims(token)
.getPayload();
}
}⚠️ 坑 2:
secret至少 32 字节(256 位),否则Keys.hmacShaKeyFor()抛弱密钥异常。生产环境不要写在配置里硬编码,要从环境变量JWT_SECRET读。
三、JWT 过滤器
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
FilterChain chain) throws ServletException, IOException {
// ① 提取 token
String token = extractToken(req);
if (token == null) {
chain.doFilter(req, resp);
return;
}
try {
// ② 解析 token
Claims claims = jwtUtil.parse(token);
if (!"access".equals(claims.get("type", String.class))) {
chain.doFilter(req, resp);
return;
}
// ③ 加载用户信息
String username = claims.get("username", String.class);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (ExpiredJwtException e) {
log.debug("Token 已过期:{}", e.getMessage());
} catch (JwtException e) {
log.warn("Token 无效:{}", e.getMessage());
}
chain.doFilter(req, resp);
}
private String extractToken(HttpServletRequest req) {
String bearer = req.getHeader("Authorization");
if (bearer != null && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}四、UserDetailsService
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserMapper userMapper;
private final RoleMapper roleMapper;
private final PermissionService permissionService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userMapper.selectByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在: " + username);
}
if (user.getStatus() != 1) {
throw new UsernameNotFoundException("用户已被禁用");
}
// 角色
List<Role> roles = roleMapper.selectByUserId(user.getId());
List<GrantedAuthority> authorities = new ArrayList<>();
for (Role role : roles) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getCode()));
}
// 按钮权限
Set<String> permissions = permissionService.getUserPermissions(user.getId());
for (String perm : permissions) {
authorities.add(new SimpleGrantedAuthority(perm));
}
return new CustomUserDetails(
user.getId(),
user.getUsername(),
user.getPassword(),
user.getStatus() == 1,
authorities
);
}
}五、登录接口
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Tag(name = "认证管理")
public class AuthController {
private final UserService userService;
private final AuthenticationManager authenticationManager;
@PostMapping("/login")
public Result<LoginVO> login(@RequestBody @Valid LoginDTO dto) {
// ① 触发 Spring Security 校验
Authentication auth;
try {
auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
dto.getUsername(), dto.getPassword()));
} catch (BadCredentialsException e) {
throw new BusinessException(ErrorCode.USER_PASSWORD_ERROR);
}
// ② 取用户信息
CustomUserDetails userDetails = (CustomUserDetails) auth.getPrincipal();
User user = userService.getById(userDetails.getUserId());
// ③ 更新最后登录信息
user.setLastLoginIp(SecurityUtils.getIp());
userService.updateById(user);
// ④ 签发双 Token
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList();
String accessToken = jwtUtil.generateAccess(user.getId(), user.getUsername(), roles);
String refreshToken = jwtUtil.generateRefresh(user.getId());
// ⑤ 构造响应
LoginVO vo = new LoginVO();
vo.setAccessToken(accessToken);
vo.setRefreshToken(refreshToken);
vo.setExpiresIn(7200L);
vo.setUser(userService.getVOById(user.getId()));
return Result.ok(vo);
}
@PostMapping("/refresh")
public Result<LoginVO> refresh(@RequestHeader("Refresh-Token") String refreshToken) {
return Result.ok(userService.refreshToken(refreshToken));
}
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest req) {
// 把当前 access token 加入黑名单
String token = extractToken(req);
tokenBlacklistService.add(token);
return Result.ok();
}
}⚠️ 坑 3:登出不能靠前端删 Token,因为 Access Token 在过期前都有效。必须服务端主动失效——把 token 加入黑名单(Redis),每次请求先查黑名单。
六、Token 黑名单
@Service
@RequiredArgsConstructor
public class TokenBlacklistService {
private final RedisTemplate<String, Object> redis;
public void add(String token) {
// 算出剩余有效期,刚好覆盖到 Token 自然过期
Claims claims = jwtUtil.parse(token);
long ttl = claims.getExpiration().getTime() - System.currentTimeMillis();
if (ttl > 0) {
redis.opsForValue().set("blacklist:" + token, "1", Duration.ofMillis(ttl));
}
}
public boolean isBlacklisted(String token) {
return Boolean.TRUE.equals(redis.hasKey("blacklist:" + token));
}
}七、Refresh Token 续签
Refresh Token 的作用是:Access 过期后,用户无感换新。
public LoginVO refreshToken(String refreshToken) {
// ① 验证 refresh token
Claims claims = jwtUtil.parse(refreshToken);
if (!"refresh".equals(claims.get("type", String.class))) {
throw new BusinessException(ErrorCode.TOKEN_INVALID);
}
Long userId = Long.valueOf(claims.getSubject());
User user = userService.getById(userId);
Assert.notNull(user, "用户不存在");
// ② 检查是否在黑名单
if (refreshTokenService.isRevoked(refreshToken)) {
throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
}
// ③ 重新签发
List<String> roles = getUserRoleCodes(userId);
String newAccess = jwtUtil.generateAccess(userId, user.getUsername(), roles);
String newRefresh = jwtUtil.generateRefresh(userId);
// ④ 旧的 refresh 进黑名单(轮换)
refreshTokenService.revoke(refreshToken);
refreshTokenService.save(userId, newRefresh);
LoginVO vo = new LoginVO();
vo.setAccessToken(newAccess);
vo.setRefreshToken(newRefresh);
vo.setExpiresIn(7200L);
vo.setUser(userService.getVOById(userId));
return vo;
}Token 轮换(refresh rotation)的核心思想:每次用 refresh 换新 access,旧的 refresh 立即作废。这样即使 refresh 被截获,也只能用一次。
八、自定义权限注解
封装 @PreAuthorize 让业务代码更清晰:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAuthority('{value}')")
public @interface RequiresPermission {
String value();
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('{value}')")
public @interface RequiresRole {
String value();
}⚠️ 注意上面的 @PreAuthorize 字符串里的 '{value}' 不是合法的 SpEL!正确写法应该是手写包装注解 + AOP,或者直接用 @PreAuthorize("hasAuthority('xxx')")。简化版用 @PreAuthorize 内联表达式即可:
@PreAuthorize("hasAuthority('user:create')")
@PostMapping
public Result<Long> create(@RequestBody UserCreateDTO dto) { ... }
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) { ... }九、未登录与无权限处理
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest req, HttpServletResponse resp,
AuthenticationException ex) throws IOException {
resp.setStatus(401);
resp.setContentType("application/json;charset=UTF-8");
Result<Void> result = Result.fail(401, "未登录或登录已过期");
resp.getWriter().write(JSON.toJSONString(result));
}
}
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest req, HttpServletResponse resp,
AccessDeniedException ex) throws IOException {
resp.setStatus(403);
resp.setContentType("application/json;charset=UTF-8");
Result<Void> result = Result.fail(403, "权限不足");
resp.getWriter().write(JSON.toJSONString(result));
}
}注册到 SecurityConfig:
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authEntryPoint)
.accessDeniedHandler(accessDeniedHandler))十、SecurityUtils 工具类
public final class SecurityUtils {
private SecurityUtils() {}
public static CustomUserDetails getCurrentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
Object principal = auth.getPrincipal();
if (principal instanceof CustomUserDetails user) {
return user;
}
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
public static Long getCurrentUserId() {
return getCurrentUser().getUserId();
}
public static String getCurrentUsername() {
return getCurrentUser().getUsername();
}
}⚠️ 坑 4:
SecurityContextHolder是基于 ThreadLocal 的!在@Async方法或新线程里访问getCurrentUser()会拿到 null。必须显式把上下文传到子线程,或者从token重新解析。
十一、本章小结
| 要点 | 关键 |
|---|---|
| 配置 | STATELESS + 放行登录接口 |
| Token | Access 2h + Refresh 30d 轮换 |
| 黑名单 | 登出时加入 Redis,过期自动失效 |
| 权限注解 | @PreAuthorize("hasAuthority('xxx')") |
| SecurityUtils | ThreadLocal 存储,子线程需重新解析 |
| 异常处理 | 401 未登录 / 403 权限不足 |
动手练习
- 登录:写一个
/api/auth/login接口,用 admin/admin123 测通 - 权限拦截:加
@PreAuthorize("hasAuthority('user:create')")后,普通用户访问被拦 - Refresh:模拟 access 过期,用 refresh 调用
/refresh拿到新 token
下一章:第 8 章:角色模块 →