在Spring Security这个圈子里我见过太多初学者卡在同一个地方查文档时觉得每个类都认识真写起来却不知道请求是从哪里进的、过滤器是干嘛的、为什么改一个配置就404或403。我自己当年也是这样对着网上零散的教程拼了半天最后发现最缺的不是范例代码而是一条能把整个认证授权链路串起来的逻辑线。所以这篇进阶小册我想换个讲法——不按文档目录走而是从请求进到应用的那一刻开始一路拆到你在PreAuthorize里写的那个hasAuthority(order:create)为什么能生效。看完之后你至少能回答两个问题Spring Security在Servlet体系里到底帮我们做了什么以及它没做的那部分该从哪里下手补。内容上我会围绕Spring Security 5/6的常见写法展开适合已经能跑起一个Demo、但对原理还是“半懂不懂”的Java开发者也适合准备面试想整理一条清晰脉络的人。读完这篇你会对过滤器链、认证流程、会话策略、授权模型这四块核心内容有自己的理解框架不会再靠死记硬背去应付那些面试题。1. 过滤器链是理解Spring Security的钥匙1.1 先搞清楚一件事Spring Security不是一个组件而是一串过滤器很多教程会直接甩给你一个WebSecurityConfigurerAdapter或者现在的SecurityFilterChain代码让你照着配。配置能跑通但一遇到问题就懵——因为你不知道在你自己写的Controller前面其实已经站了一排“隐形守卫”。Spring Security在Servlet应用里根本不是以独立Servlet的方式存在它靠的是Servlet规范里的Filter机制。一个请求进来会先穿过Tomcat容器维护的一组过滤器Spring Security注册的过滤器就在这组过滤器的某一环上。这一环的设计很巧妙它把Spring Security自身的所有逻辑都塞进了一个主线Filter里这个主线叫FilterChainProxy然后由FilterChainProxy再按照你配置的SecurityFilterChain去排列不同的过滤器。简单打个比方Tomcat的Filter链就像进商场的一排门而FilterChainProxy是其中一扇总门这扇总门后面还有十几道小门——SecurityContextHolderFilter、UsernamePasswordAuthenticationFilter、ExceptionTranslationFilter等等。你只需要知道请求一定会先过这扇总门但具体过哪些小门、按什么顺序过由SecurityFilterChain决定。1.2 过滤器顺序为什么不能乱排我记得自己在看Debug过滤调试信息时第一次感受到这套体系的精妙也第一次踩到顺序的坑。Spring Security内部给过滤器定义了严格的顺序从org.springframework.security.config.annotation.web.builders.FilterOrderRegistration可以看到一部分排序逻辑。简单理解至少要保证前面几步的顺序是先把SecurityContext从Session或其它存储里捞出来放进SecurityContextHolder让后面的过滤器知道当前请求是谁。再进行匿名认证或表单登录等认证处理把Authentication写回SecurityContextHolder。接着处理会话固定保护、携带请求的缓存等逻辑。最后才轮到授权决策——AuthorizationFilter拿到已经认证好的Authentication做hasRole或hasAuthority判断。如果你自己写了一个自定义过滤器又在里面直接读SecurityContextHolder那你就必须确认它排在SecurityContextHolderFilter之后。怎么确认没有经验的人喜欢在http.addFilterAfter()里乱放结果读到的Authentication全是null。正确做法是看你的过滤器到底关心哪一层如果需要认证信息通常排在UsernamePasswordAuthenticationFilter之后如果想做日志或审计不依赖认证信息那可以放在更前面。掌握了这个“依赖关系”再去选插入位置就不会再瞎试了。1.3 自定义过滤器到底应该怎么写实操中我们最常见的自定义过滤器场景是做JWT解析。新手容易把它写成“从Authorization头里取出Token解析出用户ID然后直接放行”。这个思路能跑但会和Spring Security自己的认证体系割裂。我建议的写法是自定义Filter只负责“把Token转换成Authentication对象”然后交给SecurityContextHolder。后续的授权判断依然走Spring Security自己的规则。伪代码大概是这样Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null SecurityContextHolder.getContext().getAuthentication() null) { // 解析token拿到用户标识和权限集合 Authentication auth buildAuthenticationFromToken(token); SecurityContextHolder.getContext().setAuthentication(auth); } filterChain.doFilter(request, response); } }这类过滤器的核心不是“校验Token”而是“把Token翻译成Spring Security社区认识的Authentication”。Token本身的真伪校验可以放在解析环节也可以依靠资源服务器的JwtDecoder处理。真正容易踩的坑有两个一是忘记判断当前SecurityContextHolder里已经有认证信息导致重复解析二是过滤器只做了解析却没把自己注册进过滤器链写了个寂寞。注册的标准做法是http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);加在UsernamePasswordAuthenticationFilter之前是因为我们要在表单登录过滤器决定“是否需要走匿名/登录流程”之前就给它一个明确身份。这段逻辑我反复检查过很多次确实是最稳妥的位置。2. 认证全链路拆解从登录请求到SecurityContext里的那两行代码2.1 别被AuthenticationManager吓到它就干一件事很多初学者见AuthenticationManager就绕路名字太吓人。剥开外壳认证链路无非是把用户输入的“凭证”包装成某个AuthenticationToken交给认证管理器认证管理器再委托给具体的Provider去“查账、对账”最后把认证成功的Authentication对象放回SecurityContextHolder。默认的ProviderManager会遍历注册好的AuthenticationProvider列表挨个问“这个Token你能不能认”。每个Provider都有supports()方法比如DaoAuthenticationProvider就只认UsernamePasswordAuthenticationToken。你传一个别的Token过来它直接表示“这事我干不了”然后轮到下一个。这也是为什么我们做JWT时经常要自己写一个AuthenticationProvider或者直接不走ProviderManager——因为默认的Provider只认用户名密码那套。2.2 DaoAuthenticationProvider到底在校验什么DaoAuthenticationProvider这个名字很直白基于DAO层数据做认证。它依赖两个核心组件——UserDetailsService和PasswordEncoder。UserDetailsService负责用用户名从库里捞账号信息捞回来的对象要转成UserDetails接口的实现。然后DaoAuthenticationProvider把用户输入的密码取出来和UserDetails.getPassword()做比对。很多人以为这里就是简单的“字符串相等”其实不然。整个过程有几个细节值得记住比对用的是PasswordEncoder.matches(rawPassword, encodedPassword)而不是解密或反编码。BCryptPasswordEncoder这类编码器是单向哈希校验时是把同一个密码用相同盐逻辑重新算一遍再比对。如果你的UserDetailsService压根没查到用户DaoAuthenticationProvider会抛UsernameNotFoundException然后外层往往会被转换成BadCredentialsException——这是故意的防止你通过报错类型探测用户是否存在。默认配置下UserDetails里的isEnabled()、isAccountNonLocked()这些方法返回false会被特殊拒绝具体异常也各不相同。所以我一直建议自定义UserDetailsService时不要只重写getUsername()和getPassword()其余布尔状态都写死返回true。等系统做到账号锁定、密码过期以后你再回头看自己当初写的return true;会明白为什么Spring Security默认要留这些状态位。2.3 认证成功之后数据放哪了认证成功后Authentication对象会被塞进SecurityContextSecurityContext再被放进SecurityContextHolder。注意SecurityContextHolder本身的存储策略是可配置的默认SecurityContextHolderFilter会在每次请求进入时从SecurityContextRepository里取出上下文放进去在请求结束时再决定要不要存回Session。这里有一个面试爱问的点无状态接口是不是就一定不需要Session答案是不一定。如果你设置了SESSION_CREATION_IF_REQUIRED且没有主动创建Session那认证信息还是会跟Session挂钩只有显式设置STATELESSSpring Security才不会用Session存SecurityContext。什么时候用STATELESS一般是在纯API后端因为API消费方通常是前端App或小程序没必要依赖服务端Session。但纯API后端也不是完全不涉及Session——登录接口如果用了表单登录还是会创建会话所以工程上更常见的是把认证状态改成无状态后配合JWT。我之前在一个老项目里就吃过这个亏接口一直用JWT但是登录流程还保留着表单登录的Session机制结果用户改密码后老Session没清权限照样是旧的。后来统一把会话策略改成STATELESS再把JWT里带上“会话版本号”字段每次改密码或踢人就让版本号失效才把整条链路理顺。3. 会话管理从有状态到无状态的边界在哪里3.1 三种SessionCreationPolicy不是随便选的Spring Security里会话策略配置是这个http.sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);IF_REQUIRED是默认值意味着“如果这个请求需要Session那就创建一个”。ALWAYS则是“不管用不用先建了再说”。STATELESS是“我绝不主动创建也不读Session里的安全上下文”。这三种策略没有绝对好坏取决于你的部署方式。单机单体、服务端可以接受Session用IF_REQUIRED最省心想要完全抛弃服务端会话、让任意节点处理请求STATELESS才合适。值得注意的是STATELESS并不是“禁用Session”而是Spring Security不再依赖Session来管理安全上下文你项目里的其它代码照样能拿到HttpSession去存购物车等业务数据。理解这个区别很重要因为很多教程会把STATELESS等同于“无会话”这是不准确的。3.2 会话固定攻击和SessionFixation防护如果你还在用Session那“会话固定攻击”是绕不开的知识点。攻击者先在自己浏览器里拿一个SessionID诱导你登录时用这个ID去发请求如果服务端不换SessionID攻击者就能拿着自己已知的SessionID偷用你的登录态。Spring Security默认提供了防御登录成功后会创建新会话或更换SessionID。具体策略由sessionFixation()配置控制migrateSession()新建Session把老Session里的属性迁移过来不改变SessionID的机制但能防止攻击者继续使用旧ID。changeSessionId()保留同一个Session对象只换掉ID。none()啥都不做这是开发调试时才用的绝不能上线。我最常推荐changeSessionId()因为改动最小又能阻止固攻击migrateSession()在新版本中有一些属性迁移的兼容问题不是必需就别用。核心原则一句话登录成功后SessionID必须变。3.3 并发会话控制与强制下线的坑系统发展到一定规模就会出现“一个账号多处登录”的限制需求。Spring Security支持通过SessionRegistry实现并发登录控制http.sessionManagement() .maximumSessions(1) .expiredUrl(/login?expired);配置倒不复杂真正的坑在于分布式部署下如果多台节点各自维护一份SessionRegistry那ConcurrentSessionControl就会失效。因为你登录到A节点、又登录到B节点两台节点不知道对方的会话存在。解决方案是把SessionRegistry实现改成基于Redis的共享版本或者索性切换成无状态JWT方案在Token层面做“单端登录”。对于无状态Token强制下线的常见思路是引入Redis或者数据库里的“会话版本号/黑名单”。每次登录生成一个新版本号旧Token里的版本号对不上就判无效。这样做业务上更可控也不依赖具体容器实现。4. 授权设计进阶从角色判断到动态权限和数据权限4.1 hasRole和hasAuthority到底差在哪不少人在写表达式时把hasRole(ADMIN)和hasAuthority(ADMIN)混着用。最终效果差不多但底层规则有一个容易被忽略的差异hasRole(ADMIN)会在判断前给角色自动加一个ROLE_前缀。也就是说hasRole(ADMIN)实际校验的是ROLE_ADMIN。这个设计为什么要存在因为Spring Security的历史约定里角色就是ROLE_开头的权限标识。如果你在DB里存权限时习惯直接写ROLE_ADMIN那用hasRole(ADMIN)最自然如果你存的是order:create这类细粒度权限码那就得用hasAuthority(order:create)。在真实项目中我更推荐把“角色”和“权限”拆成两层角色是粗粒度的权限集合权限是细粒度的操作码。用户 - 角色 - 权限这种模型最灵活。工具上可以用GrantedAuthority把角色和权限都塞进去也可以自定义AuthorizationManager做合并判断。4.2 方法级安全注解的正确打开方式URL级别的授权配置比如requestMatchers(/order/**).hasRole(USER)适合粗粒度拦截。但真正做细粒度控制要靠方法级安全。步骤是三步开启方法级安全EnableGlobalMethodSecurity(prePostEnabled true)Spring Security 6之后是EnableMethodSecurity。在Service层方法上加注解PreAuthorize(hasRole(ADMIN) or hasAuthority(order:read)) public OrderVO getOrder(Long orderId) { // ... }注意PreAuthorize是在方法执行前校验PostAuthorize在方法执行后校验。PostAuthorize适合需要根据返回结果判断权限的场景比如一个方法默认返回订单详情只有本人或管理员能看那可以在SPEL表达式里拿returnObject做判断。这里分享一个实战误区很多人喜欢把方法级安全加在Controller上这也能用但不够稳。更合理的隔离层是放在Service层因为Controller往往要封装响应格式或做参数校验Service层才是业务权限的真正边界。如果多个Controller都调用同一个Service方法权限规则写在Service层可以统一收口。4.3 数据级权限行级权限为什么不能只靠注解刚才说的PreAuthorize能管到“你能不能调用买单接口”但管不了“你只能看属于你自己的订单别人的订单要过滤掉”。后者是行级权限也叫数据权限。实现行级权限有几个层次最简单的是在查询参数里显式传userIdSQL里加条件。安全但不优雅容易漏。进阶一点是自定义一个DataScope注解配合AOP在Service方法执行前修改SQL参数或者用MyBatis拦截器在SQL执行时自动拼接权限条件。用Spring Data JPA的Filter配合FilterDef也能做但实体级别过滤的配置对新手太不友好而且复杂的“部门本人自定义数据权限”规则写起来很痛苦。我自己的经验是先用SecurityContextHolder拿到当前用户ID再把它作为查询条件传下去这是直觉方案也是小项目最可靠的方案。等项目复杂度上来以后再去引入MyBatis拦截器通过注解标记哪些表需要数据权限条件统一拼接WHERE vendor_id :currentVendorId之类的内容。这个方案灵活但一定要做白名单校验防止用户绕过自定义拦截器直接写原生SQL。4.4 动态URL授权与权限模型设计有些系统要求权限可以动态分配不能写死在配置类里。比如你新增了一个角色“运营专员”希望它能访问/report/daily但不想改代码重启。这种场景下URL权限就需要走数据库或Redis配置。常规思路是这样启动时加载权限表构建MapAntPathRequestMatcher, ListGrantedAuthority再交给自定义的AuthorizationManager去判断。权限变更时通过事件刷新这个Map或者设置一个较短缓存过期时间。判断时用当前请求的URI和HttpMethod去匹配规则匹配成功后检查当前Authentication的权限列表里是否包含对应权限。需要注意AntPathRequestMatcher在Spring Security 6里的匹配行为有变化建议直接用现成的RequestMatcher实现别自己造轮子。还有一个小细节动态权限必须精确到HTTP Method比如同一个/order/**GET表示查询DELETE表示删除权限模型里要区分清楚。不然就会出“有查询权限的人悄悄把订单删了”这种事故。5. 配置里的细枝末节CSRF、CORS、响应头都不能忽略5.1 CSRF防护什么时候开什么时候关CSRF跨站请求伪造在表单会话认证下是最要防的。攻击者诱导用户在已登录状态下访问一个恶意页面这个页面能自动向你的系统发起POST请求因为浏览器会自动带上Cookie所以后端以为这是用户本人的操作。默认表单登录时Spring Security会开启CSRF防护需要在页面表单里带上一个_csrfToken。这个Token存在Session里提交表单时校验。但一旦走的是无状态JWTCSRF防护往往就没多大意义——因为Token不是浏览器自动携带的Cookie而是放在Authorization头里跨站脚本拿不到也就构造不了伪造请求。所以线上线下教程里最普遍的做法是http.csrf().disable();等等先别学这个动作。我需要认真提醒一句即使使用JWT如果你的系统还有一部分接口依赖Cookie或不想用自定义Header传递Token那CSRF依然是个威胁。决定关掉之前先确认你的认证信息不是放在Cookie里被浏览器自动携带。比如Angular/React这种前端应用把Token放在内存或LocalStorage里每次请求手动带Header关CSRF是合理的如果是传统服务端渲染页面请你继续留着CSRF别图省事。5.2 CORS跨域配置最容易踩的三个坑前后端分离之后CORS配置几乎是标配。但Spring Security里配置CORS有个特点你需要同时搞定Spring MVC的CORS和Spring Security的CORS否则会出现“Controller明明带CrossOrigin还是被拦”的诡异现象。正确做法是在Security配置里加上http.cors(withDefaults());同时定义一个CorsConfigurationSource的BeanBean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOrigins(List.of(https://example.com)); config.setAllowedMethods(List.of(GET, POST, PUT, DELETE, OPTIONS)); config.setAllowedHeaders(List.of(*)); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }最容易踩的三个坑我一个个说第一个坑setAllowedOrigins不能写*同时setAllowCredentials(true)。浏览器规范不允许这样组合部分容器虽然能放行但某些浏览器会直接拦截预检请求。第二个坑没把OPTIONS请求放行。虽然setAllowedMethods里配了OPTIONS但预检请求很多时候被其它安全配置拦在CORS处理之前需要确保Spring Security不对OPTIONS请求做复杂拦截或者单独放行OPTIONS。第三个坑allowedOriginPatterns和allowedOrigins的优先级区别。想支持“http://localhost:8080”和“http://localhost:3000”这种动态来源时用allowedOriginPatterns更保险它支持通配符。5.3 安全响应头与点击劫持防护Spring Security默认会给响应加一组安全响应头其中最重要的就是X-Frame-Options用来防止点击劫持攻击者把你的系统页面嵌进一个透明iframe里用户以为自己点的是游戏按钮实际点的是“删除账号”。默认配置http.headers() .frameOptions().sameOrigin();这个配置允许同源页面嵌套iframe但不允许跨域嵌套。如果你的系统有后台管理界面需要被嵌套到别的系统门户里那就需要改成frameOptions().disable()同时自己设置Content-Security-Policy的frame-ancestors来精确控制允许哪些来源嵌套。别小看这一步我看过不少项目为了“方便接入统一门户”把所有frame限制都关了结果整个系统成了钓鱼页面的活靶子。除了X-Frame-Options平时部署时建议顺手开启或确认这几个头X-Content-Type-Options: nosniff、Cache-Control相关的缓存控制、Strict-Transport-SecurityHTTPS环境下。很多Spring Boot项目默认就带一部分但生产环境里最好明确配置一遍别依赖“好像默认有”。6. 疑难杂症排查403从哪来密码怎么还不对6.1 一个403问题排查链路示例我印象最深的一次排错是有个新同事怎么调都拿不到权限接口永远返回403。当时我让他走一遍排查链路最后锁定了问题。这里把这个链路写出来你可以直接照着复现第一步看日志里是否出现AuthorizationDeniedException或AccessDeniedException。如果出现了说明认证已经通过问题在授权阶段。如果没出现那可能是SecurityContext压根没拿到认证信息。第二步确认访问路径是否匹配上了你配置的requestMatchers规则。requestMatchers(/admin/**).hasRole(ADMIN)和/admin、/admin/在末尾斜杠上的匹配差异、以及/*只能匹配一层路径、/**才能匹配多层这些都是新手常掉的坑。第三步在PreAuthorize表达式里临时加上完整的权限日志把当前用户的Authentication.getAuthorities()打出来看看里面到底有没有你期望的ROLE_ADMIN。很多时候不是规则错了而是数据库里权限映射错了。第四步检查权限字符串是不是有空格或大小写问题。ROLE_ADMIN和ROLE_ ADMIN肉眼看不出来但字符串匹配一票否决。我之前在配置里因为一个多余空格排查了两个小时最后用debug输出才看到端倪。6.2 BCrypt不是随便选的说说为什么不能用MD5写代码时我经常遇到有人图省事把密码加密换成MD5理由是“反正我们这是内网系统”。这个观点在2025年真的站不住了再小的系统也可能因为一个弱口令被打穿密码库。BCryptPasswordEncoder是Spring Security最常用的密码编码器它对同一个密码每次生成不同的密文因为内部混入了随机盐。这个特点使得相同的密码在数据库里看起来完全不同攻击者即使拿到库也很难通过彩虹表反推。它的强度参数strength默认是10表示2的10次方轮哈希计算。实际项目里可以根据服务器性能调到10到12之间。调太高会让登录请求变慢调太低又不够安全。另外提一个冷知识Spring Security 5之后默认就已经不再推荐{noop}明文密码了。如果你在内存用户里看到{noop}123456那只是官方为了Demo方便千万别照抄到生产环境。如果你从老项目迁移过来数据库里全是MD5密文建议做一个兼容方案登录时先用MD5校验校验通过后立刻把密码重新加密为BCrypt下次登录就走BCrypt。这样平滑迁移风险最小。6.3 新版本配置类写法变化的坑Spring Security 5.7开始官方逐步废弃了WebSecurityConfigurerAdapter。如果你在网上看教程很多老文章还在用它。到了Spring Security 6推荐写法是组件式配置Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/public/**).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .formLogin(withDefaults()) .logout(withDefaults()); return http.build(); }这个写法的好处是分离关注点每个SecurityFilterChain对应一套规则不同类型路径可以走不同过滤器链。你完全可以用一个链管/api/**用另一个链管/internal/**。但这个变化也坑了不少人——升级后发现自己自定义的过滤器没有被注册原因往往是旧代码在WebSecurityConfigurerAdapter里覆写了configure(HttpSecurity http)又依赖了某些已删除的方法。升级到新版本时我强烈建议多做一步在应用启动日志里看看有没有关于SecurityFilterChain的警告或废弃提示。如果有尽早按官方迁移指南改。别等报警堆满日志才动手那时候你已经分不清哪些警告是新版带来的、哪些是你自己业务的问题了。写在最后的小经验Spring Security这套东西最怕的就是背概念不串链路。过滤器、认证管理器、授权管理器、会话仓库这些东西单独拿出来都不难但组合在一起以后每个环节的边界就开始模糊。我建议你把这个系列的代码亲手敲一遍每写完一个模块就追问自己一个问题一个HTTP请求从浏览器出发到我的Controller为止中间到底经历了哪几个关键组件如果你能顺畅地回答出来那Spring Security这部分你已经超过大多数Java面试候选人了。最后再分享一个调试技巧在配置里把debug参数开起来http.securityContext(context - context.requireExplicitSave(false));或者在application.yml里设置debugtrueSpring Security会打印详细的过滤链决策信息哪个请求被哪个过滤器拦掉、哪个规则匹配成功全都会明明白白列出来。这个开关在排错时比任何文档都管用但上线前一定记得关掉否则生产日志会非常吵。