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;
}
}
这个过滤器干了三件事:
- 提取token——从
Authorization头里拿,格式是Bearer xxxxx - 验证并解析——调用上一章写的
JwtTokenProvider,检查签名和有效期 - 设置认证上下文——把解析出的用户信息塞进
SecurityContextHolder
你想想看,为什么用OncePerRequestFilter?因为要确保每个请求只执行一次过滤逻辑。我记得有一次我用了普通的Filter,结果在转发请求时被执行了两次,token被重复解析,虽然没报错但性能白白浪费了。
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的区别,保证让你少走弯路。