简介本资源是一套面向Java后端开发者与Spring Security初学者的实战型源码学习包聚焦自定义认证过滤器的核心实现原理与工程集成方法帮助读者深入理解Spring Security认证流程扩展机制。压缩包共78个文件涵盖14个Java源码文件含核心过滤器、配置类与安全配置、15个XML配置文件Spring上下文与依赖管理、17个JSON测试用例与元数据文件以及YML、Properties等配置文件整体仅85KB轻量易读。已有141人下载学习适合希望从零掌握JWT或表单登录等场景下自定义AuthenticationFilter开发的中初级开发者。资源结构清晰包含标准Maven项目骨架、src/main/java完整源码目录、test单元测试及HELP.md说明文档附带mvnw脚本与.gitignore等工程规范文件开箱即可导入IDE运行调试是理解Spring Security底层过滤器链定制的优质入门范例。1. SpringSecurity 自定义认证过滤器不是加个Bean就能跑通的黑匣子而是必须亲手拧紧每颗螺丝的认证流水线你写好了登录接口、配好了UserDetailsService、甚至把AuthenticationManagerBuilder都塞满了自定义逻辑——结果一发 POST 请求过去SpringSecurity 仿佛没看见直接跳转到/login?error或者更玄学的是请求压根没进你的过滤器连断点都打不上去。这不是配置漏了是根本没摸清 SpringSecurity 的过滤器链执行机制。这个源码包不是教你怎么“用” SpringSecurity而是带你把UsernamePasswordAuthenticationFilter拆开、重写、再焊回去——从attemptAuthentication()的参数解析到successfulAuthentication()的 token 注入再到unsuccessfulAuthentication()的错误透传每一步都暴露在你眼皮底下。适合正在做单点登录对接、JWT 无状态认证迁移、或需要集成短信/生物识别等非标准凭证的后端开发者。它不讲原理图只给你能mvn clean install跑起来的完整模块含调试断点位置、日志埋点、以及三类典型失败场景的复现路径。2. 为什么必须重写认证过滤器SpringSecurity 默认链的四个硬约束与破局点SpringSecurity 的默认认证流程像一条预设轨道UsernamePasswordAuthenticationFilter→DaoAuthenticationProvider→UserDetailsServiceImpl。表面看很顺但一旦你遇到这些场景轨道就崩了——用户凭证不是用户名密码而是设备指纹临时令牌认证需调用第三方 HTTP 接口如风控中台且要求超时控制在 800ms 内登录成功后要同步刷新 Redis 中的会话白名单并记录设备 ID 到审计表错误响应不能返回401 Unauthorized而要按业务协议返回{code: 1003, msg: 验证码已失效}。默认链做不到因为它的设计哲学是「认证即授权」所有扩展点都卡在AuthenticationProvider层而这一层只负责“判真假”不负责“怎么拿凭证”“怎么回错误”。真正的破局点在于替换掉链条最前端的凭证捕获器——也就是继承AbstractAuthenticationProcessingFilter重写整个doFilter()的生命周期。本项目源码正是这样做的它没有动AuthenticationManager也没有改SecurityContextPersistenceFilter而是用一个完全自定义的CustomLoginAuthenticationFilter替换了默认的UsernamePasswordAuthenticationFilter并确保它在FILTER_SECURITY_INTERCEPTOR之前、ExceptionTranslationFilter之后精准插入。2.1 过滤器链插入时机addFilterBefore()的三个致命参数陷阱SpringSecurity 的过滤器链是ListFilter插入位置错一位整个认证就失效。本项目在SecurityConfig.java中使用Override protected void configure(HttpSecurity http) throws Exception { http .addFilterBefore(customLoginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) // 其他配置... }注意这里三个关键点目标类必须是UsernamePasswordAuthenticationFilter.class而非AbstractAuthenticationProcessingFilter.class后者是抽象基类SpringSecurity 实际注册的是具体子类实例若指定父类addFilterBefore()会找不到插入锚点导致你的过滤器被忽略customLoginAuthenticationFilter()必须是Bean方法且方法内不能 new 实例因为 SpringSecurity 需要管理其生命周期如注入AuthenticationManager若直接new CustomLoginAuthenticationFilter()setAuthenticationManager()不会被调用attemptAuthentication()里this.getAuthenticationManager()永远为 null该Bean方法必须声明Order(Ordered.HIGHEST_PRECEDENCE 10)虽然addFilterBefore()指定了位置但 Spring Boot 2.7 后多个Bean定义的过滤器若未显式排序加载顺序不可控可能导致你的过滤器在CsrfFilter之后才执行而 CSRF Token 校验失败会直接抛出异常你的过滤器根本没机会运行。提示Ordered.HIGHEST_PRECEDENCE 10是经过实测的安全值。HIGHEST_PRECEDENCE是-2147483648加 10 后仍远高于CsrfFilter的Ordered.HIGHEST_PRECEDENCE 1000确保你在 CSRF 校验前拿到原始请求体。2.2attemptAuthentication()从HttpServletRequest解析凭证的四步血泪经验这是整个过滤器最核心的方法也是新手翻车最多的地方。本项目CustomLoginAuthenticationFilter.attemptAuthentication()实现如下Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { // Step 1: 强制读取一次 request.getInputStream()否则后续 Controller 无法再读 String body StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); // Step 2: 解析 JSON提取字段此处用 Jackson非 Spring Boot 默认的 ObjectMapper JsonNode node objectMapper.readTree(body); String phone node.path(phone).asText(); String smsCode node.path(smsCode).asText(); String deviceId node.path(deviceId).asText(); // Step 3: 构建自定义 AuthenticationToken非 UsernamePasswordAuthenticationToken CustomAuthenticationToken authRequest new CustomAuthenticationToken(phone, smsCode, deviceId); authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); // Step 4: 委托给 AuthenticationManager 执行认证逻辑 return this.getAuthenticationManager().authenticate(authRequest); }关键细节说明Step 1 的StreamUtils.copyToString()不可省略HttpServletRequest.getInputStream()只能读一次SpringMVC 的RequestBody注解底层也是读这个流。若你跳过这步直接在 Controller 里用RequestBody会得到空对象。本项目选择在过滤器层一次性读完既满足认证需求又避免影响后续 ControllerStep 2 必须用独立的ObjectMapper实例不要用Autowired注入的全局ObjectMapper。因为 Spring Boot 默认配置会开启DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES而前端可能多传字段如埋点用的traceId导致解析直接抛JsonMappingException。本项目在CustomLoginAuthenticationFilter构造器中初始化了一个宽松解析的ObjectMapperobjectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)Step 3 的CustomAuthenticationToken必须继承AbstractAuthenticationToken不能用UsernamePasswordAuthenticationToken硬塞。因为后者校验逻辑固定为username/password而你的authenticate()方法在CustomAuthenticationProvider中会根据instanceof CustomAuthenticationToken做类型分发若类型不匹配supports()返回 false认证直接跳过Step 4 的this.getAuthenticationManager()是 SpringSecurity 注入的不是你自己 new 的AuthenticationManager由 Spring Security 自动装配包含你配置的所有AuthenticationProvider。若此处手动 new 一个authenticate()调用将永远走不到你写的CustomAuthenticationProvider。3. 自定义AuthenticationProvider如何让authenticate()方法真正执行你的业务逻辑AuthenticationProvider是认证的“裁判”它不关心请求怎么来只负责对传入的Authentication对象判真假。本项目中的CustomAuthenticationProvider实现了AuthenticationProvider接口并覆盖authenticate()和supports()两个方法。它的存在是为了把“校验短信验证码是否正确”这件事从过滤器里彻底剥离出来符合单一职责原则。3.1supports()方法类型守门员拒绝一切不速之客Override public boolean supports(Class? authentication) { return CustomAuthenticationToken.class.isAssignableFrom(authentication); }这行代码看似简单却是整个流程的闸门。AuthenticationManager在收到Authentication对象后会遍历所有注册的AuthenticationProvider调用其supports()方法。只有返回true的 provider才会被调用authenticate()。若你在这里写成return true;那么所有类型的认证请求包括表单登录、OAuth2 登录都会流经你的authenticate()方法导致非短信登录的请求也去查短信库性能雪崩。本项目严格限定为CustomAuthenticationToken类型确保职责边界清晰。3.2authenticate()方法三阶段业务校验与上下文注入Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { // Phase 1: 参数合法性校验快速失败 if (!(authentication instanceof CustomAuthenticationToken)) { throw new AuthenticationServiceException(Unsupported authentication type); } CustomAuthenticationToken token (CustomAuthenticationToken) authentication; String phone token.getPrincipal().toString(); String smsCode token.getCredentials().toString(); if (StringUtils.isBlank(phone) || StringUtils.isBlank(smsCode)) { throw new BadCredentialsException(手机号或验证码不能为空); } // Phase 2: 业务逻辑校验调用短信服务、风控服务 SmsVerifyResult verifyResult smsService.verifySmsCode(phone, smsCode); if (!verifyResult.isSuccess()) { throw new BadCredentialsException(verifyResult.getMsg()); } // Phase 3: 构建已认证的 Authentication 对象并注入用户上下文 UserDetails userDetails userDetailsService.loadUserByUsername(phone); CustomAuthenticationToken authenticatedToken new CustomAuthenticationToken( userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities() ); authenticatedToken.setDetails(token.getDetails()); // 保留原始请求详情如 deviceId authenticatedToken.setAuthenticated(true); // 关键标记为已认证 return authenticatedToken; }逻辑拆解Phase 1 是防御性编程双重检查instanceof和空值避免 NPE 或类型转换异常。BadCredentialsException会被ExceptionTranslationFilter捕获并转为401响应无需你手动response.sendError()Phase 2 是真实业务smsService.verifySmsCode()是你自己的服务本项目已预留接口和 Mock 实现。重点在于此方法必须是同步阻塞调用。SpringSecurity 的AuthenticationManager是同步执行的若你在这里用CompletableFuture或WebClient异步调用authenticate()方法会立即返回null导致认证失败Phase 3 的setAuthenticated(true)是生死线AbstractAuthenticationToken的isAuthenticated()默认返回false若你不显式设为trueSpringSecurity 会认为认证失败即使你返回了UserDetails也会被丢弃。这是新手最常踩的坑——以为返回UserDetails就万事大吉结果SecurityContextHolder.getContext().getAuthentication().isAuthenticated()始终为false。注意userDetailsService.loadUserByUsername(phone)加载的UserDetails对象其getAuthorities()返回的权限列表会自动注入到SecurityContext中。后续PreAuthorize(hasAuthority(ROLE_ADMIN))注解就能直接使用无需额外配置。4. 避坑五个让开发者抓狂的常见问题与根因修复SpringSecurity 自定义过滤器的坑往往藏在“看起来应该能跑”的细节里。以下是本项目源码中已复现并修复的五类高频问题每条都附带现象、根因和可复制的修复方案。4.1 现象过滤器doFilter()方法断点打上了但请求进来时完全不触发原因CustomLoginAuthenticationFilter的requiresAuthentication()方法始终返回false。该方法默认判断请求 URL 是否匹配setRequiresAuthenticationRequestMatcher()设置的路径默认是/login。但你的登录接口可能是/api/v1/auth/login若未重写此方法或未设置匹配器过滤器直接跳过。解决在CustomLoginAuthenticationFilter构造器中显式设置public CustomLoginAuthenticationFilter() { super(new AntPathRequestMatcher(/api/v1/auth/login, POST)); }或重写方法Override protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return /api/v1/auth/login.equals(request.getRequestURI()) POST.equals(request.getMethod()); }4.2 现象attemptAuthentication()中request.getInputStream()读出来是空字符串原因Content-Type头不是application/json而是text/plain或缺失。SpringSecurity 的AbstractAuthenticationProcessingFilter默认只对application/json类型的请求执行attemptAuthentication()其他类型直接放行返回null。解决重写requiresAuthentication()方法去掉Content-Type依赖Override public boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return /api/v1/auth/login.equals(request.getRequestURI()) POST.equals(request.getMethod()); }并在attemptAuthentication()开头强制读取流不依赖Content-Type。4.3 现象认证成功后SecurityContextHolder.getContext().getAuthentication()获取到的对象getPrincipal()是null原因CustomAuthenticationToken构造时principal参数传了null或空字符串。UserDetails接口的getUsername()方法若返回nullSpringSecurity 会抛IllegalArgumentException但某些版本会静默处理导致Principal为空。解决在CustomAuthenticationProvider.authenticate()中确保userDetailsService.loadUserByUsername(phone)返回的UserDetails对象其getUsername()方法返回非空字符串。可在UserDetailsServiceImpl.loadUserByUsername()中加校验if (user null || StringUtils.isBlank(user.getUsername())) { throw new UsernameNotFoundException(用户不存在); }4.4 现象认证成功但后续请求PreAuthorize注解不生效提示Access is denied原因CustomAuthenticationToken继承了AbstractAuthenticationToken但未重写getCredentials()方法导致权限校验时getCredentials()返回nullhasAuthority()判断失败。解决在CustomAuthenticationToken中显式重写Override public Object getCredentials() { return this.credentials; // 确保返回构造时传入的 credentials如密码、token }同时getPrincipal()也必须重写返回UserDetails或用户名字符串。4.5 现象CustomAuthenticationProvider.authenticate()抛出BadCredentialsException但响应体是 HTML 登录页不是 JSON 错误原因ExceptionTranslationFilter捕获异常后调用了DefaultAuthenticationFailureHandler而后者默认重定向到/login?error。你需要自定义失败处理器。解决在SecurityConfig中配置http .exceptionHandling() .authenticationEntryPoint(new CustomAuthenticationEntryPoint()) // 处理未认证请求 .defaultAuthenticationFailureHandler(new CustomAuthenticationFailureHandler()); // 处理认证失败其中CustomAuthenticationFailureHandler继承SimpleUrlAuthenticationFailureHandler重写onAuthenticationFailure()方法写入 JSON 响应Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:1001,\msg\:\ exception.getMessage() \}); }5. 认证成功后的上下文注入如何把设备 ID、IP、TraceID 写进SecurityContext认证通过只是开始真正的业务价值在于把本次登录携带的上下文信息安全、稳定、可追溯地注入到整个请求生命周期中。本项目在CustomAuthenticationProvider.authenticate()的 Phase 3 中不仅构建了CustomAuthenticationToken还通过setDetails()方法注入了CustomWebAuthenticationDetails这是一个继承自WebAuthenticationDetails的自定义类用于承载设备 ID、客户端 IP、请求 TraceID 等元数据。5.1CustomWebAuthenticationDetails轻量级上下文载体的设计逻辑public class CustomWebAuthenticationDetails extends WebAuthenticationDetails { private final String deviceId; private final String traceId; public CustomWebAuthenticationDetails(HttpServletRequest request) { super(request); this.deviceId request.getHeader(X-Device-ID); this.traceId request.getHeader(X-Trace-ID); } // getter 方法... }为什么不用MapString, Object直接塞因为WebAuthenticationDetails是 SpringSecurity 的标准扩展点Authentication.setDetails()接收的就是它。若你用HashMapSecurityContext里存的就不是标准对象后续Controller中无法通过SecurityContextHolder.getContext().getAuthentication().getDetails()安全获取。本项目严格遵循框架约定确保扩展不破坏原有生态。5.2 在 Controller 中安全提取上下文AuthenticationPrincipal的正确用法很多开发者习惯在 Controller 中写GetMapping(/profile) public ResponseEntity? getProfile() { Authentication auth SecurityContextHolder.getContext().getAuthentication(); CustomWebAuthenticationDetails details (CustomWebAuthenticationDetails) auth.getDetails(); String deviceId details.getDeviceId(); // 安全获取 // ... }但这种强转有风险若请求未认证auth为null若details为null强转抛ClassCastException。本项目推荐使用AuthenticationPrincipal注解配合AuthenticationPrincipalArgumentResolver它会在参数注入前完成类型安全检查GetMapping(/profile) public ResponseEntity? getProfile(AuthenticationPrincipal(expression details) CustomWebAuthenticationDetails details) { if (details null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } String deviceId details.getDeviceId(); // 业务逻辑... }expression details表示从Authentication对象中取getDetails()方法的返回值并尝试转换为CustomWebAuthenticationDetails类型。若类型不匹配或为nullSpringMVC 会直接返回400 Bad Request而不是让 Controller 崩溃。5.3 审计日志与会话绑定successfulAuthentication()的黄金钩子AbstractAuthenticationProcessingFilter.successfulAuthentication()是认证成功的最终回调此时Authentication已写入SecurityContext是最适合做副作用操作的时机。本项目在此方法中做了三件事Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication successfulAuthentication) throws IOException, ServletException { // 1. 记录登录审计日志异步避免阻塞主线程 auditLogService.asyncLogLogin( ((CustomAuthenticationToken) successfulAuthentication).getPrincipal().toString(), ((CustomWebAuthenticationDetails) successfulAuthentication.getDetails()).getDeviceId(), getClientIpAddress(request) ); // 2. 将设备 ID 写入 Redis 会话白名单key: session:device:{deviceId} String deviceId ((CustomWebAuthenticationDetails) successfulAuthentication.getDetails()).getDeviceId(); redisTemplate.opsForValue().set(session:device: deviceId, successfulAuthentication.getName(), 30, TimeUnit.MINUTES); // 3. 调用父类方法完成默认行为如设置 SecurityContext、触发事件 super.successfulAuthentication(request, response, chain, successfulAuthentication); }关键点审计日志必须异步auditLogService.asyncLogLogin()底层用Async或线程池防止数据库慢查询拖垮认证接口Redis 写入用opsForValue().set()而非opsForHash()因为设备 ID 是唯一主键不需要结构化存储String类型最轻量TTL 设为 30 分钟与会话有效期对齐必须调用super.successfulAuthentication()否则SecurityContext不会持久化SecurityContextHolder.getContext().getAuthentication()在后续 Filter 中会是null。从那以后我每次写自定义认证逻辑都强制走一遍这三步验证在attemptAuthentication()开头加log.info(Start auth for {}, request.getRequestURI())确认过滤器真被触发在CustomAuthenticationProvider.authenticate()的 Phase 3 结尾加log.info(Auth success for {}, userDetails.getUsername())确认业务逻辑走通在 Controller 的AuthenticationPrincipal参数上加断点确认details对象能安全注入。这三步下来90% 的“认证不生效”问题当场定位。希望帮到你。本文还有配套的精品资源点击获取