首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >从0到1掌握 Spring Security(第二篇):把“原理链”讲明白,认识过滤器链,并实现自定义登录页

从0到1掌握 Spring Security(第二篇):把“原理链”讲明白,认识过滤器链,并实现自定义登录页

作者头像
一只牛博
发布2025-08-18 08:29:12
发布2025-08-18 08:29:12
45000
代码可运行
举报
运行总次数:0
代码可运行

欢迎来到我的博客,代码的世界里,每一行都是一个故事

🎏:你只管努力,剩下的交给时间 🏠 :小破站

摘要
  • 本文承接第1篇,深入拆解 Spring Security 的“原理链”:认证模型、授权模型、安全上下文与会话,以及最核心的 过滤器链
  • 你将弄清请求是如何穿过一串安全过滤器完成“拦截→认证→放行”的,从现象到原理建立清晰心智模型。
  • 最后我们以最小代码实现 自定义登录页(保留最小用户来源),一步步完成“默认表单登录”到“自定义表单登录”的迁移。

1. 全局图景:Spring Security 在应用里扮演什么角色?🗺️

先把几个核心名词串起来:

  • Authentication(认证):解决“你是谁”。
  • Authorization(授权):解决“你能做什么”。
  • SecurityContext(安全上下文):存放当前线程的认证信息(含主体、权限)。
  • SecurityFilterChain(过滤器链):一组按序执行的 Servlet 过滤器,对每个 HTTP 请求做安全处理。

一句话心智图:请求进来 → 经过一串过滤器 → 如果未认证则引导登录 → 登录提交后走认证流程 → 认证成功把 Authentication 放进 SecurityContext → 后续请求就“带着身份”通行(并受授权规则约束)。


2. 过滤器链长什么样?🔍

Spring Security 会基于你的配置(或自动配置)拼装一条过滤器链,常见关键角色(顺序简化示意):

  1. SecurityContextPersistenceFilter:在请求开始时,从 Session 恢复 SecurityContext;请求结束时保存。
  2. LogoutFilter:处理 /logout 相关逻辑。
  3. UsernamePasswordAuthenticationFilter:处理表单登录(默认 POST /login)。
  4. DefaultLoginPageGeneratingFilter:如果你没自定义登录页,它会生成一个默认登录页。
  5. ConcurrentSessionFilter:并发会话控制(可选)。
  6. CsrfFilter:CSRF 保护(默认开启)。
  7. ExceptionTranslationFilter:把认证/授权异常转为“跳转登录”或“403”等响应。
  8. 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):

代码语言:javascript
代码运行次数:0
运行
复制
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,且包含 usernamepassword 字段(我们第1篇的模板正是这样)。

最后的登录页如下所示:

image-20250813150558991
image-20250813150558991

7. 表单页面要点(与 Security 对齐)📝

  • 表单 action 要对上 loginProcessingUrl(默认/上面都用 /login):
代码语言:javascript
代码运行次数:0
运行
复制
<form th:action="@{/login}" method="post">
  <input type="text" name="username" required>
  <input type="password" name="password" required>
  <button type="submit">登录</button>
</form>
  • 失败提示可基于 ${param.error}
代码语言:javascript
代码运行次数:0
运行
复制
<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. 验证步骤(从默认到自定义)✅

  1. 先不加 SecurityConfig,直接运行:
    • 访问受保护资源 → 默认登录页
    • 控制台看到 Using generated security password 或使用你在 yml 里配置的用户
  2. 加上本文的 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 详解:异常如何“被翻译”成跳转与状态码 🚧

职责:拦截 AuthenticationExceptionAccessDeniedException,然后:

  • 未认证(AuthenticationException)→ 调用 AuthenticationEntryPoint
    • 表单登录时:重定向到登录页
    • HTTP Basic 时:返回 401 并附带 WWW-Authenticate
  • 已认证但无权限(AccessDeniedException)→ 调用 AccessDeniedHandler
    • 默认是 403 页面

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. 过滤器顺序与可视化:如何“看见”链路 🧯

  • 最简单的方式:开启调试日志,观察启动时打印的过滤器链(强烈推荐开发期开启):
代码语言:javascript
代码运行次数:0
运行
复制
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
  • 关注 ExceptionTranslationFilterFilterSecurityInterceptor 日志;
  • 检查表单参数名与 URL;
  • 使用浏览器 DevTools 看请求是否携带 CSRF 隐藏域或 Header。

21. 自定义登录流程的进一步定制(成功/失败处理器、参数名、URL)🧪

如果你想要更精准的交互体验,可进一步定制:

代码语言:javascript
代码运行次数:0
运行
复制
.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,生活多彩,心情常青!🚀

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-08-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 摘要
  • 1. 全局图景:Spring Security 在应用里扮演什么角色?🗺️
  • 2. 过滤器链长什么样?🔍
  • 3. 表单登录的完整链路 🧾
  • 4. 谁来“装配”过滤器链?⚙️
  • 5. 从默认到自定义:我们要改哪些点?🧭
  • 6. 最小可行配置(自定义登录页)🧩
  • 7. 表单页面要点(与 Security 对齐)📝
  • 8. 原理补全:认证是如何“落袋为安”的?📦
  • 9. 授权规则写在哪、怎么算?🧮
  • 10. 验证步骤(从默认到自定义)✅
  • 11. 延伸与最佳实践 📘
  • 12. 小结 🧾
  • 13. 核心参与者深挖:ProviderManager、DaoAuthenticationProvider、UserDetailsService、PasswordEncoder 🧩
  • 14. SecurityContextHolder 策略与存储 🎒
  • 15. CSRF 深入:为什么、怎么用、何时关?🛡️
  • 16. ExceptionTranslationFilter 详解:异常如何“被翻译”成跳转与状态码 🚧
  • 17. DefaultLoginPageGeneratingFilter:默认登录页从哪来,怎么“挤走”它?🧾
  • 18. 会话管理与 Remember-Me、并发控制、匿名用户 👥
  • 19. 过滤器顺序与可视化:如何“看见”链路 🧯
  • 20. 常见误区与排查清单 🧨
  • 21. 自定义登录流程的进一步定制(成功/失败处理器、参数名、URL)🧪
  • 22. 练习题与实践建议 🏋️
  • 感谢
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档