资讯动态

Spring Security从入门到实战:认证授权与过滤器链详解

发布时间:2026/9/8 8:29:16 来源:尧图企业网站定制
Spring Security在Java后端领域几乎是绕不开的一座山尤其是做企业级应用、涉及用户登录和权限控制的时候。我第一次真正深入接触它是在接手一个老项目时——当时系统里塞满了自定义拦截器每个接口都在手写session校验逻辑还散落在各处改一个权限点要翻半天代码。后来重构时换成Spring Security才逐渐体会到这套框架真正厉害的地方它不是给你几个工具类而是把“认证”“授权”“会话管理”这些安全治理问题统一成一条可扩展的过滤链。这篇博客不打算照搬官方文档就从一个快速上手的视角把Spring Security最核心的机制、配置方式和常见坑讲清楚。内容适合两类人看一类是刚接触Spring Boot、想给项目加登录和权限控制的新手另一类是做过简单鉴权、但一直没搞懂Spring Security内部逻辑的开发者。读完这篇你能亲手搭起一个带认证授权的最小系统也知道后续接JWT、接数据库权限模型时该动哪些配置。1. 从手写拦截器到Spring Security为什么大家都选它1.1 手写鉴权方案到底痛在哪在没有Spring Security之前很多项目做登录校验的思路很直接写一个拦截器或者过滤器在每个请求进来的时候检查一下session里有没有登录用户没有就跳转登录页。这种方案在小项目里跑得通但项目一复杂问题就全冒出来了。我自己就踩过不少坑。多个接口的权限粒度不一样管理员能看的页面普通用户不能进这需要在拦截器里写一长串if else判断URL前缀密码存储格式不统一有的是MD5有的是明文还有的是加盐SHA每次校验逻辑都不一样会话超时后用户要重新登录但有些接口是ajax请求直接返回跳转HTML又会解析报错。还有个更隐蔽的问题自己实现的拦截器往往只覆盖了业务接口静态资源、错误页面、内置端点这些地方很容易漏掉一不小心就变成安全漏洞。Spring Security之所以能成为主流是因为它把这些问题收敛成了一个统一模型。认证你是谁、授权你能不能做这件事、会话登录状态怎么保持、防护CSRF、点击劫持、响应头安全都被拆成标准组件你在配置文件里声明式地组合它们。这套模型不是Spring Security发明的但它在Java生态里实现得最完整。1.2 核心设计一条过滤链解决所有安全问题Spring Security最核心的机制是一条Servlet过滤器链。如果你用过Servlet就知道Filter可以在请求到达Controller之前和响应返回客户端之前做拦截。Spring Security把十几个过滤器按固定顺序串成一条链每个过滤器只负责一件小事。比如UsernamePasswordAuthenticationFilter负责处理登录表单提交BasicAuthenticationFilter负责处理HTTP Basic认证FilterSecurityInterceptor负责最终的方法权限校验。请求进来后按顺序经过这些过滤器任何一个过滤器发现认证失败或权限不足就直接抛出异常或返回错误响应根本到不了你的Controller。这套过滤器链的一个好处是你不需要在业务代码里到处关心安全问题只需要在接入点声明“哪些请求要什么权限”。另一个好处是可扩展性极强——你想自己定义认证方式就往过滤链里插一个自定义Filter你想改用JWT无状态认证就替换掉session相关的过滤器。很多初学者一上来就看源码结果被一连串的Filter和Provider搞得晕头转向。我的建议是先记住这个结论你看到的SecurityFilterChain配置本质就是在编排这条过滤器链的规则。2. 5分钟搭起第一个Spring Security应用2.1 依赖引入与第一次启动我用Spring Boot 3.x做演示JDK用17。第一步先引入依赖在pom.xml里添加Spring Security和Spring Webdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency直接启动项目你会发现控制台打印了一串随机密码类似下面这样Using generated security password: 8e8e0f45-30b5-4b9b-9f2e-6d827f6f24e6这时访问任何接口都会被一个默认登录页拦下来。默认用户名是user密码就是控制台打印的这段随机内容。用它们登录之后就能正常访问接口了。这个“开箱即用”的体验其实是Spring Boot自动配置的功劳。它检测到classpath里存在Spring Security就自动化生成了一套默认的安全配置所有请求都需要认证、默认表单登录、默认登录页和登出页。这套配置对新手很友好但对实际项目远远不够接下来我们要把它改成自己控制。2.2 第一个SecurityFilterChain配置Spring Security授权了新的配置方式核心是一个SecurityFilterChain的Bean。下面这份配置是我认为最适合作为入门模板的Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/, /home, /login).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .formLogin(form - form .loginPage(/login) .permitAll() ) .logout(logout - logout .logoutSuccessUrl(/) ); return http.build(); } }这里authorizeHttpRequests是在配置URL访问规则/、/home、/login允许所有人访问/admin/**需要ADMIN角色剩下的任何请求只要登录了就行。formLogin指定使用自定义登录页也可以不指定用Spring Security默认的登录页。logout配置登出行为。这个配置看起来简单但已经把Spring Security最常用的三层需求全部覆盖了哪能匿名访问、哪需要认证、哪需要特定角色。配置的层次感很重要规则的顺序是从上到下匹配的越具体的规则要写在越前面否则会被后面的anyRequest吞掉。2.3 内存用户与密码加密在还没有数据库的情况下先在内存里创建两个测试用户Bean public UserDetailsService userDetailsService(PasswordEncoder encoder) { UserDetails admin User.withUsername(admin) .password(encoder.encode(admin123)) .roles(ADMIN, USER) .build(); UserDetails user User.withUsername(user) .password(encoder.encode(user123)) .roles(USER) .build(); return new InMemoryUserDetailsManager(admin, user); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }注意密码必须通过PasswordEncoder加密之后再存进去不能直接写明文。这里用了BCryptPasswordEncoder它每次生成的哈希值都不同但matches方法能正确校验这是为了防彩虹表攻击。Spring Security 5之后强制要求配置PasswordEncoder原来的{noop}明文方案早已不再推荐。此时启动项目用admin/admin123登录访问/admin/**下的接口就能成功用user/user123登录访问/admin/**会返回403。3. 认证流程拆解用户名密码提交后到底怎么被验证的3.1 AuthenticationManager到底在管什么很多初学者会被AuthenticationManager、AuthenticationProvider、UserDetailsService这几个概念绕晕。我用一个生活化类比解释AuthenticationManager像前台的接待员收到“我要登录”的请求后把任务分发给具体的AuthenticationProviderAuthenticationProvider像具体办事的专员他认识用户数据源知道怎么核对凭证UserDetailsService则是数据库查询员负责按用户名查到用户详情。默认情况下DaoAuthenticationProvider会调用你的UserDetailsService加载用户然后调用PasswordEncoder.matches比对密码。比对通过后返回一个完整填充了权限信息的Authentication对象Spring Security会把它塞进SecurityContext。这个流程里有一个常见的误区UserDetailsService.loadUserByUsername抛出的UsernameNotFoundException会被默认的DaoAuthenticationProvider隐藏统一转换成BadCredentialsException。这个设计是为了防止攻击者通过异常信息枚举用户名。所以你在自定义实现时不要在异常消息里暴露“用户不存在”这种提示。3.2 从数据库查询用户的正确姿势实际项目里用户肯定在数据库里自己实现一个UserDetailsService是必经之路。假设你有一张sys_user表字段分别为username、password、status、role对应的UserDetailsService可以这样写Service public class DbUserDetailsService implements UserDetailsService { private final UserMapper userMapper; public DbUserDetailsService(UserMapper userMapper) { this.userMapper userMapper; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user userMapper.findByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .roles(user.getRole()) .build(); } }这里有几个关键细节要注意。第一数据库存的密码必须是PasswordEncoder加密后的结果通常是BCryptPasswordEncoder生成的字符串。第二roles方法会自动加上ROLE_前缀而authorities方法不会加前缀。如果你在配置里用hasRole(ADMIN)这里就用roles(ADMIN)如果用hasAuthority(ROLE_ADMIN)这里就用authorities(ROLE_ADMIN)。两者混用是最常见的配置不生效原因。第三如果你的用户表有禁用状态可以在返回UserDetails前判断一下状态或者实现UserDetails.isEnabled()方法返回是否启用。3.3 SecurityContext与当前用户信息的获取认证成功后用户信息会保存在SecurityContext中。SecurityContext默认存在SecurityContextHolder里它是一个ThreadLocal容器意味着同一线程内随处可以访问当前用户。Controller里获取当前用户最常用的方式是通过Authentication参数GetMapping(/me) public MapString, Object me(Authentication authentication) { return Map.of( name, authentication.getName(), authorities, authentication.getAuthorities() ); }也可以直接注入AuthenticationPrincipalGetMapping(/me) public UserDetails me(AuthenticationPrincipal UserDetails userDetails) { return userDetails; }我个人更推荐AuthenticationPrincipal因为它直接把业务对象给你避免从Authentication里手动转型。如果你的UserDetailsService返回的是自定义对象比如SysUserDetails那AuthenticationPrincipal的实参类型就直接是这个类使用起来非常顺手。需要特别提醒的是请求处理完ThreadLocal可能会被清空如果你在子线程里想获取登录用户需要显式把SecurityContext传过去或者使用SecurityContextHolder.setContext复制一份。异步任务里常见的空指针问题十有八九就是这个原因。4. 授权与权限控制登录只是第一步4.1 URL级别的访问规则怎么配才不容易出错Spring Security的授权配置最直观的就是URL匹配但这里面的规则有顺序之分。配置如下http.authorizeHttpRequests(auth - auth .requestMatchers(/admin/**).hasRole(ADMIN) .requestMatchers(/user/**).hasAnyRole(USER, ADMIN) .requestMatchers(/login, /register, /public/**).permitAll() .anyRequest().authenticated() );写这段配置时我踩过不少坑现在把心得整理成几条铁律第一具体规则写在前面宽泛规则写在后面。规则是按顺序从前往后匹配的一旦匹配成功就不再继续。如果把anyRequest().authenticated()写在最前面那后面的/admin/**规则就永远没机会生效了。第二requestMatchers里支持Ant风格表达式/admin/**代表/admin下的所有层级路径但如果你用的是Spring Boot 3需要注意路径匹配器和原来略有差异推荐直接用PathPattern规则更清晰。第三需要放行的不只是页面本身还有静态资源。CSS、JS、图片这些资源如果不放行页面样式会全部挂掉。通常我会加上.requestMatchers(/css/**, /js/**, /images/**, /favicon.ico).permitAll()4.2 方法级安全控制细粒度权限URL级别的控制只能管到路径没法精确到某个方法。比如同一个接口普通用户只能查自己的数据管理员能查所有人的数据这种细粒度控制就需要方法级安全。先开启方法级安全Configuration EnableMethodSecurity public class SecurityConfig { }之后就能在Service层或Controller层直接用注解GetMapping(/order/{id}) PreAuthorize(hasRole(ADMIN) or orderSecurity.isOwner(#id)) public Order getOrder(PathVariable Long id) { return orderService.getOrder(id); }这里的PreAuthorize使用SpEL表达式条件成立才允许调用。它支持hasRole、hasAnyAuthority、isAuthenticated、permitAll等内置表达式也可以引用Bean方法比如上面写的orderSecurity.isOwner(#id)判断当前登录用户是否是订单的归属人。方法级安全的粒度比URL级细得多适合用在数据权限、按钮权限、操作权限这类场景。但要注意注解本身有性能开销而且表达式出错时排查比较麻烦。我建议原则是URL级负责粗粒度拦截方法级负责关键操作的数据级校验两者叠加而不是互相替代。4.3 角色层级与动态权限模型真实系统的权限很少只有两三个固定角色更多场景是角色多、权限细、还带层级关系。这里先说角色层级。Spring Security支持在配置中定义层级让父角色自动拥有子角色的权限Bean public RoleHierarchy roleHierarchy() { return RoleHierarchyImpl.withDefaultRolePrefix() .role(ADMIN).implies(MANAGER) .role(MANAGER).implies(USER) .build(); }配置之后拥有ADMIN角色的用户自动拥有MANAGER和USER的所有权限不需要在数据库里给ADMIN重复分配细粒度权限点。这个机制能减少很多冗余配置。再涉及动态权限模型。如果权限点特别多不想在每个方法上写死注解可以把权限规则存到数据库里让过滤器动态判断。常见做法是自定义一个AuthorizationManager每次请求时从数据库加载该URL需要的权限再和当前用户的GrantedAuthority做比对。这种方式灵活但要注意缓存否则每个请求都查一次数据库并发上来之后数据库压力比较大。5. 真实项目中绕不开的进阶配置5.1 无状态会话与JWT接入思路Spring Security默认基于Session保存登录状态也就是用户登录后服务端会生成Session ID浏览器通过Cookie携带。但前后端分离项目、App接口场景里无状态JWT认证变成了主流。JWT的核心思路是登录成功后由服务端签发一个包含用户信息和过期时间的令牌客户端存起来每次请求时在Authorization请求头里带上Bearer token。服务端不再保存会话状态每次请求只验签令牌即可。在Spring Security里接JWT本质不是改造认证流程而是往过滤链里插入一个自定义过滤器。流程通常是这样写一个JwtAuthenticationFilter继承OncePerRequestFilter在请求进来时解析Authorization头拿到JWT字符串。用密钥验签从JWT里解析出用户名和权限信息。构造一个UsernamePasswordAuthenticationToken塞进SecurityContext。在SecurityFilterChain里用http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)把这个过滤器插到合适的位置。过滤器的插入位置很关键必须在UsernamePasswordAuthenticationFilter之前执行这样后续的AuthorizationFilter才能拿到完整的认证信息。我当时第一次接JWT时没注意顺序结果Spring Security一直认为用户未认证排查了老半天最后发现就是addFilterBefore用错了位置。至于session管理需要在SecurityFilterChain里关闭并改策略http.sessionManagement(session - session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) );STATELESS表示服务端不再创建会话适合纯接口服务。需要注意的是如果项目里还有页面跳转逻辑不要全局关闭session否则表单登录的会话保持会失效。5.2 CSRF与CORS两个容易搞混的安全配置CSRF跨站请求伪造是Spring Security默认开启的一个防护机制它会给渲染出的表单加一个随机token字段提交时校验。纯前后端分离的接口服务如果走自定义请求头携带JWT认证开启CSRF意义不大反而会导致没有token的POST请求被拒绝。所以接口项目通常会关闭http.csrf(csrf - csrf.disable());但如果是服务端渲染的网页项目不建议关闭。你自己实现CSRF Token管理反而容易出漏洞不如用框架内置的机制。CORS跨域资源共享是浏览器的跨域访问策略和CSRF是两个维度的东西。前后端分离项目需要配置允许跨域访问的域名、方法和请求头http.cors(cors - cors.configurationSource(corsConfigurationSource())); Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(http://localhost:*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }这里有个使用细节如果开启allowCredentials(true)addAllowedOrigin不能用*必须使用具体的域名或addAllowedOriginPattern否则浏览器会报错。这个坑当年让前端同事怀疑人生折腾了很久才发现是Credentials和通配符不兼容。5.3 登录成功与失败的处理策略默认的表单登录成功后会重定向到之前访问的页面登录失败则跳回登录页并携带错误参数。前后端分离项目里接口返回JSON更常见。定制方式很简单http.formLogin(form - form .successHandler((request, response, authentication) - { response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:0,\msg\:\登录成功\}); }) .failureHandler((request, response, exception) - { response.setContentType(application/json;charsetUTF-8); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write({\code\:401,\msg\:\用户名或密码错误\}); }) );同样未登录时访问受保护资源默认情况下页面请求会跳转登录页接口请求会返回403甚至302。想让接口统一返回401 JSON可以配置AuthenticationEntryPointhttp.exceptionHandling(ex - ex .authenticationEntryPoint((request, response, authException) - { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录\}); }) );这里的核心认知是Spring Security把“未认证”和“无权限”分开管理。未认证通常走AuthenticationEntryPoint无权限走AccessDeniedHandler。如果只配了一个另一个场景还是会走默认逻辑所以两个都要盯着配齐。6. 常见问题与排查技巧实录6.1 接口一直403但用户名密码明明是对的这个问题我至少被问过几十次几乎都是下面几个原因之一用户拥有的权限和配置有冲突。比如配置hasRole(ADMIN)但用户是USER角色403是正常的。角色前缀对不上。hasRole(ADMIN)实际校验的是ROLE_ADMIN如果通过authorities(ADMIN)设置的权限就匹配不上。多个SecurityFilterChain之间的顺序有冲突。Spring Security支持多规则链但规则链有优先级顺序如果你的应用配置了两条链请求可能被前面的链先拦截。没加EnableMethodSecurity方法上的PreAuthorize完全不生效这种情况报的错是AccessDeniedException但配置也没问题很让人抓狂。排查时我通常先看异常日志里抛出的异常类型。AuthenticationException说明认证没过AccessDeniedException说明权限不够方向不同排查路径完全不同。6.2 密码报错There is no PasswordEncoder mapped for the id null这是Spring Security 5之后最容易遇到的启动或登录期错误原因是配置里使用了默认的PasswordEncoder但用户密码没按指定格式存储。如果非要用明文密码可以在密码前加{noop}前缀比如{noop}123456。但我不建议这么干生产环境明文密码等于裸奔。正确的做法是定义一个BCryptPasswordEncoder的Bean然后注册用户时统一用它加密密码。这样数据库里存的密码是类似$2a$10$...的BCrypt哈希登录时DaoAuthenticationProvider会自动用这个Bean去匹配。这里再分享一个调试技巧如果登录总失败可以在自定义UserDetailsService里打印一下数据库查出来的密码和用户输入的密码确认格式是否一致。很多时候不是加密器的问题而是注册用户时忘了加密把明文塞进数据库登录时拿BCrypt去对比自然永远不匹配。6.3 过滤链配置与顺序相关的问题配置了自定义过滤器后发现无论如何都不生效优先检查两件事第一SecurityFilterChain是否真的被Spring管理到了。如果类没有加Configuration或者SecurityFilterChain的Bean方法没被扫描到整个配置就是空转。第二自定义过滤器的注入位置。http.addFilterAt是添加到某个过滤器相同位置http.addFilterBefore是插到前面http.addFilterAfter是插到后面。位置错了认证信息就传不到后续过滤器链中。另外Spring Boot 3里Filter是jakarta.servlet包下的别引成javax.servlet否则Bean注册时类型对不上启动也会报错。这个低级错误在换了Boot版本后尤其容易踩到。6.4 排查问题常用的辅助手段Spring Security的调试不像普通代码那么直观但它也留了一些后门。最直接的是在配置类上加EnableWebSecurity(debug true)启动日志会打印完整的安全过滤器链包括当前启用了哪些过滤器以及它们的顺序。看这份日志能快速了解框架认为的过滤器链长什么样。第二个手段是看异常堆栈。Spring Security的异常经常包装了好几层但关键信息都在最底层比如BadCredentialsException、InsufficientAuthenticationException、AccessDeniedException看到类型就能锁定是大类问题。再就是建议写集成测试而不是纯靠浏览器点。用MockMvc模拟请求直接断言状态码和响应体能把很多配置问题在开发阶段就暴露出来Test void accessProtectedUrlWithoutLogin() throws Exception { mockMvc.perform(get(/admin)) .andExpect(status().isUnauthorized()); }这类测试成本低、反馈快我们团队现在几乎成了标配。我个人在实际操作中的体会是Spring Security最大的学习门槛不在功能本身而在它的抽象层次和过滤器链思维。如果你只是照着配置文档敲一遍项目能跑起来但换个需求就不知道该动哪。所以初学阶段不要怕繁琐把SecurityFilterChain、AuthenticationManager、UserDetailsService这几个关键组件的职责记熟再对照过滤链日志去理解流程入门速度会快很多。最后再分享一个小技巧遇到任何Spring Security的诡异问题强烈建议先打断点看SecurityContext里有没有Authentication对象八成问题都能从这一步顺藤摸瓜找到答案。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价