表单登录
定义与作用
表单登录(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 的区别
| 对比项 | defaultSuccessUrl | successForwardUrl |
|---|---|---|
| 跳转方式 | 重定向(Redirect) | 请求转发(Forward) |
| 是否回到原请求 | 若用户因访问受保护资源被拦截到登录页,登录成功后优先回到原请求;否则到默认 URL | 无条件转发到指定 URL,不会回到原请求 |
| 地址栏变化 | 变化,显示目标 URL | 不变,仍显示 /login |
| 适用场景 | 普通 Web 应用,优先恢复用户被打断的操作 | 需要在服务端保持请求属性,或固定跳转目标 |
面试考点:failureUrl 与 failureForwardUrl 的区别
| 对比项 | failureUrl | failureForwardUrl |
|---|---|---|
| 跳转方式 | 重定向(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,推荐改用SecurityFilterChainBean +requestMatchers配置。