我见过太多团队在权限校验上翻车,Spring MVC的拦截器明明是最直接的那把工具,但很多人要么漏了注册,要么preHandle里写了一半就收工,要么把权限校验做完顺手把异常吞成200,线上出了事故还一脸茫然。今天不聊虚的,就聊Spring MVC拦截器怎么从零搭起来,怎么在生产环境里真正扛住权限校验这块活,以及我这些年踩过的7个坑。这几个坑是真真切切的Java开发高频问题,90%这个数字不严谨,但至少组里每次有人接手权限模块,基本绕不开其中一两个。
1. 权限校验为什么老写不对:先看基础模型
1.1 你可能正在用的几种错误姿势
先说我见过的几种典型写法,大家可以对号入座。
第一种,在Controller里复制粘贴校验逻辑。每个需要登录的接口开头都是“从request拿token -> 查用户 -> 判断isLogin -> 不通过就返回错误”。短期看逻辑没毛病,但系统一复杂,十几个Controller全是同一套代码,今天改个Redis key格式,明天换套Token生成器,就有改漏的地方。这不是权限校验的问题,是代码组织方式的问题。
第二种,把权限判断直接写进Service层。比如下单业务里,先调用checkLogin()再走核心逻辑。听起来合理,实际上一旦业务方法被内部调用,比如定时任务、MQ消费者直接调Service,那么登录态可能根本不存在,这时你会被迫写一堆兼容逻辑,Time Local越塞越乱。
第三种,用Filter一把梭。Filter本身在Servlet层面,拿不到Spring MVC的HandlerMethod,无法知道当前请求映射到哪个Controller方法,想做细粒度角色权限就得自己解析URL。这种方案在简单项目里能用,但一旦需要根据“方法上的注解”来控制权限,就非常别扭。
这些错误的共同点是:把“通用能力”和“业务代码”搅在了一起,最终权限校验沦为到处打补丁的泥巴。
1.2 过滤器与拦截器:80%面试题都在问这个
如果你准备过Java面试,一定刷过“拦截器和过滤器的区别”这道八股文。以前我觉得这就是面试官凑数的问题,直到我用错一次后在排查上耗费了一个下午,才意识到这道题的价值在于逼你理清技术边界。
过滤器Filter是Servlet规范的一部分,在请求进入DispatcherServlet之前执行,基于Servlet容器工作,对静态资源、Servlet、Spring MVC都生效。拦截器HandlerInterceptor是Spring MVC提供的能力,作用在DispatcherServlet将请求分发给对应HandlerMethod的环节上,天然能拿到处理器方法信息。
用一张表就能说清:
| 维度 | 过滤器 Filter | 拦截器 HandlerInterceptor |
|---|---|---|
| 规范归属 | Servlet规范 | Spring MVC |
| 是否可获取HandlerMethod | 否 | 是 |
| 执行时机 | 进入DispatcherServlet前 | Handler执行前后 |
| 适用场景 | 编码设置、跨域、简单日志、全局限流 | 登录校验、Token刷新、方法级权限校验 |
| 是否可自定义放行明细 | 比较粗 | 支持精确到URL pattern甚至方法注解 |
权限校验这种“需要知道当前要执行哪个方法”的场景,用拦截器是最贴合的。
1.3 为什么是拦截器,而不是AOP
也有人说,权限校验用AOP不行吗?@Aspect配合@annotation(),拦截到所有标了@RequireLogin的方法,不是也很清爽。
说实话AOP能做,但它在使用上有几个制约。第一,AOP切面拿不到HttpServletRequest和HttpServletResponse,除非用RequestContextHolder拿,虽然也不难,但比拦截器直接用方法参数多一层间接感。第二,AOP在Controller方法执行时才触发,如果这个Controller方法内部调用自身另一个方法,或者被其他service调用,切点表达式很容易误伤。第三,URL维度的白名单控制,AOP表达起来很笨,而拦截器的addPathPatterns/excludePathPatterns天然就是为URL匹配设计的。
所以生产最佳实践是:跨域、编码这类Servlet层面的通用处理放Filter;登录、角色、Token这类对URL敏感、对方法元信息敏感的处理放拦截器;真正跟业务耦合的权限校验逻辑(比如数据权限)再下沉到Service或AOP。三者边界清晰,才不会乱。
2. 从零搭一个最小可用的登录拦截器
2.1 实现HandlerInterceptor的第一步
Spring MVC的拦截器核心接口是HandlerInterceptor,里面三个方法,正常使用时只需要关注:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token = request.getHeader("Authorization"); // 这里写校验逻辑 return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { // Controller执行后、视图渲染前 } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 整个请求结束后,推荐在这里清理ThreadLocal } }preHandle返回true表示放行,返回false表示拦截,后续的Controller不再执行。很多人第一版写得稀烂,是因为不知道handler对象在Spring MVC里通常是HandlerMethod,所以我在第一行就做了一遍类型判断。
有一个细节值得注意:如果handler不是HandlerMethod,比如静态资源请求走了ResourceHttpRequestHandler,不要在这里面做权限判断直接放行,否则很可能把静态资源也拦掉,前后端联调时一脸懵。
2.2 注册配置与URL匹配的玄机
写好了拦截器类,不注册等于白写。在Spring Boot里最标准的做法是必须且仅有:
@Configuration public class WebConfig implements WebMvcConfigurer { private final LoginInterceptor loginInterceptor; public WebConfig(LoginInterceptor loginInterceptor) { this.loginInterceptor = loginInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/**") .excludePathPatterns("/login", "/register", "/error"); } }这里最容易踩的第一个坑,就是Spring Boot启动后拦截器完全没生效。原因往往是忘了加@Configuration,或者拦截器没注入Spring容器,new出来的对象当然不会被WebMvcConfigurer扫描到。我推荐把拦截器声明为@Component,通过构造器注入到WebConfig里,这样生命周期由Spring管理,也方便在拦截器里注入RedisTemplate、UserService等依赖。
addPathPatterns里的/**表示匹配所有层级路径,而/*只会匹配一级。这两个通配符的区别必须刻在脑子里,因为接下来很多诡异的放行、误拦,几乎都是从这里引发的。
2.3 用ThreadLocal安全传递用户信息
拦截器里校验通过后,Controller里怎么拿到当前用户?很多老代码的做法是写一个UserUtil,里面用static Map存储用户,这种写法并发一高就全乱套。正确姿势是ThreadLocal。
@Component public class UserContext { private static final ThreadLocal<UserInfo> HOLDER = new ThreadLocal<>(); public static void set(UserInfo user) { HOLDER.set(user); } public static UserInfo get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }在preHandle里校验成功后UserContext.set(user),在afterCompletion里UserContext.clear()。请求是一条线程走完的,ThreadLocal天然隔离,不串数据,用完删掉还能避免内存泄漏。
我见过有人图省事,只在preHandle里set,从不clear,结果在Tomcat线程池复用下,B请求居然能读到A请求的用户信息,这是什么级别的生产事故,体会过的人都知道有多酸爽。这个点也是我后面要单独说的“坑七”。
3. 生产级实战:从登录到细粒度权限
3.1 登录态到底应该放Session还是Redis
一个常见的业务问题:登录状态应该放Session还是Token+Redis?早期单体服务,Session够用;现在前后端分离,多实例部署,Session要想办法共享,分布式Session的复杂度一点都不低。所以Token+Redis成了主流。
Token选择用UUID或JWT都可以,我习惯用UUID,原因很简单——JWT携带的用户信息如果过期时间很长,被拦截后需要服务端主动踢人时很麻烦,而UUID存在Redis里可以随时删除,黑名单就是Redis的一条记录。
登录成功后的核心逻辑:
String token = UUID.randomUUID().toString().replace("-", ""); String redisKey = "session:user:" + token; stringRedisTemplate.opsForValue().set(redisKey, String.valueOf(user.getId()), 30, TimeUnit.MINUTES); // 返回给前端preHandle里的校验逻辑也就变得非常清晰:
String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { return reject(response, "未登录"); } String redisKey = "session:user:" + token; String userIdStr = stringRedisTemplate.opsForValue().get(redisKey); if (StringUtils.isBlank(userIdStr)) { return reject(response, "登录已过期"); } UserInfo user = userService.getById(Long.valueOf(userIdStr)); UserContext.set(user); return true;3.2 基于注解和反射做细粒度权限
权限校验的最高频需求不只是“是否登录”,还有“是否有某个角色”、“是否有某个权限码”。这时候最优雅的方案是自定义注解。
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface RequirePermission { String value(); }在目标Controller方法上标上注解:
@GetMapping("/order/refund") @RequirePermission("order:refund") public Result refund(@RequestParam Long orderId) { // ... return Result.success(); }拦截器里用反射读取HandlerMethod上的注解,做权限判断:
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } // 登录校验省略 RequirePermission requirePermission = handlerMethod.getMethodAnnotation(RequirePermission.class); if (requirePermission != null) { String required = requirePermission.value(); Set<String> userPermissions = loadUserPermissions(UserContext.get()); if (!userPermissions.contains(required)) { return reject(response, "无权限访问"); } } return true; }这套方案的好处是权限规则只跟“方法”绑定,新增接口时只需要加一行注解,其他人也看得懂,避免了在拦截器里堆一长串URL判断。JDK的反射和自定义注解,在这一刻是真的好用。如果你平时在用Lambda,其实内部可以进一步用Lambda表达式配合策略模式把多类权限规则写得更优雅,比如权限校验策略可以做成Map<String, Predicate >,代码会清爽很多。
3.3 白名单设计:该放行的一个别拦
白名单放哪里、怎么写,直接决定联调效率。我见过把静态资源、Swagger文档、登录、注册一股脑写进excludePathPatterns,结果上线后某个接口没被拦截,从日志一查发现路径被“通配放行”了,这种案例不在少数。
一个生产项目比较稳妥的放行清单大概是:
- /login、/register、/captcha 这类匿名接口
- /error、/favicon.ico 这类基础设施
- Swagger/接口文档相关:/doc.html、/webjars/、/v3/api-docs/
- 静态资源:/static/、/public/、/assets/**
如果你用Spring Boot 3.x,Swagger相关路径可能少,但健康检查有的会做/actuator/health,也要考虑放行。这些设计成配置项更合理:
auth: whitelist: - /login - /register - /doc.html - /webjars/** - /v3/api-docs/**然后在注册拦截器时读取配置,编译为拦截器的排除清单,这样运维在大促前调整白名单时不需要改代码、发版本。这块能力我建议任何一个上了规模的系统都做掉,否则一旦需求来了,临时要放行一个回调URL,你还要重新打包发布,那个等待时间足够被业务方骂醒。
4. 7个坑:90%的Java开发踩过
4.1 坑一:拦截器没生效,Spring Boot静默无视
现象:日志里完全看不到preHandle输出,接口直接进Controller。排查半天发现项目里有好几个配置类,有些类没被扫描到。最典型的就是拦截器类没有加@Component,或者WebConfig类没有加@Configuration,又或者WebConfig放在了启动类无法扫描的包路径之外。
解决办法是确认两点。第一,拦截器类交给Spring管理,能注入其他Bean。第二,WebMvcConfigurer实现类一定要被Spring扫描到。如果你不确定,直接在addInterceptors方法里打一行日志,启动时看是否打印,没打印就说明配置类都没进Spring容器。
4.2 坑二:URL pattern写成/*,把请求全拦了或者全放了
//add PathPatterns("/**") 和 ("/") 效果完全不同。/*只匹配一级路径,比如 /order能匹配,/order/detail不匹配。所以很多人把排除名单写成excludePathPatterns("/login/"),结果/login/verify这个接口就是进不了白名单,一直被拦。
我在排查过一次“为什么用户登录失败,密码接口放行不了”之后,就养成了习惯:所有排除路径全部用双星**,并且每配一条都做一次demo验证。别相信闭眼写出来的pattern,等联调撞上再处理已经很耗时间了。
.excludePathPatterns( "/login", "/login/**", "/register/**", "/doc.html", "/webjars/**" )这一段我加了/login和/login/两条,是因为如果登录接口本身还挂了一堆子路径,比如发送验证码也是/login/,单独写一个写漏了,后面又要查半天。
4.3 坑三:preHandle里直接return true,等于白写
有一种看似无害的写法,最能迷惑新人:
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { try { String token = request.getHeader("Authorization"); if (checkToken(token)) { return true; } return reject(response); } catch (Exception e) { return true; } }catch里面直接return true,意味着校验系统一抛异常,请求就安静放行了。“发现异常不如不拦”这个思路,放在对安全性要求不高的内部系统里勉强说得过去,但凡是涉及资金、订单、个人信息,这条就是妥妥的漏洞。正确做法是catch异常后记录日志,返回统一错误JSON,或者让异常继续抛出由全局异常处理器接管。绝对不能在catch块里默默return true。
4.4 坑四:静态资源和OPTIONS预检请求被拦截
前端联调时最常见的怪现象:接口调通了,但浏览器控制台一片跨域报错。排查到最后发现不是跨域配置问题,而是拦截器把OPTIONS请求拦了。浏览器在跨域请求前会先发一个OPTIONS预检,你如果直接requireLogin判断,预检请求没有Authorization头,就被打回403,真实请求永远发不出。
处理方式是:在preHandle最前面判断请求方法,如果是OPTIONS,直接放行,同时允许跨域配置生效。
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; }另外一个类似的坑是静态资源也被拦截器接管。如果你给addPathPatterns设置了/**,并且没有排除静态资源目录,像图片、CSS、JS都会被拦截。其实HandlerMethod判断可以帮你挡掉一部分,因为静态资源请求进来时handler不是HandlerMethod,但你还是要给静态资源路径留白名单,避免无谓日志和潜在误伤。
4.5 坑五:try-catch吞掉异常,接口返回200但页面报错
这个坑踩得最冤。有个同事在拦截器里做了Token解析,解析失败抛出异常后,他在preHandle里catch住返回了false,但他忘了写response状态和返回体,然后前端拿到的HTTP状态是200但响应体为空,业务上一直报“数据解析失败”。
拦截器里返回false之前,必须明确告诉接下去的所有节点“这个请求终止了”,最基础的做法是写入错误信息:
private boolean reject(HttpServletResponse response, String message) throws IOException { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"" + message + "\"}"); return false; }如果你是前后端分离项目,建议直接抛出业务异常,然后由@RestControllerAdvice统一加工JSON返回,拦截器里try-catch少写,靠全局异常处理器兜底,整体风格会统一很多。
4.6 坑六:RedisTemplate.increment()反序列化报错
权限校验和登录态经常用到Redis,其中最常踩的坑之一,是用RedisTemplate<String, Object>去做incr操作。比如做用户限流时,你写:
RedisTemplate<String, Object> redisTemplate; Long count = (Long) redisTemplate.opsForValue().increment("limit:" + userId);看着没问题,运行时报错,常见提示类似于“not integer or out of range”,或者反序列化失败。这是因为RedisTemplate默认的value序列化器是JdkSerializationRedisSerializer,存储在Redis里的是带类型和包名的序列化数据,incr命令在Redis服务端根本认不出二进制安全字符串,自然报错。
解决办法有两个。第一,直接使用StringRedisTemplate,key和value都是字符串,incr天然友好。第二,如果业务要求复杂对象保存,可以把RedisTemplate的value序列化器改成StringRedisSerializer或Jackson。
我自己在权限模块里只放userId、过期时间这些简单对象,所以项目里统一用StringRedisTemplate。
Long count = stringRedisTemplate.opsForValue().increment("login:limit:" + username, 1); if (count != null && count == 1) { stringRedisTemplate.expire("login:limit:" + username, 1, TimeUnit.MINUTES); }这段我专门写进去,是提醒你:用incr做限流时,第一次设置后一定要设置过期时间,否则key会永久存在,同一个人号被锁一年,到时候别说我没提醒。
4.7 坑七:ThreadLocal用户信息不清,内存泄漏和串号
前面讲了UserContext存用户很香,但香的前提是坚持在afterCompletion里clear。Tomcat的工作线程是复用的,线程执行完一个请求后被放回线程池,ThreadLocal里的值不会自动消失。下一个请求如果复用了这条线程,并且碰巧在新请求没有set之前去get,就会拿到上一个用户的残留信息。
更隐蔽的情况是,有些开发者把用户信息存到一个static的Map里,并发请求下用户A覆盖了用户B,导致权限错乱。解决思路还是ThreadLocal,并且保证在finally里remove。
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); }如果你在一个请求里又开启了异步子线程,切记ThreadLocal默认不跨线程传播,子线程里是拿不到用户信息的。我一般会在提交异步任务前把UserInfo取出来,作为参数传给子线程,而不是指望ThreadLocal自动延续。
5. 快速排查与避坑习惯
5.1 常见问题速查表
| 症状 | 可能原因 | 解决方向 |
|---|---|---|
| 拦截器完全没执行 | 未加@Component、未注册、配置类没被扫描 | 启动时日志确认,检查WebMvcConfigurer |
| 排除路径不生效 | pattern用了/*,只匹配一级 | 统一改成/** |
| 跨域失败 | OPTIONS被拦截 | preHandle开头放行OPTIONS |
| 返回200但业务报错 | preHandle里吞了异常且没有输出响应体 | 统一由全局异常处理返回JSON |
| Redis incr报错 | RedisTemplate序列化方式与Redis命令不兼容 | 使用StringRedisTemplate |
| 用户信息串号 | ThreadLocal未清除或static Map | 在afterCompletion里remove |
| 接口莫名被放行 | preHandle某分支直接return true | 检查异常分支,全部走统一拦截逻辑 |
5.2 顺带做好的两个加强:限流与异步接口
权限模块稳定后,我建议顺手把登录防刷也做了,就是前面提到过的incr限流。思路很简单,以用户名或IP为key,记录某个时间窗口内的失败次数,超过阈值直接拒绝登录一段时间,这比每次被爆破后翻日志强太多。
异步接口也要注意。Spring MVC从3.2开始支持Callable和DeferredResult,请求线程先走一遍preHandle返回true,等异步结果返回后,DispatcherServlet会再次派发,但拦截器不会从头再执行一次。如果你在异步任务里需要当前用户,仍然建议提前从UserContext取出并传递。
5.3 我个人的最后一点习惯
聊了这么多,最后分享一个我自己的习惯:权限校验的代码必须写成“测试看得到”的样子。拦截器逻辑也不复杂,但常见误区就是在某个分支写太爽,忘了考虑handler类型、OPTIONS请求、异常走向、ThreadLocal清理。我现在写拦截器时,一定先把三个方法框架写好,再往preHandle里填逻辑,填完再检查一遍所有return true是否有条件漏洞。
把登录校验、Token续期、角色权限、防刷限流都做好后,你会发现开发新接口变得异常轻松,Controller里干干净净,没有一行权限判断,新人看一眼就知道业务逻辑在哪。这套东西,值得花一个下午从头捋一遍。