如果你打开过Spring Boot项目的启动日志,大概率见过这样一行:Using generated security password: 一串随机字符。第一次见的时候,很多人以为是某个中间件随手打的日志,直到访问任意接口被重定向到登录页才意识到——Spring Security已经被悄悄塞进项目里了。
这种体验几乎成了 Java Web 开发者的共同记忆。Spring Security 作为 Spring 生态里的安全基础设施,覆盖了登录认证、用户权限、接口保护、密码加密这些绕不开的问题。这篇是系列的入门篇,我会把"基础概念"和"快速入门"两件事讲透:先搞清楚它到底解决了什么问题、内部骨架长什么样,再亲手跑通一个最小可用的登录应用。适合第一次接触 Spring Security、或者已经配置成功但说不清原理的人。
1. 为什么Java Web项目绕不开Spring Security
1.1 没有框架的年代,认证代码是怎么写的
聊 Spring Security 之前,先回忆一下没有安全框架的日子。
早期做 Servlet 项目,登录检查基本靠"约定"。每个需要登录的接口,入口处先取 Session,判断里面有没有当前用户对象,没有就response.sendRedirect("/login")。接口一多,这段逻辑就会被复制到几十个地方。更麻烦的是权限升级:比如某天产品说"这个页面普通用户也能看了",你得去翻代码,找到对应的判断逻辑,逐个改if条件。
除了重复代码,还有一堆容易踩的细节。Session 超时后要区分"未登录"和"登录过期";密码存数据库时直接明文,或者用不带盐的 MD5 糊弄;CSRF 防御没人管,后端接口裸奔。这些问题不是不能自己解决,但每做一个项目就要重写一遍,写出来的方案还未必经过安全验证。
我自己经历过一个真实场景:某内部系统把所有接口都放在一个拦截器里做权限判断,结果新来的同事不知道这个约定,新加的几个接口没有放进拦截路径,上线后数据泄露。这种"靠自觉"的安全策略,迟早出事。
1.2 Spring Security把安全问题拆成了两件大事
Spring Security 的核心,其实就是把"安全"这件事拆成了两个问题:认证(Authentication)和授权(Authorization)。
认证解决的是"你是谁"。你提交用户名密码,系统校验凭证,确认身份成立,然后在后续请求中持续识别你。授权解决的是"你能干什么"。身份确认之后,系统根据你的角色或权限,决定哪些接口能访问、哪些操作能执行。举个门禁卡的例子:刷卡识别身份是认证,确认这张卡能开哪些门是授权。两者先先后后,缺一不可。
拆开之后,Spring Security 把大量的公共细节都扛了下来:Session 管理、登录页生成、密码加密、请求重定向、异常捕获、CSRF 防护,甚至连"密码错误还是用户不存在"这种信息是否暴露给前端,都替你想好了。开发者只需要关心业务层面的"用户从哪里来、权限怎么定义",剩下的交给框架。
1.3 为什么Java社区最终收敛到它
Java 生态里安全框架不止一个,但 Spring Security 几乎是事实标准。原因也挺简单:它和 Spring Boot 的自动装配配合得太好了,加一个依赖就能获得一套完整的默认安全策略,学习曲线可以很平缓;需要定制的时候,又通过过滤器链和一系列扩展点提供了足够深的弹性。
我自己用下来的感受是,Spring Security 的抽象很"正"。它没有堆砌一堆炫技的 API,而是把认证、授权、会话、异常这些概念老老实实地建模。你花一个下午看懂它的过滤器链,后面所有配置看起来都是顺理成章的。
1.4 这个系列打算怎么讲
这篇是系列的第一篇,定位在"概念 + 最小可用"。我会先带你建立骨架级认知,再跑通一个带登录页的 Spring Boot 应用。后续系列会逐步深入:数据库用户存储、自定义登录页、方法级安全、OAuth2 接入、微服务场景下的安全方案。下一篇预告会在结尾给出,现在先专注把地基打牢。
2. 骨架级认知:请求在过滤器链里经历了什么
2.1 加一行依赖后,启动日志里的随机密码是哪来的
新建一个 Spring Boot 项目,往pom.xml里加一行:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>启动项目,控制台会打印一个随机密码。这个密码是框架自动生成的,用于内置的默认用户user。不做任何配置的情况下,Spring Boot 已经帮你完成了三件事:所有接口默认需要认证、提供一个默认的表单登录页、支持基于 Session 的登录态保持。
这背后是 Spring Boot 的自动装配在起作用。它注册了一条默认的SecurityFilterChain,把安全策略接进了 Servlet 的过滤器链路。你要做的,是在这条默认链上做定制,而不是从零搭一套。
2.2 过滤器链是一条安检流水线
Servlet 规范里,Filter可以在请求到达 Controller 之前和响应返回客户端之前插入逻辑。多个过滤器按顺序组成一条链。你可以把它想象成机场安检通道:你的请求是乘客,进入候机区前要依次经过行李扫描、身份核验、登机牌查验,任何一环不过都会被拦下来。
Spring Security 就是在这条链上塞了一组过滤器。入门阶段你需要认识几个关键角色:
SecurityContextHolderFilter:负责在请求开始时从 Session 恢复登录身份,请求结束时保存回去UsernamePasswordAuthenticationFilter:处理表单登录请求,解析用户名密码并执行认证AnonymousAuthenticationFilter:没有登录的请求会被赋予一个匿名身份,方便后续统一处理ExceptionTranslationFilter:捕获认证和授权阶段的异常,决定是重定向到登录页还是返回 403AuthorizationFilter:做最终的授权判断,确认当前身份是否有权限访问目标接口
这些过滤器的先后顺序是有讲究的。身份恢复必须最早做,因为后面的过滤器都要用到当前用户信息;授权判断必须最晚做,因为要先确认身份再谈权限。
2.3 Authentication里到底装了什么
认证过程中最核心的对象是Authentication。它像一个装了三个格子的盒子:
| 字段 | 含义 | 例子 |
|---|---|---|
principal | 身份主体 | 登录用户对象 |
credentials | 凭证信息 | 密码(认证前有值,认证后一般清空) |
authorities | 权限列表 | ROLE_ADMIN、ROLE_USER |
以用户名密码登录为例。UsernamePasswordAuthenticationFilter拿到请求里的用户名和密码后,会构造一个UsernamePasswordAuthenticationToken对象。注意这个名字里的"Token"不是那种 JWT Token,它只是Authentication的一个实现,表示"这是一个待认证的身份"。
这个对象带着用户输入的凭证,进入AuthenticationManager。AuthenticationManager会找合适的AuthenticationProvider来实际执行校验,比如DaoAuthenticationProvider负责去UserDetailsService里加载用户,再用PasswordEncoder比对密码。比对通过后,原来那个未认证的Authentication对象会被替换成一个已认证的、包含完整权限信息的对象。
2.4 SecurityContext和ThreadLocal的秘密
认证通过后,身份信息要存到一个地方,供后续流程使用。Spring Security 的做法是先把已认证的Authentication放进SecurityContext,再把SecurityContext放进SecurityContextHolder。
SecurityContextHolder默认使用ThreadLocal存储。ThreadLocal可以理解为"每个线程专属的储物柜":同一个线程里,任何代码都能从里面拿到当前用户信息,但别的线程拿不到。这带来了一个隐患:如果你在业务代码里新开了线程,或者用了异步调用,子线程默认是拿不到父线程里那份SecurityContext的。
Spring Security 6.x 时代,SecurityContextHolderFilter承担了核心的读写职责:请求开始时,从SecurityContextRepository(默认基于 HttpSession 实现)读取身份信息,放进ThreadLocal;请求结束时,再写回仓库并清理ThreadLocal,避免线程池复用导致信息串台。
2.5 授权过滤器怎么用权限做判断
身份有了,权限也有了,最后一关是AuthorizationFilter。它会根据你配置的安全规则,比对请求路径和当前身份的authorities。
这里有一个入门阶段很容易混淆的细节:hasRole("ADMIN")实际上查询的是ROLE_ADMIN这个权限,因为 Spring Security 约定角色必须以ROLE_开头。而hasAuthority("ADMIN")查询的就是单纯的ADMIN权限,不加前缀。这个差异看起来不起眼,实际踩坑的人不少,后面我会专门讲。
3. 快速跑通:一个自带登录页的最小应用
3.1 五分钟环境准备
开始实操前,确认你的环境:
- JDK 17 及以上(Spring Boot 3.x 的要求)
- Maven 3.6+ 或 Gradle
- 一个你顺手的 IDE
用 Spring Initializr 创建一个 Spring Boot 项目,依赖里勾选Spring Web和Spring Security。如果你更习惯手动写配置,pom.xml里加这两段即可:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>版本直接继承父工程的spring-boot-starter-parent就行。我建议不要手动指定 Spring Security 的版本号,避免和 Spring Boot 版本不匹配。
3.2 第一次启动:看默认行为
什么都不配置,直接启动应用。你会在控制台看到类似这样的输出:
Using generated security password: a1b2c3d4-e5f6-7890-...这时访问http://localhost:8080/,浏览器会 302 重定向到http://localhost:8080/login,一个简洁的登录页出现了。用户名是user,密码就是上面那串随机字符串。输入正确后,页面跳回原来的目标地址。
这就是默认安全策略的全部表现:所有路径需要认证、表单登录、基于 Session 的会话保持。你可以先亲手走一遍这个流程,感受一下"什么都不写就有了安全"是什么体验。
3.3 自定义安全配置和内存用户
默认行为只能用来体验,实际项目里肯定要自定义。最基础的定制,是定义一个SecurityFilterChain配置类,并指定用户存储。
我创建一个SecurityConfig类:
package com.example.demo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/home", "/css/**", "/js/**", "/images/**").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/index", true) .permitAll() ) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") .permitAll() ); return http.build(); } @Bean public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { UserDetails admin = User.builder() .username("admin") .password(passwordEncoder.encode("123456")) .roles("ADMIN") .build(); return new InMemoryUserDetailsManager(admin); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }这段配置做了几件事:放行首页和静态资源,其余接口必须认证;开启表单登录,登录成功后默认跳到/index;定义了一个内存用户admin,密码是 BCrypt 加密后的。
再写一个简单的测试 Controller:
package com.example.demo.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class HelloController { @GetMapping("/") public String home() { return "欢迎来到首页"; } @GetMapping("/index") public String index() { return "登录成功后的页面"; } @GetMapping("/admin") public String admin() { return "只有管理员能看到的页面"; } }这里的/admin暂时没有做角色限制,访问它只需要登录即可。后面讲到授权时会补上。
注意:入门阶段为了演示方便,可以先不关闭 CSRF。但如果你用 POST 接口测试登录后的操作,会碰到 403,这是 Spring Security 默认开启 CSRF 防护的结果。开发期可以在
http配置链上加一句.csrf(AbstractHttpConfigurer::disable),生产环境一定要保留。具体原因在第五章详细讲。
3.4 完整跑一遍登录流程
配置完成后,重新启动项目,用admin/123456走一遍完整流程:
- 访问
/hello,未登录状态下被重定向到/login - 在登录页输入用户名密码,提交
UsernamePasswordAuthenticationFilter拦截/loginPOST 请求,执行认证- 认证成功,框架把认证信息写入 Session,并重定向到之前想访问的地址(本例中配置了固定跳转
/index) - 浏览器带着 Session Cookie 再次访问
/hello,SecurityContextHolderFilter从 Session 恢复身份,请求正常放行
这一步建议配合调试器或 Debug 日志看一遍过滤器的执行顺序,比背十遍概念都管用。你在 IDE 里给UsernamePasswordAuthenticationFilter的doFilter方法打上断点,然后提交一次登录,就能清楚看到框架内部如何一步步处理你的凭证。
3.5 先别急着扩展,理解一下这段配置
很多教程到这里就直接讲 OAuth2、JWT 了,但我觉得入门阶段最该做的是理解那段SecurityFilterChain的 Lambda 写法。它本质上是对过滤器链的"组装说明":authorizeHttpRequests配置授权规则,formLogin配置登录流程,logout配置退出流程。每个方法对应的都是一组过滤器,你在链上看到的每一个"动作",背后都有对应的实现类。
看懂了这个对应关系,后面学习自定义登录页、调整会话策略、接入 OAuth2 都会轻松很多。它们是同一个骨架上的不同零件,而不是孤立的知识点。
4. 密码不能裸奔:PasswordEncoder与UserDetails的关键细节
4.1 为什么数据库存密码要格外小心
很多刚接触安全框架的人,会觉得"密码加密"是个锦上添花的选项——反正系统内网访问,泄露概率低。我见过不止一个项目把用户密码明文存进数据库,或者只做一次不加盐的 MD5。这类做法一旦发生数据泄露,风险是连锁性的:用户在其他平台可能用了同一套密码,攻击者拿着数据库就能横向撞库。
加密密码的核心目的,是让数据库泄露时攻击者也无法轻易还原原始密码。这里要注意,加盐哈希和普通 Hash 是两回事。MD5 的问题是计算速度太快,攻击者可以每秒尝试海量组合,还可以用彩虹表直接查明文。而 BCrypt 这类算法故意设计得"慢",并且每次加密自动带上随机盐,同样的密码每次生成的密文都不一样,彩虹表基本失效。
4.2 BCryptPasswordEncoder用起来其实很简单
Spring Security 里最省心的PasswordEncoder实现是BCryptPasswordEncoder。用法很简单:
PasswordEncoder encoder = new BCryptPasswordEncoder(); String encoded = encoder.encode("123456"); System.out.println(encoded); // $2a$10$7eq...,每次运行结果都不同 boolean matched = encoder.matches("123456", encoded); // true生成出来的字符串以$2a$10$开头,其中10是 cost 参数,代表计算强度。默认值是 10,这个值越大计算越慢,安全性越高,但也越消耗 CPU。普通应用用默认值足够了,硬件好、用户量大的场景可以调到 12,不建议超过 14,否则登录会明显卡顿。
顺便说一个容易困惑的点:User.builder().password(passwordEncoder.encode("123456"))生成的是不带前缀的 BCrypt 密文。但在一些示例代码里,你会看到密码字符串前面有{noop}这样的前缀。那是DelegatingPasswordEncoder在起作用,它根据前缀委托给对应的编码器。{noop}表示明文不加密,只用于演示或测试,生产环境千万别用。
4.3 UserDetails和InMemoryUserDetailsManager的真相
内存用户存储用的InMemoryUserDetailsManager,实现的是UserDetailsService接口——这个接口只有一个方法:loadUserByUsername(String username)。框架在认证时,会调用它按用户名查找用户,拿回一个UserDetails对象。
UserDetails接口里包含的字段比你想的多:用户名、密码、权限列表,以及四个返回boolean的方法——账号是否未过期、凭证是否未过期、账号是否未锁定、是否可用。这给了框架完整的能力去表达"账号冻结""密码过期需重置"这类状态。
实际项目中很少用InMemoryUserDetailsManager,但理解它的位置很重要。将来你做数据库版用户存储,本质上就是自己实现一个UserDetailsService,从数据库查用户后组装成UserDetails返回。这个扩展点在系列下一篇会专门讲。
4.4 涉及密码的几个新手雷区
关于密码,有几个我在答疑时反复遇到的情况,先列出来让你有心理准备:
- 不要为了省事用
{noop}:它只是让人能直接用明文密码启动项目,但会把"不设防"带入生产环境。 - 改编码器后旧密码全失效:如果你的系统以前用 MD5 存密码,换了
BCryptPasswordEncoder后,新代码用 BCrypt 匹配,旧密码永远匹配不上。平滑的做法是用DelegatingPasswordEncoder配置多种编码器:新密码用 BCrypt 编码,带前缀标识;旧密码用旧算法校验,用户下次登录成功后再升级存储格式。 - 数据库字段长度要留够:BCrypt 密文长度固定是 60 个字符,但有些字段设计成 32 位或 45 位放不下,保存时会报错。要么预留 68 位,要么用
varchar(255)。 User.builder()里不要直接放明文:password方法接收的是待存储的密文,框架不会帮你加密。你放明文,框架就存明文。
密码这块虽然基础,但直接影响整个系统的安全底线,值得花时间一次搞明白。
5. 快速入门必踩的几个坑和一套清晰的排查思路
5.1 请求总是跳回登录页?先按这条链路查
"我明明登录成功了,为什么访问某个接口又跳到登录页"——这是入门阶段最经典的问题。
我的排查习惯是按下述顺序来:
- 查配置是否生效:确认你写的
SecurityConfig被 Spring 扫描到了。类上必须有@Configuration,并确保它在启动类的扫描路径下。 - 查请求路径是否匹配:
requestMatchers写的路径和实际访问路径是否一致。比如放行了/user/**,但接口实际路径是/userInfo,那就不匹配。 - 查是否落到
anyRequest().authenticated():如果请求匹配了更具体的规则,后面更宽松的规则不会覆盖它,但反过来,没匹配到任何规则就落到兜底规则,被要求认证。 - 查会话是否正常:登录成功后,浏览器是否保存了 Session Cookie?如果你用
curl手动测试,不会自动携带 Cookie,每次请求自然都是"未登录"。
排查时把 Spring Security 的日志调到 DEBUG 会事半功倍:
logging: level: org.springframework.security: DEBUG日志里会明确告诉你:请求匹配了哪条规则、当前用户是否匿名、被哪个过滤器拦截。
5.2 hasRole、hasAuthority、roles到底怎么收尾
权限相关的方法名看着都差不多,实际规则很严格。还是那张表讲清楚:
| 配置写法 | 底层查询的权限 | 匹配的权限来源 |
|---|---|---|
hasRole("ADMIN") | ROLE_ADMIN | 用户拥有ROLE_ADMIN |
hasAuthority("ROLE_ADMIN") | ROLE_ADMIN | 用户拥有ROLE_ADMIN |
hasAuthority("ADMIN") | ADMIN | 用户拥有ADMIN(不带前缀) |
入口处用roles("ADMIN")构造用户,实际添加的权限是ROLE_ADMIN;用authorities("ADMIN")构造,添加的权限是ADMIN。如果你入口用了authorities("ADMIN"),接口规则却写hasRole("ADMIN"),结果就是 403。这类写成两边前缀不一致,是权限拦截最常见的低级错误。
5.3 一个典型的403:CSRF默认开启
刚接触 Spring Security 时,很多人会遇到一个诡异现象:登录页能正常打开,登录也能成功,但只要一提交 POST 请求,就 403。
这大概率是 CSRF 防护在起作用。CSRF 攻击的原理是:攻击者诱导已登录用户在不知情的情况下,向你的系统发送伪造请求。Spring Security 默认要求所有会改变状态的请求(POST、PUT、DELETE)都携带一个随机 Token 才算合法。对于同样是表单 POST 的登录请求,框架已经帮你把 Token 写进了登录页表单;但对于你自己写的普通 POST 接口,如果你没有在页面上获取并提交这个 Token,请求就会被拒。
入门阶段,如果只是在本地捣鼓,可以先关掉 CSRFF 方便调试:
http.csrf(AbstractHttpConfigurer::disable);但你要清醒:生产环境必须保留,而且要在页面或者 AJAX 请求里正确携带 Token。具体怎么做,系列后面我会单独写一篇讲。
5.4 异常信息怎么读
Spring Security 的异常类不算少,入门阶段认识最常见的几个就够了:
| 异常 | 表现 | 排查方向 |
|---|---|---|
BadCredentialsException | 登录页提示"用户名或密码错误" | 表单用户名/密码是否正确;密码是否用同一个PasswordEncoder校验 |
AccessDeniedException | 已登录但权限不足,返回 403 | 检查接口规则和用户拥有的权限是否匹配 |
DisabledException/LockedException | 用户被禁用/锁定 | 检查UserDetails里对应布尔方法的状态 |
| 未登录访问受保护接口 | 重定向到登录页 | 检查会话是否失效、请求是否携带 Cookie |
读异常信息时把握一个核心判断:跳到登录页说明还没认证;直接 403 说明认证成功但权限不足。这两者界定的方式在 ExceptionTranslationFilter 里:它会把访问受保护资源时的异常分类处理——未认证就去走认证流程,已认证就返回 403。
6. 给入门阶段的读者几句实在话
这篇的内容量不小,但核心其实就三个:过滤器链是骨架、Authentication 是血液、配置类是组装说明。你不需要把每个过滤器都背下来,但至少要能在脑里画出请求从浏览器到 Controller 之间经过了哪几道关键检查。
我对入门者的建议是:先照本文的配置把项目跑通,然后把配置里的permitAll、authenticated、formLogin逐个改成不同组合,观察访问控制的变化。这个"改一下、跑一次、看结果"的过程,比看十篇教程都有效。自己动手把登录流程走顺之后,再回头看书里的概念,会突然发现那些名词都"活"了。
下一篇会讲数据库用户存储:如何从数据库查询用户、实现自己的UserDetailsService、自定义登录页和登录成功后的处理逻辑,顺便把登录认证的完整时序再过一遍。如果你照着本文把最小应用跑通了,下一篇的内容会衔接得很自然。我在这系列里踩过的坑,都会尽量提前帮你标记出来。