news 2026/9/9 20:21:32

Spring MVC拦截器权限校验实战:从原理到七个常见坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring MVC拦截器权限校验实战:从原理到七个常见坑

我见过太多团队在权限校验上翻车,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里干干净净,没有一行权限判断,新人看一眼就知道业务逻辑在哪。这套东西,值得花一个下午从头捋一遍。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 20:17:45

从无标题到高质量博文:信息结构化与内容写作四步法

写博客的朋友应该都有一个共同的经历&#xff1a;新建文档时顺手命名成“无标题”&#xff0c;然后这个“无标题”就安静躺在文件夹里&#xff0c;一躺就是几个月。看似是个空文档&#xff0c;里面其实堆满了复制粘贴的链接、随手记的片段、突然冒出来的想法&#xff0c;杂乱得…

作者头像 李华
网站建设 2026/9/9 20:16:04

腾讯云CNB新人闯关:低成本用Credits玩转云服务器与Docker

先把一个真实场景放在前面&#xff1a;你花了一晚上在本地写好了一个带 Redis 和 Docker 的小项目&#xff0c;本地一切正常&#xff0c;但一想到要买一台云服务器去部署&#xff0c;就犹豫了。低配实例一年几百块不算贵&#xff0c;可你知道自己大概率只会在周末碰它&#xff…

作者头像 李华
网站建设 2026/9/9 20:15:57

【JAVA课程设计/毕业设计】企业知识产权规范化管理平台的设计与实现——基于前后端分离技术 融合SpringBoot与Vue技术的知识产权业务管理系统开发与落地【附源码、数据库、万字文档】

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围&#xff1a;&am…

作者头像 李华