Skip to content
第 7 章 ⏱ 12 分钟阅读

第 7 章:权限模块 ​

学习目标 ​

  • 集成 Spring Security + JWT 实现登录认证
  • 实现 Access Token + Refresh Token 双 Token 机制
  • 实现基于 @PreAuthorize 的权限校验

一、Spring Security 配置 ​

java
@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 工具类 ​

java
@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 过滤器 ​

java
@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 ​

java
@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
        );
    }
}

五、登录接口 ​

java
@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 黑名单 ​

java
@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 过期后,用户无感换新。

java
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 让业务代码更清晰:

java
@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 内联表达式即可:

java
@PreAuthorize("hasAuthority('user:create')")
@PostMapping
public Result<Long> create(@RequestBody UserCreateDTO dto) { ... }

@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) { ... }

九、未登录与无权限处理 ​

java
@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:

java
.exceptionHandling(ex -> ex
        .authenticationEntryPoint(authEntryPoint)
        .accessDeniedHandler(accessDeniedHandler))

十、SecurityUtils 工具类 ​

java
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 + 放行登录接口
TokenAccess 2h + Refresh 30d 轮换
黑名单登出时加入 Redis,过期自动失效
权限注解@PreAuthorize("hasAuthority('xxx')")
SecurityUtilsThreadLocal 存储,子线程需重新解析
异常处理401 未登录 / 403 权限不足

动手练习 ​

  1. 登录:写一个 /api/auth/login 接口,用 admin/admin123 测通
  2. 权限拦截:加 @PreAuthorize("hasAuthority('user:create')") 后,普通用户访问被拦
  3. Refresh:模拟 access 过期,用 refresh 调用 /refresh 拿到新 token

下一章:第 8 章:角色模块 →

本站基于 VitePress 构建 · 由 StackHub 团队维护