Spring Security 的配置网上教程一抓一大把但很多都是抄来抄去要么止步于“能跑通”要么直接给你扔一个巨复杂的全量配置然后什么都不解释。我自己从 Spring Boot 2.x 一路用到现在踩过的坑比看过的文档还多这篇就把配置这件事从原理到实操彻底讲明白。这篇东西适合谁看刚接触 Spring Security 想搭一个登录认证功能的人被各种 SecurityFilterChain、UserDetailsService、BCrypt 搞晕的人以及想把“只会在本地跑”的 Demo 改成“能在生产环境见人”的配置的人。我会从最原始的配置形态讲起再逐步叠加数据库用户、权限控制、记住我、会话管理这些真实项目中躲不掉的功能全程给代码、给参数、给原因。1. 配置前需要想清楚的事Spring Security 到底在解决什么问题1.1 抛开配置谈原理认证与授权的本质我见过太多人一上来就照着网上的配置抄结果业务里一加接口就各种 401、403 乱飞。根本原因在于没搞懂 Spring Security 管的是哪两件事。第一件事叫认证Authentication解决的是“你是谁”的问题。系统收到请求后要先判断请求里带的凭证用户名密码、Token、Cookie 等能不能证明一个合法用户的身份。认证通过之后Spring Security 会在 SecurityContextHolder 里放一个 Authentication 对象里面记录了当前用户的身份信息和权限信息。这个对象默认存在 ThreadLocal 里——也就是说同一个线程里后续的所有代码都能直接拿到当前用户是谁。第二件事叫授权Authorization解决的是“你能干什么”的问题。认证通过不代表什么都能访问比如普通用户进不了管理后台运营人员改不了别人的订单。Spring Security 通过过滤器链上的一系列授权过滤器在请求进入 Controller 之前把当前用户的权限和你配置的访问规则进行比对不匹配就直接拒绝。这里必须提一个非常关键的设计Spring Security 的所有能力都是通过 Servlet 过滤器链FilterChain实现的。框架启动的时候会往容器里注册一条过滤器链请求进来后按顺序经过过滤器认证过滤器先干活授权过滤器再干活整条链都通过之后请求才最终到达业务代码。网上很多配置教程根本不提这条链所以大家经常出现这种情况明明配置了放行/login结果前端一请求还是被拦截了或者自定义了一个过滤器却始终不生效。这类问题几乎都和过滤器链的顺序、生效范围有关。所以后面讲任何一段配置我都会先点明它作用在链上的哪一个环节。1.2 五个最常见的误区在正式动手之前先把这些年我见过的高频误区列出来这些比配置本身更容易坑人。第一以为spring-security-config是万能的。很多人只在pom.xml里加了spring-boot-starter-security然后试图通过application.yml里的几行配置搞定一切。实际上Spring Boot 对 Spring Security 的自动配置只是一个很基础的兜底方案真正的业务场景几乎都要写 Java 配置类。第二以为放行了页面就万事大吉。很多人配置了.antMatchers(/login).permitAll()结果登录请求还是报 403。这就是没搞清楚静态资源、错误页、预检请求OPTIONS这些都需要单独处理。尤其在前后端分离的项目里CORS 预检请求如果没放行前端连登录接口都调不通。第三不启用 CSRF 防护却不知道为什么。Spring Security 默认开启 CSRF 防护这在传统表单登录的 MVC 项目里是对的但很多前后端分离项目用 Token 做认证仍然带着默认配置导致 POST 请求一直 403。不是说不该开 CSRF而是你要知道你做的到底是什么形态的项目再决定这一步怎么处理。第四把密码加密当可选项。我从见过有教程直接存明文密码的放在几分钟就能跑通的 Demo 里没问题但一旦有人拿去接到真实业务里那就是事故。密码加密不是配置项的事是要你在业务里动手的。第五试图在一个配置类里塞下所有东西。我自己刚开始也这么干过把所有过滤规则、认证逻辑、权限配置全写在一个类里看着很爽维护起来简直就是噩梦。后面会讲怎么拆分。2. 最小可用配置从零搭建一条可运行的认证链路2.1 依赖引入与版本选择先解决依赖的问题。我用的是 Spring Boot 3.x对应 Spring Security 6.x。如果你是 Spring Boot 2.x 的项目那对应 Spring Security 5.xAPI 上有几个不大不小的差异后面我会在关键位置标注出来。用 Maven 的话最直接的方式是引入dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency只加这一个依赖Spring Boot 就会帮你自动装配一系列东西默认用户名为user密码在启动日志里随机生成所有接口默认都需要认证默认启用表单登录和 HTTP Basic 认证。这个状态就是框架的“安全默认值”它的意义在于保证你哪怕什么都不写应用也处于一个相对安全的状态。如果你只想在自己的项目里用它的注解或部分能力可以选择引入spring-security-config和spring-security-web但日常开发中直接用 starter 就够了。我建议版本跟随 Spring Boot 的 BOM 管理不要自己填版本号。自己指定版本很容易和 Spring Boot 的自动配置冲突出一些你根本不想排查的问题。2.2 最简配置类SecurityFilterChain 的正确写法有了依赖之后核心配置类长这样Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/login, /css/**, /js/**).permitAll() .anyRequest().authenticated() ) .formLogin(form - form .loginPage(/login) .permitAll() ) .logout(logout - logout .logoutUrl(/logout) .logoutSuccessUrl(/login?logout) ); return http.build(); } }这段配置的关键点我逐个说。EnableWebSecurity这个注解是整套机制的触发器。它开启了一个叫WebSecurityConfiguration的后置处理Spring 会把容器里所有SecurityFilterChain类型的 Bean 收集起来组成过滤器链列表。注意Spring Boot 的自动配置会有一个默认的SecurityFilterChain如果你自己定义了那么默认的会被你的覆盖或追加取决于条件注解的判断。所以只要你自己声明了一个SecurityFilterChainBean很多默认行为就不会生效了比如默认生成的随机密码用户。authorizeHttpRequests是 5.4 之后的新写法。它接收一个AuthorizeHttpRequestsConfigurer里面配置的是请求路径和权限规则。我以前用的antMatchers在 Spring Security 6 里已经被移除了统一改成了requestMatchers。注意它支持 Ant 风格路径匹配也支持正则和一些 MVC 匹配方式默认是 MVC 匹配。直接把.anyRequest().authenticated()放最后一条意思是所有未在前面匹配到的请求都需要登录——这条规则我是强烈建议保留的不要随手删掉。formLogin配置表单登录。loginPage(/login)是自定义登录页面的地址这个地址本身不需要写处理逻辑Spring Security 会负责在 GET 请求这个地址时渲染一个默认登录页如果没指定模板引擎的话并在 POST 请求这个地址时做用户名密码校验默认接收参数名是username和password。如果你用前后端分离这里通常就不走表单登录了而是改成一个接收 JSON 的登录接口。有一点很多人没注意到loginPage(/login)一经指定Spring Security 会认为登录页、登录失败页、注销相关页面都归它管所以你要确保/login这个地址能访问到你的登录页否则会出现“输入地址直接 403”的情况。2.3 表单登录与内存用户的完整实操有了上面的配置还需要提供一个用户来源。最简单的方式是使用内存用户Bean public UserDetailsService userDetailsService() { UserDetails user User.withUsername(admin) .password({noop}123456) .roles(ADMIN) .build(); return new InMemoryUserDetailsManager(user); }注意这里面的{noop}前缀。Spring Security 5.x 之后的版本不再允许在代码里直接存明文密码{noop}是告诉PasswordEncoder的委托机制“这个密码没有编码直接比对原文”。正式项目中别这么干这只是演示。更推荐的写法是配置一个PasswordEncoderBeanBean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }这时候内存用户的密码就用 BCrypt 加密后的字符串UserDetails user User.withUsername(admin) .password($2a$10$...) // BCrypt 加密后的串 .roles(ADMIN) .build();把整条链路串起来看请求先经过UsernamePasswordAuthenticationFilter它从请求参数里拿到用户名密码交给AuthenticationManager去认证认证成功之后把Authentication放进SecurityContext然后继续走到授权过滤器判断这个用户有没有权限访问目标路径。从我个人经验来说这个最小配置是学习 Spring Security 设计的最好起点。你不需要关心复杂业务仅通过这几个 Bean 就能把“认证”和“授权”从框架里“拉”出来看清楚。等把这条链路摸熟了后续加数据库、加 JWT、加 OAuth2 都是在往链上挂新的“处理器”而已。3. 数据库用户与密码加密把配置从演示变成生产可用3.1 UserDetailsService 的实现细节内存用户只能用于测试真实项目里用户都是存在数据库里的。这个时候要做的事是提供一个UserDetailsService的实现Spring Security 在认证过程中默认会调用它的loadUserByUsername(String username)方法根据用户名查出用户信息然后去比对密码。直接上代码Service public class DatabaseUserDetailsService implements UserDetailsService { private final UserMapper userMapper; public DatabaseUserDetailsService(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.builder() .username(user.getUsername()) .password(user.getPassword()) .roles(user.getRoles().split(,)) .disabled(user.getStatus() 0) .build(); } }这里有几个关键点要特别注意。第一loadUserByUsername返回的UserDetails对象里的password字段必须是数据库里存储的加密后的密码而不是明文。Spring Security 会拿用户输入的明文密码结合你配置的PasswordEncoder对数据库里的密文进行比对。这里涉及到一个底层逻辑BCryptPasswordEncoder.matches(明文, 密文)它会把明文重新加盐哈希然后和密文比较。第二用户的状态字段处理。disabled(true)会让该用户即使密码正确也无法登录认证管理器会在校验时抛出DisabledException。对账号锁定、过期这类状态UserDetails接口里也都有对应方法。第三loadUserByUsername抛出的异常类型。很多人直接返回null表示用户不存在这样不对。框架对你返回null的处理不如抛异常来得干净而且抛异常能让你有机会在全局异常处理里做业务上的提示比如“用户不存在”和“密码错误”要不要区分开这是产品策略问题但底层机制你得用对。还有一点如果你用 JPA、MyBatis-Plus 这类 ORM 框架务必注意延迟加载问题。UserDetails里的getAuthorities()方法在认证过程中会被多次调用如果用户角色关联的是一个延迟加载的集合但此时 Session 已经被关闭就会抛LazyInitializationException。解决方案一般是把角色信息提前查询好放进内存或者把关联关系改成 EAGER 加载。3.2 密码加密方案选型与 BCrypt 使用要点密码加密这个问题我单独拿出来讲因为它在配置中的位置非常关键而且坑特别多。Spring Security 提供了一个PasswordEncoder接口所有密码比对都通过它来完成。在 Spring Security 5 之后默认的PasswordEncoder是一个委派机制DelegatingPasswordEncoder它能根据密文的前缀自动识别加密算法。比如{bcrypt}开头的密文走 BCrypt{noop}开头的走明文比对{md5}开头的走 MD5。这个设计是为了兼容历史代码和数据库里的存量密码但也经常造成混乱。我在实际项目中的建议是搞清楚你现有的密码存储情况。全新项目直接用 BCrypt没有悬念。选 BCrypt 的原因很简单它内置了随机盐值能抵抗彩虹表攻击它的计算过程是可调节强度的默认强度 10在安全性和计算耗时之间取得了平衡它是目前 Java 生态里支持最好、资料最多的方案。配置方式Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }这里需要注意一个顺序问题一旦你显式声明了PasswordEncoderBean之前内存用户里的{noop}前缀写法就不再适用了因为框架会选择你声明的这个 Bean 使用。不过理论上它仍然会先看前缀但建议统一用 BCrypt。如果你需要迁移历史用户比如以前是用 MD5 存的有个过渡期的做法配置两个PasswordEncoder用Map指定前缀映射默认用BCryptPasswordEncoderBean public PasswordEncoder passwordEncoder() { String idForEncode bcrypt; MapString, PasswordEncoder encoders new HashMap(); encoders.put(bcrypt, new BCryptPasswordEncoder()); encoders.put(md5, new MessageDigestPasswordEncoder(MD5)); return new DelegatingPasswordEncoder(idForEncode, encoders); }这种配置下新密码全部用 BCrypt 加密老用户的 MD5 密文也能验证通过。等所有老用户都改用新密码之后再从代码里移除 MD5 支持。有一个实操细节值得单独提醒BCrypt 生成的密文是 60 个字符如果你数据库里的密码字段长度小于 60用户注册的时候就会报数据库字段溢出错误。别问我是怎么知道的。password字段长度直接设为 64 或 128一劳永逸。3.3 动态权限配置与 PreAuthorize 注解数据库用户接进来之后认证已经没问题了但授权这块通常还有一个升级大部分业务系统不是简单地把所有接口分成“登录才能看”和“匿名能看”两类而是分为“管理员才能看”“运营才能看”“用户本人才能看”这种细粒度权限。Spring Security 处理这种需求有两条路。一条是全局过滤器里的 URL 授权规则适合路径型的权限判断另一条是方法安全注解适合业务逻辑型的权限判断。先说 URL 授权规则的写法http.authorizeHttpRequests(auth - auth .requestMatchers(/admin/**).hasRole(ADMIN) .requestMatchers(/user/**).hasAnyRole(USER, ADMIN) .requestMatchers(/public/**).permitAll() .anyRequest().authenticated() );注意hasRole(ADMIN)和hasAuthority(ROLE_ADMIN)的区别。hasRole是简写形式它会在内部给权限加上ROLE_前缀再比对。也就是说你的用户信息里授权的集合元素必须是ROLE_ADMIN才能通过。而hasAuthority是精确比对不会加前缀。再说方法安全注解。要启用注解需要在配置类上加EnableGlobalMethodSecurity(prePostEnabled true)Spring Security 6 中是EnableMethodSecurityConfiguration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { // ... }然后在业务方法上直接写PreAuthorize(hasRole(ADMIN)) public void deleteUser(Long userId) { // ... }也可以结合 SpEL 做更细粒度的控制PreAuthorize(hasRole(ADMIN) or #userId authentication.principal.id) public void updateUser(Long userId) { // ... }#userId是方法参数的引用authentication是 SpEL 里默认暴露的对象通过它可以拿到当前认证信息。这种表达方式在“用户只能操作自己的数据”这种场景里非常实用。我遇到最典型的坑是注解加上了但完全不生效。排查思路很固定先确认EnableMethodSecurity有再确认 SecurityFilterChain 的 Bean 已经创建成功最后确认调用过程走的是 Spring 代理对象——如果这个方法是在同一个类的内部被this调用那自调用的时候注解是不生效的。这是 AOP 的通病谈不上是 Spring Security 的问题。4. 记住我、会话管理与常见问题排查实录4.1 记住我功能的配置与实现“记住我”这个概念在 Spring Security 里的实现和很多人的直觉不一样。它并不是简单地把remember-me参数记录下来而是通过一个专门的RememberMeAuthenticationFilter来实现的。原理是这样的登录成功后框架生成一个加密的 Cookie下次访问时过滤器从 Cookie 里解析出用户信息完成认证。这样就算 Session 过期了用户仍然能通过 Cookie 重新建立身份。最简单的配置是在 SecurityFilterChain 里加上http.rememberMe(remember - remember .key(unique-and-secret-key) .tokenValiditySeconds(7 * 24 * 60 * 60) );这里key是用于签名 Cookie 的密钥绝对不能用默认值否则任何人都能伪造记住我 Cookie。tokenValiditySeconds是 Cookie 的有效期7 天是常见的配置具体长短看业务需求。还有一种基于数据库存储的方案用的是PersistentTokenBasedRememberMeServices。这种方案会在登录时把 token 存进数据库表每次校验时查库比默认的基于散列的做法安全性更高一些代价是每次请求都多一次数据库查询。如果系统对安全性要求高这个开销是值得的。使用记住我功能有一个经典问题用户明明勾选了“记住我”但每次打开浏览器还是要求重新登录。排查时先确认是不是设置了tokenValiditySeconds为 0这是很多人踩的坑——他们把它当成“永不过期”实际上 0 意味着立即失效。另一个坑是浏览器禁用了第三方 Cookie或者前后端分离时没有配置 Cookie 的跨域携带属性这些都会让记住我功能在表面上“没反应”。4.2 会话管理与并发登录控制讲完记住我再说会话管理。实际系统里“同一个用户能不能在多个设备上同时登录”是产品经理一定会问的问题。Spring Security 原生支持这个控制。先在 SecurityFilterChain 里显式配置 Session 策略http.sessionManagement(session - session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .maximumSessions(1) .maxSessionsPreventsLogin(true) );maximumSessions(1)限制单用户最多一个有效会话。maxSessionsPreventsLogin(true)的含义是当这个用户已经有一个有效会话时新登录会被直接拒绝。如果设成false则是“顶号”模式——新登录会把最老的那个会话踢掉。需要注意一个问题maximumSessions(1)生效的前提是账密认证通过之后用户信息里有稳定的用户名用来识别。如果多个用户共享同一个账号现实中总有这种情况那并发控制就等于是摆设。另外这个控制是通过监听SessionRegistry来实现的当检测到 session 超时时需要在代码里显式调用sessionRegistry.removeSessionInformation()否则过期记录会一直留在内存里。我自己在项目中还遇到过这样一个场景用户修改密码后希望强制他所有已登录的终端都下线。这个需求仅仅配置maximumSessions实现不了需要在修改密码成功后遍历SessionRegistry拿到该用户的所有 Session然后手动失效。这种动态管理能力依赖于你把SessionRegistry暴露成 BeanBean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); }然后注入到你的用户服务里使用。4.3 常见问题速查与排查实录这一部分我把这几年被问得最多的问题和排查方法整理成一个速查表配置出错的时候建议按表格里的思路来。现象大概率原因排查方向启动后所有接口都要求登录没有任何页面依赖里引入了 starter-security但没写 SecurityFilterChain检查控制台启动日志里的随机密码确认是否走了默认配置POST 请求报 403CSRF 防护拦截前后端分离项目考虑关闭 CSRF或让前端在请求头附带 CSRF Token登录成功后跳转 404没配置登录成功后的跳转页面或接口检查默认成功处理器逻辑确认用户角色对应的首页地址静态资源被拦截只放行了登录页没放行静态资源在 requestMatchers 里加入/css/**,/js/**,/images/**自定义过滤器不生效过滤器没有被加入 SecurityFilterChain用.addFilterBefore()或.addFilterAfter()手动指定位置方法注解不生效没加EnableMethodSecurity检查配置类注解确认放置位置正确内存用户登录失败密码不是加密格式或没指定 PasswordEncoder检查{noop}前缀或密码 Hash 是否匹配记住我 Cookie 失效太快tokenValiditySeconds设置不当检查是否设置了 0检查前后端 Cookie 携带配置再补一个我最近排查过的小问题配置了.permitAll()的接口居然还是被拦了。查到最后发现是过滤器顺序的问题——某个自定义过滤器在认证过滤器之前执行它直接抛了异常导致请求根本没走到授权环节。所以如果你确认配置正确但还是被拦别死盯着权限配置把断点打在过滤器链上逐个看是哪个环节拒绝的这是最有效率的排查方式。最后再分享一个实用小技巧Spring Security 6.x 里有一个调试特性在application.yml里配置logging: level: org.springframework.security: DEBUG打开之后你能在日志里看到请求经过的过滤器链、每个过滤器做了什么事、认证失败的具体原因。这个日志对排查问题帮助极大我第一次用的时候直接把一个困扰两天的 401 问题定位到了请求头缺失上。5. 配置文件的模块化拆解从一个类到一套维护体系如果你只是写个 Demo把所有配置塞进一个 SecurityConfig 类里完全没问题。但真实项目里一个类的体量会膨胀得特别快——认证、授权、记住我、会话管理、CORS、异常处理、过滤器注册、密码编码器全部堆在一起光找配置项就得翻半天。我自己的习惯是拆成下面几个模块SecurityConfig主配置类只做过滤器链的组装声明 Bean 引用其他模块。SecurityProperties用ConfigurationProperties管理的配置项包括放行 URL、Token 有效期、Cookie 域名、CORS 允许的源等。RestAuthenticationEntryPoint认证失败的统一返回不跳登录页而是返回 JSON。RestAccessDeniedHandler权限不足的统一返回。DatabaseUserDetailsService用户信息的数据库加载逻辑。PasswordEncoderConfig独立的密码编码器配置。LoginSuccessHandler/LoginFailureHandler登录成功后的跳转或返回逻辑。拆分的核心原则是“配置与逻辑分离”SecurityConfig 里只留规则把具体的处理逻辑交到不同的组件里。这样以后换密码方案、换认证方式都只要替换对应的类不用动主配置。举个例子前后端分离项目里登录接口通常是一个 POST JSON 请求需要自定义成功失败处理器。代码大概长这样Component public class JsonLoginSuccessHandler implements AuthenticationSuccessHandler { private final ObjectMapper objectMapper; public JsonLoginSuccessHandler(ObjectMapper objectMapper) { this.objectMapper objectMapper; } Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { response.setContentType(application/json;charsetUTF-8); response.getWriter().write(objectMapper.writeValueAsString( Map.of(code, 0, message, 登录成功, data, authentication.getName()) )); } }然后在配置类里.formLogin(form - form .loginProcessingUrl(/api/login) .usernameParameter(username) .passwordParameter(password) .successHandler(jsonLoginSuccessHandler) .failureHandler(jsonLoginFailureHandler) )这一段配置值注意的是loginProcessingUrl和自定义 Controller 的关系。很多新手会额外写一个PostMapping(/api/login)的 Controller结果根本不会被调用。Spring Security 的过滤器会在请求到达 DispatcherServlet 之前就处理掉这个地址上的认证逻辑业务 Controller 压根没有机会执行。所以要么用框架的登录处理要么完全禁用它自己写不要混着来。分离成这样之后配置类的可读性会高很多。而且我自己的经验是拆完模块以后新增一种认证方式比如增加手机验证码登录不需要动主过滤链只需要新增一个过滤器加在 UsernamePasswordAuthenticationFilter 之前代码结构清晰排查问题也方便。6. 最后再分享一点实操体会写了很多配置最后说点心得。Spring Security 的配置本质上是不断往过滤器链上挂处理器的过程。只要理解了这条链上“认证过滤器负责确认身份、授权过滤器负责检查权限、异常处理器负责输出错误”的流水线模型再复杂的配置也能拆解成一个个小零件。我个人最早学 Spring Security 的时候老是想把所有功能一次性配齐结果遇到错误不知道是哪个环节出的问题。后来换了思路每次只加一个功能加完就跑一次测试确认没问题再继续。这样虽然慢但每一步都踏实遇到问题也能快速定位。如果你准备投入真实项目我给一个低成本的建议先不接数据库用内存用户把认证链路跑通然后换成数据库用户再然后加 URL 权限规则再然后加方法注解最后加记住我和会话控制。每步之间都不要跳。这套路径走下来你对 Spring Security 的掌控力会有质的提升而不是停留在“配完跑通”的层面。下一篇如果有机会我会接着写“从表单认证平滑切换到 JWT”也会讲自定义过滤器怎么放才安全。有问题直接评论我会尽量回复。
