1. 从一次线上告警说起:为什么要自己写处理程序
去年冬天的一个凌晨,我被一连串告警短信炸醒。某业务系统的接口在没有任何代码变更的情况下,突然开始大量返回 401,紧接着是 403,最后连正常的健康检查都开始超时。排查了整整两个小时,最后定位到的原因让人哭笑不得:一个上游服务在调用我们的接口时,携带了一个格式完全合法、但业务上早已废弃的令牌字段,而我们的安全框架默认把它当成了“需要重新认证”的信号,触发了一连串的连锁反应。
那次事故之后,我花了大概两周时间,把整个系统的安全处理链路从头到尾梳理了一遍,最终决定不再依赖框架的默认行为,而是自己动手写一套自定义处理程序。这篇文章就是那次重构的完整总结,包含了我踩过的坑、做过的取舍,以及最终落地的方案。
所谓“自定义处理程序”,在安全领域里通常指的是认证入口点、访问拒绝处理器、认证成功/失败处理器这几类组件的自定义实现。它们处在整个安全过滤链的关键节点上,负责在认证失败、权限不足、登录成功等时刻决定系统该如何响应。框架自带的默认实现往往只考虑了最通用的场景,一旦你的业务有特殊需求——比如需要返回统一的 JSON 结构、需要记录审计日志、需要做灰度降级——默认实现就会显得捉襟见肘。
这篇文章适合两类人看:一类是正在被安全框架默认行为折磨、想改又不敢改的开发者;另一类是想搞清楚安全过滤链底层运转逻辑、希望把控制权握在自己手里的工程师。我会从设计思路讲到具体实现,再到线上排查,尽量把每个决策背后的“为什么”说清楚。
2. 整体设计思路:先想清楚控制权该放在哪一层
2.1 默认处理程序到底做了什么
在动手写自己的实现之前,必须先搞清楚框架默认帮我们做了什么。大多数安全框架的默认处理程序逻辑其实非常朴素:认证入口点通常就是往响应头里塞一个WWW-Authenticate,然后返回 401;访问拒绝处理器就是直接返回 403,页面内容可能就是一个白板错误页;认证成功处理器更简单,什么都不做,直接放行到下一个过滤器。
这种设计在单体应用、前后端不分离的年代是完全够用的。浏览器收到 401 会自动弹出认证框,收到 403 会显示一个错误页,用户能理解发生了什么。但现在的情况完全不同了:前端是独立部署的单页应用,后端是纯 API 服务,客户端可能是小程序、移动端 App、甚至是第三方系统。这时候再返回一个 HTML 错误页,前端拿到之后根本没法解析,只能显示一个“未知错误”。
我见过太多项目在这种情况下选择在 Controller 层做全局异常处理,试图用@ExceptionHandler把安全框架抛出的异常拦截下来。这个思路本身没错,但有个致命问题:安全过滤链的执行时机在 DispatcherServlet 之前。也就是说,认证失败和权限不足的异常根本不会走到 Spring MVC 的异常处理机制里,你的@ExceptionHandler压根不会被触发。这就是为什么很多人写了全局异常处理,却发现 401 和 403 还是返回框架默认格式的原因。
2.2 自定义处理程序的三个关键节点
理解了上面的问题,自定义处理程序的价值就清晰了。它让我们能够在安全过滤链内部、在异常被抛出之前,就接管响应逻辑。具体来说,有三个节点是最值得自定义的:
第一个是认证入口点,对应AuthenticationEntryPoint这类接口。它在用户未认证或认证失败时被调用,是返回 401 的地方。自定义它的核心目的是统一响应格式,同时可以在这里做一些额外的事情,比如记录未认证访问的来源 IP、判断是否需要触发风控、决定是否返回更详细的错误码。
第二个是访问拒绝处理器,对应AccessDeniedHandler这类接口。它在用户已认证但权限不足时被调用,返回 403。这里最典型的自定义需求是区分“真的没权限”和“认证信息过期”,因为这两种情况对前端的处理逻辑完全不同——前者应该提示用户联系管理员,后者应该跳转登录页。
第三个是认证成功与失败处理器。成功处理器在认证通过后被调用,可以用来记录登录日志、更新最后登录时间、发放额外的令牌。失败处理器则在认证过程中抛出异常时被调用,可以在这里做登录失败次数统计、账户锁定判断、验证码触发等逻辑。
2.3 方案选型:继承默认实现还是完全重写
确定了要自定义哪些节点之后,下一个问题是:继承框架的默认实现然后覆写部分方法,还是完全从零实现接口?
我的建议是优先继承默认实现。原因有三点。第一,默认实现里往往包含了一些容易被忽略的细节,比如响应头的设置、字符编码的处理、缓存控制头,这些如果自己从零写很容易漏掉。第二,框架版本升级时,默认实现可能会增加新的逻辑,继承的方式能自动获得这些改进。第三,完全重写意味着你要对接口契约有百分之百的理解,一旦理解有偏差,可能出现安全漏洞。
但有一种情况必须完全重写:当默认实现的某个行为与你的业务需求根本冲突,且无法通过覆写单个方法解决时。比如某些框架的默认入口点会强制跳转到登录页,而你需要的是返回 JSON,这种情况下继承反而会带来麻烦,不如直接实现接口来得干净。
3. 核心细节拆解:响应格式、状态码与异常映射
3.1 统一响应体的设计
自定义处理程序最核心的产出就是响应体。我见过很多团队在这里各写各的,认证入口点返回一种格式,访问拒绝处理器返回另一种格式,前端不得不写两套解析逻辑。正确的做法是定义一个统一的响应结构,所有处理程序都复用它。
我通常会用这样一个结构:一个业务状态码、一个给用户看的消息、一个给开发排查用的详细描述、一个时间戳。业务状态码不要直接用 HTTP 状态码,而是自己定义一套,比如 1001 表示未认证、1002 表示令牌过期、1003 表示权限不足。这样前端可以根据业务码做精确判断,而 HTTP 状态码只用来表达传输层的语义。
这里有个细节值得展开:HTTP 状态码到底该不该用 401 和 403。有些团队为了“统一”,把所有错误都返回 200,然后在响应体里放业务码。这种做法我不推荐,因为它破坏了 HTTP 的语义,会让网关、监控、日志系统无法正确识别错误。我的做法是 HTTP 状态码保持语义正确,401 就是 401,403 就是 403,业务码放在响应体里做更细的区分。
3.2 认证失败与权限不足的边界
这两个概念经常被混淆,但在自定义处理程序里必须分清楚。认证失败意味着“我不知道你是谁”,权限不足意味着“我知道你是谁,但你不能做这件事”。前者应该引导用户去登录,后者应该提示用户没有权限。
实际开发中最容易出问题的是令牌过期的情况。令牌过期本质上属于认证失败,但很多框架会把它归类到权限不足,因为令牌本身是存在的,只是失效了。如果你不做特殊处理,用户会看到“权限不足”的提示,然后一脸茫然地去找管理员,而实际上他只需要重新登录。
我的处理方式是在认证入口点里判断异常类型。如果是令牌过期相关的异常,返回一个特定的业务码,前端收到这个码就自动跳转登录页并带上回跳地址。如果是其他认证失败,返回通用业务码,前端提示用户重新登录。这个判断逻辑需要你对框架抛出的异常体系有清晰的了解,建议在开发阶段把所有可能的异常都打印出来看一遍。
3.3 异常到响应的映射表
为了让映射逻辑清晰可维护,我习惯维护一张异常到响应的映射表。这张表在代码里可以是一个 Map,也可以是一段 switch 逻辑。下面是我在实际项目中用到的一张表,供参考:
| 异常类型 | HTTP 状态码 | 业务码 | 前端处理建议 |
|---|---|---|---|
| 凭证无效 | 401 | 1001 | 提示重新登录 |
| 令牌过期 | 401 | 1002 | 自动跳转登录页 |
| 账户锁定 | 401 | 1003 | 提示联系管理员 |
| 权限不足 | 403 | 2001 | 提示无权限 |
| 资源不存在 | 403 | 2002 | 提示资源已失效 |
| 其他未知 | 500 | 9999 | 提示稍后重试 |
这张表的价值在于,它把“后端怎么返回”和“前端怎么处理”这两件事对齐了。我在做这个重构的时候,专门拉着前端同学一起过了一遍这张表,确认每个业务码对应的交互逻辑,避免出现后端返回了码但前端不知道怎么处理的情况。
提示:映射表里的业务码一旦确定就不要随意改动,因为前端会硬编码这些码。如果确实需要调整,一定要走版本兼容流程,比如新增码而不是修改已有码的含义。
4. 实操落地:从零写一个可用的自定义处理程序
4.1 认证入口点的完整实现
先来看认证入口点的实现。假设我们用的是某主流安全框架,它提供了AuthenticationEntryPoint接口,只有一个commence方法。我的实现大致是这样的思路:
public class CustomAuthEntryPoint implements AuthenticationEntryPoint { private final ObjectMapper objectMapper; private final AuditLogger auditLogger; @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // 第一步:判断异常类型,决定业务码 String bizCode = resolveBizCode(authException); // 第二步:记录审计日志 auditLogger.recordAuthFailure(request, authException); // 第三步:构建统一响应体 ApiResponse body = ApiResponse.fail(bizCode, resolveMessage(bizCode)); // 第四步:设置响应头和状态码 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.setCharacterEncoding("UTF-8"); // 第五步:写出响应 response.getWriter().write(objectMapper.writeValueAsString(body)); } private String resolveBizCode(AuthenticationException ex) { if (ex instanceof CredentialsExpiredException) { return "1002"; } if (ex instanceof LockedException) { return "1003"; } return "1001"; } }这段代码里有几个细节值得说明。第一,response.setCharacterEncoding必须显式设置,否则中文消息可能乱码。第二,objectMapper建议注入框架管理的实例,而不是自己 new 一个,这样能复用全局的序列化配置。第三,审计日志的记录要放在写响应之前,避免因为写响应失败导致日志丢失。
还有一个容易被忽略的点:响应头的缓存控制。认证失败的响应不应该被任何中间层缓存,否则可能出现一个用户认证失败后,后续所有用户都收到同样的失败响应。我通常会在响应头里加上Cache-Control: no-store和Pragma: no-cache。
4.2 访问拒绝处理器的实现要点
访问拒绝处理器的结构和认证入口点类似,但有几个关键差异。首先是状态码用 403 而不是 401。其次是异常类型不同,这里拿到的是AccessDeniedException。最重要的是,这里需要区分“认证信息缺失导致的拒绝”和“真正的权限不足”。
为什么会有这个区分?因为在某些框架里,如果用户未认证就访问受保护资源,抛出的可能是AccessDeniedException而不是AuthenticationException。如果你不加区分地返回 403,用户会以为自己没权限,而实际上他只是没登录。
我的处理方式是在访问拒绝处理器里先检查当前的安全上下文。如果上下文中没有认证信息,说明是未认证访问,应该委托给认证入口点处理;如果有认证信息,才是真正的权限不足。这个判断逻辑看起来简单,但如果没有处理,会导致前端在用户未登录时收到 403 而不是 401,交互逻辑就全乱了。
public class CustomAccessDeniedHandler implements AccessDeniedHandler { private final AuthenticationEntryPoint entryPoint; @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) throws IOException { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); // 未认证的情况,交给入口点处理 if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { entryPoint.commence(request, response, new InsufficientAuthenticationException("未认证")); return; } // 真正的权限不足 ApiResponse body = ApiResponse.fail("2001", "权限不足"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(body)); } }4.3 认证成功与失败处理器的配合
认证成功处理器通常用来做登录后的收尾工作。我在项目里用它做了三件事:更新用户的最后登录时间和 IP、记录登录成功的审计日志、检查是否需要强制修改密码。这里有个性能上的注意点:成功处理器是在认证流程中同步执行的,如果里面做了耗时的操作(比如远程调用),会拖慢整个登录响应。我的做法是把非关键操作异步化,用消息队列或者线程池处理,只保留必须同步的逻辑。
认证失败处理器则要小心信息泄露。默认的失败消息往往会告诉用户“用户名不存在”或者“密码错误”,这给了攻击者枚举用户名的机会。我的做法是统一返回“用户名或密码错误”,不区分具体原因。但这里有个权衡:用户体验会变差,用户不知道自己到底是用户名错了还是密码错了。我的折中方案是,在连续失败达到一定次数后,才启用统一消息,前几次仍然给出具体提示。这个策略需要配合失败次数统计,通常用缓存来实现。
4.4 注册到安全配置的完整流程
写完这几个处理程序之后,需要把它们注册到安全配置里。不同的框架注册方式不同,但核心思路是一致的:在配置类里把自定义实现设置进去。
@Configuration public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .exceptionHandling(ex -> ex .authenticationEntryPoint(customAuthEntryPoint()) .accessDeniedHandler(customAccessDeniedHandler()) ) .formLogin(form -> form .successHandler(customSuccessHandler()) .failureHandler(customFailureHandler()) ); return http.build(); } }这里有个坑我踩过:处理程序的 Bean 循环依赖。访问拒绝处理器里注入了认证入口点,如果两个都是通过方法注入的 Bean,可能出现循环依赖。我的解决办法是把认证入口点单独定义成一个 Bean,访问拒绝处理器通过构造器注入它,而不是在方法内部调用。这样 Spring 能正确解析依赖顺序。
5. 常见问题与排查技巧实录
5.1 自定义处理程序不生效的排查思路
这是最常见的问题,写了自定义实现但发现根本没被调用。排查思路我总结成了一条链路,按顺序检查:
第一,确认配置真的生效了。有时候项目里有多个安全配置类,或者有自动配置覆盖了你的配置。可以在处理程序里打个断点或者加一行日志,看是否被触发。
第二,确认异常真的被抛到了处理程序这一层。有些异常在更早的过滤器里就被处理掉了,根本走不到入口点。比如某些框架的 CORS 过滤器会先处理跨域请求,如果跨域配置有问题,请求可能在 CORS 阶段就被拒绝了。
第三,确认没有其他处理程序覆盖。如果项目里引入了某些安全相关的 starter,它们可能注册了自己的处理程序。检查方式是打印出所有的AuthenticationEntryPointBean,看有几个。
第四,确认请求路径真的匹配了安全规则。如果请求的路径不在安全过滤链的匹配范围内,处理程序自然不会生效。这个用框架的调试日志能看出来。
5.2 响应乱码与格式错乱的解决
响应乱码通常有两个原因:字符编码没设置,或者响应已经被写过了一部分。前者好解决,显式设置setCharacterEncoding("UTF-8")即可。后者比较麻烦,如果前面的过滤器已经往 response 里写了内容,你再写就会追加而不是覆盖,导致 JSON 格式错乱。
判断响应是否已被写入,可以用response.isCommitted()方法。如果已经 committed,就不能再改了,只能记录日志告警。预防的办法是确保自定义处理程序在过滤链中的顺序足够靠前,不给其他组件写响应的机会。
还有一种格式错乱是 Content-Type 设置不对。有些框架默认返回text/html,你写 JSON 进去,前端按 HTML 解析就乱了。所以setContentType("application/json;charset=UTF-8")这行不能省。
5.3 性能与并发场景下的注意事项
自定义处理程序是在每个请求的认证环节执行的,它的性能直接影响整个系统的吞吐。我做过一次压测,发现处理程序里一个不起眼的日志打印,在高并发下能占到整个认证耗时的 30%。所以有几个原则:
- 处理程序里不要做远程调用,包括配置中心、用户中心、权限中心的调用
- 日志打印要控制级别,生产环境不要打 debug 级别的详细堆栈
- 对象序列化用复用的 ObjectMapper,不要每次 new
- 如果要做失败次数统计,用本地缓存而不是远程缓存,且要设置合理的过期时间
还有一个并发相关的坑:SecurityContext 的清理。在某些框架里,处理程序执行完之后,SecurityContext 可能没有被正确清理,导致线程复用时出现认证信息串号。这个问题极其隐蔽,表现为偶发的权限判断错误。解决办法是在处理程序里显式调用SecurityContextHolder.clearContext(),或者确认框架的过滤器链里有清理逻辑。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 处理程序不生效 | 配置未加载 | 打断点看是否进入 | 检查配置类是否被扫描 |
| 返回 401 但格式不对 | 编码未设置 | 看响应头 Content-Type | 显式设置编码和类型 |
| 中文乱码 | 字符集问题 | 看响应字节 | 设置 UTF-8 |
| 偶发权限错误 | 上下文未清理 | 看是否线程复用 | 显式清理上下文 |
| 响应格式错乱 | 响应已提交 | 检查 isCommitted | 调整过滤器顺序 |
| 登录变慢 | 处理程序耗时 | 加耗时日志 | 异步化非关键逻辑 |
提示:这张表里的“偶发权限错误”是最难排查的,因为它不可复现。我的经验是,只要看到权限相关的偶发问题,第一反应就是检查 SecurityContext 的清理逻辑,十有八九是这个问题。
6. 一些踩坑之后的经验之谈
写自定义处理程序这件事,技术难度其实不高,难的是把边界情况想全。我前后重构过三次,第一次只改了响应格式,结果发现令牌过期和权限不足分不清;第二次加了异常映射,结果发现未认证访问走到了访问拒绝处理器;第三次才把整个链路理顺。
如果让我给正在做这件事的人一个建议,那就是:先把所有可能的异常路径列出来,再动手写代码。具体做法是,在开发环境把所有安全相关的异常都触发一遍,用日志把异常类型、触发时机、当前的安全上下文状态都打出来。这张“异常地图”画出来之后,处理程序的逻辑自然就清晰了。
另外,自定义处理程序不是越多越好。有些需求其实用过滤器或者拦截器就能解决,没必要动安全过滤链。比如单纯的请求日志记录,用过滤器就够了;只有涉及到认证和授权的响应决策,才需要自定义处理程序。判断标准很简单:这个逻辑需不需要知道“认证结果”和“权限判断结果”?需要,就用处理程序;不需要,就用过滤器。
最后分享一个我在实际项目中用到的小技巧:给每个处理程序加一个“干跑模式”开关。开启后,处理程序只记录日志不实际修改响应,这样可以在生产环境灰度验证新逻辑,确认无误后再真正启用。这个开关用配置中心控制,出问题能秒级回滚。这个做法帮我在一次线上变更中避免了一次大规模故障,当时新逻辑在处理某种特殊令牌时会有问题,干跑模式提前发现了。