欢迎来到我的博客,代码的世界里,每一行都是一个故事
🎏:你只管努力,剩下的交给时间
🏠 :小破站
摘要
- 本文承接第1篇,深入拆解 Spring Security 的“原理链”:认证模型、授权模型、安全上下文与会话,以及最核心的 过滤器链。
- 你将弄清请求是如何穿过一串安全过滤器完成“拦截→认证→放行”的,从现象到原理建立清晰心智模型。
- 最后我们以最小代码实现 自定义登录页(保留最小用户来源),一步步完成“默认表单登录”到“自定义表单登录”的迁移。
1. 全局图景:Spring Security 在应用里扮演什么角色?🗺️
先把几个核心名词串起来:
- Authentication(认证):解决“你是谁”。
- Authorization(授权):解决“你能做什么”。
- SecurityContext(安全上下文):存放当前线程的认证信息(含主体、权限)。
- SecurityFilterChain(过滤器链):一组按序执行的 Servlet 过滤器,对每个 HTTP 请求做安全处理。
一句话心智图:请求进来 → 经过一串过滤器 → 如果未认证则引导登录 → 登录提交后走认证流程 → 认证成功把 Authentication 放进 SecurityContext → 后续请求就“带着身份”通行(并受授权规则约束)。
2. 过滤器链长什么样?🔍
Spring Security 会基于你的配置(或自动配置)拼装一条过滤器链,常见关键角色(顺序简化示意):
- SecurityContextPersistenceFilter:在请求开始时,从 Session 恢复 SecurityContext;请求结束时保存。
- LogoutFilter:处理 /logout 相关逻辑。
- UsernamePasswordAuthenticationFilter:处理表单登录(默认 POST /login)。
- DefaultLoginPageGeneratingFilter:如果你没自定义登录页,它会生成一个默认登录页。
- ConcurrentSessionFilter:并发会话控制(可选)。
- CsrfFilter:CSRF 保护(默认开启)。
- ExceptionTranslationFilter:把认证/授权异常转为“跳转登录”或“403”等响应。
- FilterSecurityInterceptor:最终做授权决策(基于 URL/方法安全规则)。
关键感知:
- 表单登录提交时,真正“接单”的是 UsernamePasswordAuthenticationFilter;
- 你没配登录页时,DefaultLoginPageGeneratingFilter 负责“兜底生成”页面;
- 真正拦住未登录用户的是 ExceptionTranslationFilter + FilterSecurityInterceptor 的组合。
3. 表单登录的完整链路 🧾
以默认表单登录为例(不自定义任何 Bean 的情况下):
- GET / 访问需要认证的资源 → 被拦截 → 引导到默认登录页
- 在默认登录页输入用户名/密码 → POST /login → UsernamePasswordAuthenticationFilter 拿到表单参数
- 委托给 AuthenticationManager(内部多个 Provider 之一是 DaoAuthenticationProvider)
- DaoAuthenticationProvider → 调用 UserDetailsService 加载用户(默认 InMemoryUserDetailsManager)
- 使用 PasswordEncoder 验证密码(默认无加密或由你配置)
- 成功 → 生成 Authentication,放入 SecurityContext,执行 SuccessHandler(默认重定向到之前的目标或首页)
- 失败 → FailureHandler(默认带 error 参数回登录页)
掌握了这条链,就知道自定义登录页要改哪里:
- 显式声明 SecurityFilterChain,在其中 formLogin().loginPage(“/login”) 指定你的登录页;
- 给 /login 相关路径 permitAll();
- 其他 URL 按需 authenticated()。
4. 谁来“装配”过滤器链?⚙️
- 在 Spring Boot 中,如果你没有声明 SecurityFilterChain Bean,Boot 会为你配置一条默认链(含默认登录页、默认规则)。
- 一旦你声明了 SecurityFilterChain Bean,默认 Web 安全配置就会“退让”,链条以你的配置为准(但 UserDetailsService 自动配置仍可能保留,除非你也自己提供了)。
这就是为什么:只通过声明一个最小的 SecurityFilterChain,就能开启“自定义登录页”,而用户来源仍可沿用“默认/配置文件/你自定义 Bean”这三种方式之一。
5. 从默认到自定义:我们要改哪些点?🧭
目标:在不大动干戈的前提下,把登录页换成我们自己的 login.html,并明确资源访问规则。
最小思路:
- 保留现有 Thymeleaf 的 login.html;
- 新增一个最小的安全配置类 SecurityConfig,仅声明 SecurityFilterChain;
- 允许匿名访问 /、/login、静态资源;其余路径需要认证;
- 使用默认的 /login 处理地址(表单 action 指向 /login 即可)。
6. 最小可行配置(自定义登录页)🧩
下面给出最小配置示例(基于 Spring Boot 3 / Spring Security 6):
package com.acowbo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/css/**", "/js/**", "/images/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(login -> login
.loginPage("/login") // 使用你自定义的登录页(GET /login 返回视图)
.loginProcessingUrl("/login") // 表单提交处理地址(POST /login)
.defaultSuccessUrl("/dashboard", true) // 成功后跳转
.failureUrl("/login?error") // 失败后回登录页
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/")
.permitAll()
)
.csrf(Customizer.withDefaults());
return http.build();
}
}
说明:
- 仅声明 SecurityFilterChain 不会影响 UserDetailsService 的自动配置(它仍会按规则兜底)。
- 如果你在 application.yml 中配置了 spring.security.user,将使用该用户;否则仍能触发“随机密码”默认用户(第一篇已演示)。
- 你的 login.html 表单需要 POST /login,且包含 username 与 password 字段(我们第1篇的模板正是这样)。
最后的登录页如下所示:
7. 表单页面要点(与 Security 对齐)📝
- 表单 action 要对上 loginProcessingUrl(默认/上面都用 /login):
<form th:action="@{/login}" method="post">
<input type="text" name="username" required>
<input type="password" name="password" required>
<button type="submit">登录</button>
</form>
<div th:if="${param.error}">用户名或密码错误</div>
- 成功重定向位置由 defaultSuccessUrl 决定;也可不写,使用默认“曾经拦截的目标地址”。
8. 原理补全:认证是如何“落袋为安”的?📦
- 认证成功后,Provider 构造一个 Authentication 对象(带用户、权限等信息)。
- SecurityContextHolder(默认 ThreadLocal 策略)保存当前线程的 SecurityContext。
- SecurityContextPersistenceFilter 会在请求结束时把上下文写回 Session(下次请求可恢复)。
- 有了 Authentication,后续授权判断(在 FilterSecurityInterceptor)就能基于 GrantedAuthority 做决策。
这套“存取”机制让你在 Controller 里能拿到 Principal/Authentication,并在模板里用 sec:authentication 标签显示用户名。
9. 授权规则写在哪、怎么算?🧮
- URL 级授权:在 SecurityFilterChain 的 authorizeHttpRequests 块里表达(如上)。
- 方法级授权:使用 @PreAuthorize、@Secured 等注解(需额外启用,后续篇章细讲)。
- 决策点:FilterSecurityInterceptor 会基于你配置的规则 + 当前 Authentication 的权限进行判定。
常用规则示例:
.requestMatchers(“/public/**”).permitAll().requestMatchers(“/admin/**”).hasRole(“ADMIN”).anyRequest().authenticated()
10. 验证步骤(从默认到自定义)✅
- 先不加 SecurityConfig,直接运行:
- 访问受保护资源 → 默认登录页
- 控制台看到 Using generated security password 或使用你在 yml 里配置的用户
- 加上本文的 SecurityConfig:
- GET /login 会展示你的自定义登录页(已在项目 templates/login.html)
- POST /login 成功后进入 /dashboard
- 退出登录 /logout 返回首页
如果出现登录表单不生效:
- 检查 loginProcessingUrl 与表单 action 是否一致
- 检查是否允许 /login permitAll
- 检查是否开启 CSRF(默认开)且表单包含 CSRF 隐藏域(Thymeleaf 的
th:action + Spring Security 会自动注入)。
11. 延伸与最佳实践 📘
- PasswordEncoder 必须用(如 BCryptPasswordEncoder),不要在生产中用明文密码。
- 自定义 UserDetailsService:把用户放数据库,结合密码编码器与账户状态(锁定/过期等)。
- 分层授权:URL + 方法双层控制,不同粒度更安全。
- 静态资源放行:/css、/js、/images 必须 permitAll,否则样式都进不来。
- 异常体验:自定义 AuthenticationEntryPoint 与 AccessDeniedHandler 提升交互质量。
12. 小结 🧾
- 我们把“原理链”拆透了:过滤器链如何拦截、谁来处理登录、认证信息如何落地、授权在哪决策。
- 用一个最小的 SecurityFilterChain,完成了“从默认登录页 → 自定义登录页”的迁移,同时保留了用户来源的灵活性(默认/配置/自定义)。
下一篇我们将:
- 引入 PasswordEncoder 与编码密码;
- 实现 自定义 UserDetailsService(基于内存/数据库);
- 补充 方法级安全 与常见的授权策略套路;
- 简要讲解 会话管理 与 记住我 的实现要点。
13. 核心参与者深挖:ProviderManager、DaoAuthenticationProvider、UserDetailsService、PasswordEncoder 🧩
理解“谁在认证”与“怎么认证”:
- ProviderManager:认证调度者,内部维护一个 AuthenticationProvider 列表,逐个尝试“谁能处理这个 Authentication”。
- DaoAuthenticationProvider:最常见的 Provider,职责是:
- 调用 UserDetailsService 按用户名加载 UserDetails
- 取出存储的密码(通常是 BCrypt 等编码后的值)
- 使用 PasswordEncoder 对比表单明文密码与存储的编码密码
- 校验账户状态(是否锁定/过期/禁用)
- UserDetailsService:提供用户数据来源(内存/数据库/LDAP…)。
- PasswordEncoder:密码编码/匹配策略(强烈推荐 BCryptPasswordEncoder)。
一句话:UsernamePasswordAuthenticationFilter 收表单 → ProviderManager 派单 → DaoAuthenticationProvider 找用户 + 校验密码 → 成功则返回 Authentication。
进阶建议:
- 提前落地 PasswordEncoder Bean,确保所有存储密码都是编码后的;
- 自定义 UserDetailsService 对接数据库,实现更贴近生产的认证逻辑。
14. SecurityContextHolder 策略与存储 🎒
SecurityContextHolder 默认使用 ThreadLocal 保存认证信息:
- MODE_THREADLOCAL(默认):每个线程一个上下文。
- MODE_INHERITABLETHREADLOCAL:子线程可继承父线程的上下文(注意线程池可能导致意外继承)。
- MODE_GLOBAL:全局单例(很少使用)。
请求开始时 SecurityContextPersistenceFilter 会从 HttpSession 还原上下文;请求结束时写回 Session。这样下一次请求能“带着登录态”继续访问。
调试技巧:在 Controller 里注入 Authentication 或使用 SecurityContextHolder.getContext() 打印,观察认证前后变化。
15. CSRF 深入:为什么、怎么用、何时关?🛡️
- 为什么:防止跨站请求伪造(用户已登录,攻击者构造恶意 POST 引发状态变更)。
- 怎么用:启用后,写操作(POST/PUT/DELETE/PATCH)需要携带 CSRF Token;Thymeleaf 的
th:action 会自动注入隐藏域。 - 何时关:纯后端渲染站点不建议关闭;如前后端分离 + 使用 Token(非 Cookie)认证时,可视情况关闭 CSRF 或在前端以 Header 携带 Token。
常见问题:403 Forbidden(CSRF token missing or incorrect)
- 表单不是用
th:action 或未包含隐藏域:_csrf - 使用 Ajax 提交时未携带
X-CSRF-TOKEN Header
16. ExceptionTranslationFilter 详解:异常如何“被翻译”成跳转与状态码 🚧
职责:拦截 AuthenticationException 与 AccessDeniedException,然后:
- 未认证(AuthenticationException)→ 调用 AuthenticationEntryPoint:
- 表单登录时:重定向到登录页
- HTTP Basic 时:返回 401 并附带
WWW-Authenticate 头
- 已认证但无权限(AccessDeniedException)→ 调用 AccessDeniedHandler:
SavedRequest:
- 当用户被拦截重定向到登录页时,原始请求 会被保存(如 URL、参数)。
- 登录成功后通过 SavedRequestAwareAuthenticationSuccessHandler 恢复跳回原目标(若你设置了
defaultSuccessUrl(“/dashboard”, true) 则强制去 dashboard)。
17. DefaultLoginPageGeneratingFilter:默认登录页从哪来,怎么“挤走”它?🧾
- 未自定义
formLogin().loginPage(“/login”) 时,该过滤器会生成一个简单的默认登录页。 - 一旦你指定了 loginPage(“/login”) 且该路径可访问,它就会 退场,交由你的模板控制。
- 这就是我们为什么只要声明 SecurityFilterChain 并指定 loginPage,就能让自己的 login.html 生效。
18. 会话管理与 Remember-Me、并发控制、匿名用户 👥
- SessionManagementFilter:
- 配置无 Session、总是创建、按需创建等策略
- 可结合 ConcurrentSessionFilter 做并发会话控制(同账号在线数限制)
- RememberMeAuthenticationFilter:
- 记住我功能(通常在登录表单勾选),生成长期 Cookie,让用户长期免登
- 需配置
rememberMe()、key、持久化策略等
- AnonymousAuthenticationFilter:
- 对未认证请求注入一个“匿名 Authentication”,使授权表达式
isAnonymous()、isAuthenticated() 有明确语义
这些过滤器并非“必需最小集”,但了解它们有助于你在复杂场景下定制登录态与会话行为。
19. 过滤器顺序与可视化:如何“看见”链路 🧯
- 最简单的方式:开启调试日志,观察启动时打印的过滤器链(强烈推荐开发期开启):
logging:
level:
org.springframework.security: DEBUG
- 运行后在控制台可看到 FilterChainProxy 注册的各个过滤器及顺序。
- 你也可以在关键过滤器周围设置断点(如 UsernamePasswordAuthenticationFilter、ExceptionTranslationFilter、FilterSecurityInterceptor)进行单步调试。
20. 常见误区与排查清单 🧨
- 表单 action 与 loginProcessingUrl 不一致:导致 UsernamePasswordAuthenticationFilter 接不到请求。
- 用户名/密码参数名不匹配:默认是
username/password;如改了需 .usernameParameter()/.passwordParameter() 对齐。 - CSRF 导致 403:表单未携带 CSRF;前后端分离时忽略了 Token 模式差异。
- 静态资源未放行:导致登录页样式丢失、脚本加载失败。
- permitAll 顺序与匹配器覆盖:更宽的匹配器(如 anyRequest)放在前面会吞掉后续规则。
- 默认登录页与自定义登录页并存误解:指定 loginPage 后默认页就不再生成。
排查招式:
- 打开 org.springframework.security DEBUG;
- 关注 ExceptionTranslationFilter、FilterSecurityInterceptor 日志;
- 检查表单参数名与 URL;
- 使用浏览器 DevTools 看请求是否携带 CSRF 隐藏域或 Header。
21. 自定义登录流程的进一步定制(成功/失败处理器、参数名、URL)🧪
如果你想要更精准的交互体验,可进一步定制:
.formLogin(login -> login
.loginPage("/login")
.loginProcessingUrl("/doLogin")
.usernameParameter("username")
.passwordParameter("password")
.successHandler((req, resp, auth) -> {
// 自定义成功逻辑:记录审计、跳转不同首页等
resp.sendRedirect("/dashboard");
})
.failureHandler((req, resp, ex) -> {
// 自定义失败逻辑:统计失败次数、国际化错误等
resp.sendRedirect("/login?error");
})
.permitAll()
)
页面表单需同步改成 th:action=“@{/doLogin}”,并保持用户名/密码字段与上面参数名一致。
22. 练习题与实践建议 🏋️
- 练习1: 打开 org.springframework.security 的 DEBUG,按日志顺序画出一次“未登录访问受保护资源→重定向到登录页→登录成功→跳回原目标”的完整时序图。
- 练习2: 将
loginProcessingUrl 改为 /doLogin,并把页面表单改为 @{/doLogin};随手试试把 username 改为 email,看失败原因并修正。
感谢
感谢你读到这里,说明你已经成功地忍受了我的文字考验!🎉
希望这篇文章没有让你想砸电脑,也没有让你打瞌睡。
如果有一点点收获,那我就心满意足了。
未来的路还长,愿你
遇见难题不慌张,遇见bug不抓狂,遇见好内容常回访。
记得给自己多一点耐心,多一点幽默感,毕竟生活已经够严肃了。
如果你有想法、吐槽或者想一起讨论的,欢迎留言,咱们一起玩转技术,笑对人生!😄
祝你代码无bug,生活多彩,心情常青!🚀