4、用户认证模块(二):Spring Security配置、自定义过滤器实现Token验证、用户权限上下文持有、登录拦截与异常处理

好,咱们接着上一章往下聊。上一章我们把JWT的生成和解析搞定了,说白了就是有了「令牌」的铸造和验真技术。但光有令牌不行,你得让整个系统认这个令牌。怎么认?就得靠Spring Security这把大锁了。

我个人习惯,做前后端分离项目时,Spring Security的配置思路跟传统session模式完全不同。传统模式是「你访问页面,我检查session」,现在呢?是「你发请求,我检查token」。所以配置上要动刀子——把默认的表单登录、session管理全关掉,换成我们自己的过滤器。

4.1 Spring Security核心配置

先搭架子。Spring Security的配置类,我一般叫SecurityConfig,继承WebSecurityConfigurerAdapter。嗯,虽然新版本推荐用组件方式,但老版本项目里这个写法还是主流,咱们课程就用这个。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            // 关闭CSRF——前后端分离不需要
            .csrf().disable()
            // 无状态模式,不用session
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            // 配置接口权限
            .authorizeRequests()
            .antMatchers("/api/auth/login", "/api/auth/register").permitAll()
            .antMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
            .and()
            // 添加自定义过滤器
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            // 异常处理
            .exceptionHandling()
            .authenticationEntryPoint(new JwtAuthenticationEntryPoint())
            .accessDeniedHandler(new JwtAccessDeniedHandler());
    }
}

这里有几个关键点,我挨个说。

第一,关CSRF。 为什么?因为前后端分离用token验证,CSRF攻击的根基是session cookie,我们压根不用cookie,所以关了省事。我在项目中见过有人忘了关,结果POST请求全被拦截,排查了半天才发现是CSRF在捣鬼。

第二,无状态session。 STATELESS意味着Spring Security不会创建或使用HttpSession。每次请求都是独立的,全靠token里的信息来识别用户。这样你的后端服务可以水平扩展,不用操心session共享的问题。

第三,接口权限配置。 登录注册接口放行,管理员接口限制角色,其他接口必须认证。这个粒度可以根据业务调整,但原则是「白名单制」——只放行必须公开的接口,其余一律锁死。

我的小技巧: 接口路径设计时,把公开接口统一放在 /api/auth/** 下,这样配置起来一目了然。我曾经接手过一个项目,公开接口散落在各个模块里,配置权限时差点没把我逼疯。

4.2 自定义过滤器实现Token验证

核心来了。Spring Security的过滤器链里,我们要插入一个自定义过滤器,专门负责从请求头里提取token、验证、然后设置认证信息。

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        // 从请求头获取token
        String token = getTokenFromRequest(request);

        if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
            // 解析token获取用户名
            String username = jwtTokenProvider.getUsernameFromToken(token);

            // 加载用户信息
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            // 构建认证对象
            UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities());

            // 设置到SecurityContext中
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String getTokenFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

这个过滤器干了三件事:

  1. 提取token——从Authorization头里拿,格式是Bearer xxxxx
  2. 验证并解析——调用上一章写的JwtTokenProvider,检查签名和有效期
  3. 设置认证上下文——把解析出的用户信息塞进SecurityContextHolder

你想想看,为什么用OncePerRequestFilter?因为要确保每个请求只执行一次过滤逻辑。我记得有一次我用了普通的Filter,结果在转发请求时被执行了两次,token被重复解析,虽然没报错但性能白白浪费了。

注意: 过滤器里不要抛异常!如果token无效,直接放行让后面的异常处理器去处理。否则你会在过滤器里写一堆try-catch,代码会变得很难看。

4.3 用户权限上下文持有

认证信息设置到SecurityContextHolder后,怎么在业务代码里拿到当前用户?Spring Security提供了便捷方式:

// 在Controller或Service中获取当前用户
public class CurrentUserUtil {

    public static UserDetails getCurrentUser() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || !authentication.isAuthenticated()) {
            return null;
        }
        return (UserDetails) authentication.getPrincipal();
    }

    public static String getCurrentUsername() {
        UserDetails userDetails = getCurrentUser();
        return userDetails != null ? userDetails.getUsername() : null;
    }

    public static boolean hasRole(String role) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return authentication != null &&
               authentication.getAuthorities().stream()
                   .anyMatch(granted -> granted.getAuthority().equals("ROLE_" + role));
    }
}

这里有个坑,我踩过。在异步线程里获取SecurityContextHolder会返回null。为什么?因为SecurityContextHolder默认使用ThreadLocal存储,子线程拿不到父线程的上下文。解决方案有两种:

  • 使用SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
  • 或者手动把认证对象传递到子线程

我个人建议用第二种,显式传递更可控。第一种方式在复杂线程池场景下容易出问题。

4.4 登录拦截与异常处理

最后一块拼图——异常处理。用户token过期了怎么办?权限不够怎么办?不能直接返回500吧,得给前端一个友好的JSON响应。

我定义了两个处理器:

// 未登录或token过期
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 401);
        result.put("message", "未登录或token已过期,请重新登录");
        result.put("timestamp", System.currentTimeMillis());

        response.getWriter().write(new ObjectMapper().writeValueAsString(result));
    }
}

// 已登录但权限不足
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 403);
        result.put("message", "权限不足,请联系管理员");
        result.put("timestamp", System.currentTimeMillis());

        response.getWriter().write(new ObjectMapper().writeValueAsString(result));
    }
}

这两个处理器分别对应401和403。401是「你是谁?我不认识你」,403是「我知道你是谁,但你没权限干这事」。前端可以根据不同的状态码做不同的UI提示。

避坑指南: 我曾经在异常处理器里忘了设置response.setContentType("application/json;charset=UTF-8"),结果前端收到的是纯文本,解析JSON时报错。排查了半天才发现是ContentType的问题。嗯,这种小细节最容易忽略。

4.5 完整流程串讲

好了,我们把整个认证流程串起来看看:

步骤 操作 组件
1 用户发送登录请求 AuthController
2 验证用户名密码,生成JWT JwtTokenProvider
3 前端保存token,后续请求携带 前端拦截器
4 请求到达后端,被JwtAuthenticationFilter拦截 自定义过滤器
5 提取token,验证签名,解析用户信息 JwtTokenProvider
6 设置SecurityContextHolder 过滤器
7 请求到达Controller,获取当前用户 CurrentUserUtil
8 如果token无效或过期,返回401 JwtAuthenticationEntryPoint
9 如果权限不足,返回403 JwtAccessDeniedHandler

这个流程走通后,你的OA系统就有了一个完整的认证闭环。前端不用管session,后端不用存session,每个请求都是自包含的。说白了,这就是JWT认证的精髓——无状态、可扩展、跨域友好。

下一章我们会聊用户权限模块的另一个核心——角色与权限的数据库设计,以及如何用Spring Security的注解来控制接口访问。到时候我会分享一个我在实际项目中踩过的坑,关于@PreAuthorize@Secured的区别,保证让你少走弯路。