乐途乐途
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
  • 学习路径
  • 第1章 Spring Security 基础

    • 本章定位
    • Spring Security 是什么
    • DelegatingFilterProxy
    • 安全过滤器链
    • SecurityFilterChain
    • 过滤器执行顺序
  • 第2章 认证

    • 本章定位
    • Authentication
    • 认证流程
    • AuthenticationManager
    • ProviderManager
    • DaoAuthenticationProvider
    • UserDetails
    • UserDetailsService
    • PasswordEncoder
    • BCryptPasswordEncoder
    • DelegatingPasswordEncoder
    • 表单登录
    • SecurityContext
    • SecurityContextHolder
    • UsernamePasswordAuthenticationToken
  • 第3章 授权

    • 本章定位
    • 授权模型
    • GrantedAuthority
    • AccessDecisionManager
    • AccessDecisionVoter
    • URL 级别授权
    • 方法级别安全
    • @PreAuthorize
    • @PostAuthorize
    • @PreFilter
    • @PostFilter
    • @Secured
    • RoleHierarchy
  • 第4章 过滤器链

    • 本章定位
    • FilterChainProxy
    • SecurityContextHolderFilter
    • LogoutFilter
    • BasicAuthenticationFilter
    • CsrfFilter
    • CorsFilter
    • HeaderWriterFilter
    • AnonymousAuthenticationFilter
    • RequestCacheAwareFilter
    • ExceptionTranslationFilter
    • FilterSecurityInterceptor
  • 第5章 会话管理

    • 本章定位
    • 会话管理
    • SessionFixation
    • 会话并发控制
    • SessionCreationPolicy
    • RememberMe
  • 第6章 JWT

    • 本章定位
    • JWT
    • JwtDecoder
    • JWT 认证
    • JwtAuthenticationConverter
  • 第7章 OAuth2

    • 本章定位
    • OAuth2 基础
    • OAuth2 Client
    • OAuth2 Resource Server
    • 第三方登录配置
  • 第8章 攻击防护

    • 本章定位
    • CSRF 跨站请求伪造防护
    • CORS 跨域防护
    • Clickjacking 点击劫持防护
    • 安全响应头
    • Session Fixation 会话固定防护
  • 第9章 测试

    • 本章定位
    • 安全测试
    • @WithMockUser
    • 最佳实践

表单登录

定义与作用

表单登录(Form Login)是 Spring Security 最经典的认证方式,适用于传统基于 Session 的 Web 应用。用户在登录页面输入用户名和密码,服务器校验后建立会话,后续请求通过 Session Cookie 维持认证状态。

手写登录流程需要自行处理:编写 login.jsp 或 HTML 页面、创建 Controller 接收表单提交、调用 HttpServletRequest.login() 或手写验证逻辑、校验密码、设置 HttpSession、管理跳转目标。任何一个环节遗漏或处理不当,都会引入安全漏洞或业务缺陷。

Spring Security 通过 formLogin() 一行配置即可提供完整登录流程:生成默认登录页、自动拦截表单提交、调用 AuthenticationManager 完成认证、处理成功/失败跳转、写入 SecurityContextHolder、触发 Session Fixation 防护。开发者只需关注业务配置,无需重复造轮子。

http.formLogin();  // 启用默认表单登录,自动生成登录页

核心原理

formLogin() 配置的底层是 UsernamePasswordAuthenticationFilter,该过滤器默认拦截 POST /login 请求,从表单参数中提取用户名和密码,封装为 UsernamePasswordAuthenticationToken,交由 AuthenticationManager 完成认证。本文档聚焦于应用层配置,不涉及 Filter 内部实现。

常用配置项

配置项说明默认值
loginPage("/login")自定义登录页路径,GET 请求展示登录表单/login(默认生成)
loginProcessingUrl("/login")表单提交目标路径,POST 请求由 UsernamePasswordAuthenticationFilter 拦截/login
usernameParameter("user")自定义用户名参数名username
passwordParameter("pass")自定义密码参数名password
defaultSuccessUrl("/home")登录成功后默认跳转,若来自被拦截请求则回到原页面/
successForwardUrl("/home")登录成功后无条件转发到指定路径(不会回到原页面)—
failureUrl("/login?error")登录失败后重定向到指定路径,URL 带 ?error 参数/login?error
failureForwardUrl("/login?error")登录失败后以请求转发方式到指定路径(服务端内部跳转)—
permitAll()放行登录页和登录提交端点,无需认证—

自定义处理器

当默认跳转逻辑无法满足需求时,可替换为自定义 Handler:

  • AuthenticationSuccessHandler:认证成功后的处理逻辑,可用于记录登录日志、返回 JSON 给前端、动态计算跳转目标等。
  • AuthenticationFailureHandler:认证失败后的处理逻辑,可用于区分异常类型、记录安全日志、返回自定义错误信息。

登录页必须放行

自定义登录页后,必须使用 permitAll() 放行登录相关路径。否则用户访问登录页时会被 Security 过滤器拦截,要求先认证,从而陷入重定向死循环(请求登录页 → 需认证 → 重定向到登录页 → 需认证 …)。

http.formLogin(form -> form
        .loginPage("/login")
        .permitAll()  // 必须放行,否则死循环
    );

表单登录完整流程


示例一:从默认登录页到自定义登录页

场景

学生管理系统需要美观的自定义登录页,而非 Spring Security 生成的默认灰白页面。要求登录页路径为 /login,表单参数名自定义为 user 和 pass,登录成功后跳到 /home。

操作前:仅启用默认登录页

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(auth -> auth
                .antMatchers("/public/**").permitAll()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .formLogin();  // 使用默认登录页
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("student").password("{noop}123456").roles("USER")
            .and()
            .withUser("admin").password("{noop}admin123").roles("ADMIN");
    }
}

此时访问 /admin 会被重定向到 Spring Security 自动生成的默认登录页,页面路径为 /login,表单参数名为 username 和 password。

操作后:配置自定义登录页

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(auth -> auth
                .antMatchers("/public/**").permitAll()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")              // 自定义登录页路径
                .loginProcessingUrl("/login")     // 表单提交目标路径
                .usernameParameter("user")        // 自定义用户名参数名
                .passwordParameter("pass")       // 自定义密码参数名
                .defaultSuccessUrl("/home", false) // 登录成功后跳 /home
                .failureUrl("/login?error=true")
                .permitAll()                      // 放行登录页,避免死循环
            )
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login?logout")
                .permitAll()
            );
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("student").password("{noop}123456").roles("USER")
            .and()
            .withUser("admin").password("{noop}admin123").roles("ADMIN");
    }
}

Spring MVC Controller 仅提供登录页展示方法(不展开 MVC 细节):

@Controller
public class LoginController {

    @RequestMapping(value = "/login", method = RequestMethod.GET)
    public String login() {
        return "login";  // 返回自定义登录视图
    }

    @RequestMapping(value = "/home", method = RequestMethod.GET)
    public String home() {
        return "home";
    }
}

登录页面表单示例

<!DOCTYPE html>
<html>
<head><title>学生管理系统登录</title></head>
<body>
<form action="/login" method="post">
    <input type="text" name="user" placeholder="用户名" required/>
    <input type="password" name="pass" placeholder="密码" required/>
    <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
    <button type="submit">登录</button>
</form>
</body>
</html>

结果分析

  • 访问 /admin 时,因未认证被重定向到 /login,展示自定义登录页。
  • 输入用户名 admin、密码 admin123,表单以 user=admin&pass=admin123 提交到 /login。
  • UsernamePasswordAuthenticationFilter 拦截 POST 请求,完成认证。
  • 认证成功,SecurityContextHolder 存入 Authentication,Session ID 更换,跳转至 /home。
  • 若直接访问 /login 后登录,同样跳转 /home(defaultSuccessUrl 第二个参数 false 表示不强制回到原页面)。

示例二:登录失败处理与自定义 Handler

场景

生产系统需要区分"用户名不存在"和"密码错误"的日志记录,用于安全审计和排障。但对外返回统一提示"用户名或密码错误",避免攻击者枚举有效用户名。

操作前:默认失败跳转

.formLogin(form -> form
    .loginPage("/login")
    .failureUrl("/login?error")  // 仅知道失败,无详细异常信息
    .permitAll()
)

默认配置下,?error 参数只能告知"登录失败了",Controller 层面无法获知是用户名不存在还是密码错误,安全日志也无法记录详细异常。

操作后:自定义 AuthenticationFailureHandler

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(auth -> auth
                .antMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .loginProcessingUrl("/login")
                .defaultSuccessUrl("/home", false)
                .failureHandler(new CustomAuthenticationFailureHandler())
                .permitAll()
            );
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("teacher").password("{noop}pass123").roles("USER");
    }
}

自定义 AuthenticationFailureHandler 实现:

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

    private static final Logger logger = LoggerFactory.getLogger(
            CustomAuthenticationFailureHandler.class);

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception)
            throws IOException, ServletException {

        String username = request.getParameter("username");

        // 内部安全日志记录详细异常类型,用于排障
        if (exception instanceof UsernameNotFoundException) {
            logger.warn("登录失败:用户名不存在,username={}", username);
        } else if (exception instanceof BadCredentialsException) {
            logger.warn("登录失败:密码错误,username={}", username);
        } else if (exception instanceof LockedException) {
            logger.warn("登录失败:账户已锁定,username={}", username);
        } else {
            logger.warn("登录失败:其他异常,username={},exception={}",
                    username, exception.getClass().getSimpleName());
        }

        // 对外返回统一错误信息,避免信息泄露
        request.getSession().setAttribute("errorMessage", "用户名或密码错误");
        response.sendRedirect("/login?error");
    }
}

结果分析

  • 用户输入不存在的用户名时,UserDetailsService.loadUserByUsername() 抛出 UsernameNotFoundException,Handler 记录"用户名不存在"日志。
  • 用户输入错误密码时,PasswordEncoder.matches() 不匹配导致 BadCredentialsException,Handler 记录"密码错误"日志。
  • 无论哪种异常,用户浏览器最终都被重定向到 /login?error,页面展示统一提示"用户名或密码错误"。
  • 安全与排障兼顾:外部攻击者无法通过错误信息差异枚举用户名;内部运维人员可通过日志定位问题根因。

易错场景与面试考点

常见错误一:自定义登录页后未 permitAll() 导致死循环

配置 loginPage("/login") 后,如果未调用 permitAll(),/login 路径仍受 anyRequest().authenticated() 保护。用户未认证访问 /login 时,Security 拦截器要求先认证,于是重定向到 /login —— 再次触发拦截,形成无限循环。浏览器最终报错"重定向次数过多"。

正确做法:formLogin() 配置链末尾始终追加 .permitAll()。

常见错误二:loginProcessingUrl 与 loginPage 设为同一路径,Controller 未区分 GET/POST

当 loginPage("/login") 和 loginProcessingUrl("/login") 相同时,Spring Security 的 UsernamePasswordAuthenticationFilter 只拦截 POST 请求,GET 请求应交给 Controller 展示登录页。如果 Controller 未限制 method = RequestMethod.GET,或错误地以 @RequestMapping("/login") 接收所有方法,可能导致 POST 请求被 Controller 提前消费,Security 过滤器收不到表单提交。

正确做法:Controller 的登录页方法明确标注 method = RequestMethod.GET,让 POST 请求透传给 Filter。

面试考点:defaultSuccessUrl 与 successForwardUrl 的区别

对比项defaultSuccessUrlsuccessForwardUrl
跳转方式重定向(Redirect)请求转发(Forward)
是否回到原请求若用户因访问受保护资源被拦截到登录页,登录成功后优先回到原请求;否则到默认 URL无条件转发到指定 URL,不会回到原请求
地址栏变化变化,显示目标 URL不变,仍显示 /login
适用场景普通 Web 应用,优先恢复用户被打断的操作需要在服务端保持请求属性,或固定跳转目标

面试考点:failureUrl 与 failureForwardUrl 的区别

对比项failureUrlfailureForwardUrl
跳转方式重定向(Redirect)请求转发(Forward)
地址栏变化变化,显示 /login?error不变,仍显示 /login
请求属性保留不保留,需通过 Session 传错误信息保留,可在 Request 属性中直接放错误信息
适用场景分离错误页与登录页,或需刷新 URL需在服务端内部传递异常详情,不暴露 URL 变化

版本说明:本文代码基于 Spring Security 5.x(WebSecurityConfigurerAdapter、antMatchers、@EnableGlobalMethodSecurity),对应 Spring Boot 2.x 环境。Spring Security 6.x 已移除 WebSecurityConfigurerAdapter,推荐改用 SecurityFilterChain Bean + requestMatchers 配置。

上一页
DelegatingPasswordEncoder
下一页
SecurityContext