第四章 验证码功能实现
本章将介绍如何通过自定义 Filter 实现登录验证码功能,提升系统安全性。
在 Web 应用中,默认登录页暴露在公网环境下,容易遭受暴力破解攻击。增加验证码是一种有效的防护手段。本篇文章将详细介绍如何实现登录验证码功能。
一、问题切入
暴力破解是指攻击者通过自动化工具尝试大量用户名密码组合,直到成功登录。即使我们使用了强密码策略,也无法完全阻止此类攻击。
常见的防护手段包括:
- 增加登录验证码
- 限制登录尝试次数
- 锁定账户
- IP 黑名单
本章我们实现登录验证码功能作为防护手段。
二、解决方案:自定义验证码 Filter
2.1 Filter 实现
继承 OncePerRequestFilter,确保每个请求只执行一次:
@Component
public class CaptchaFliter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String uri = request.getRequestURI();
// 仅对登录请求验证验证码
if (uri.contains("/user/login")) {
String code = request.getParameter("captcha");
String sessionCode = (String) request.getSession()
.getAttribute("captcha");
// 验证码为空或匹配失败
if (!StringUtils.hasLength(code) ||
!code.equalsIgnoreCase(sessionCode)) {
response.sendRedirect("/");
return;
}
}
// 验证通过,放行
chain.doFilter(request, response);
}
}
2.2 接入安全过滤链
在 SecurityConfig 中使用 addFilterBefore() 将验证码过滤器插入到用户名密码认证过滤器之前:
@Resource
private CaptchaFliter captchaFliter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
return http
.formLogin(form -> form
.loginProcessingUrl("/user/login")
.loginPage("/tologin")
.defaultSuccessUrl("/", true)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/tologin", "/common/captcha").permitAll()
.anyRequest().authenticated()
)
// 插入到 UsernamePasswordAuthenticationFilter 之前
.addFilterBefore(captchaFliter,
UsernamePasswordAuthenticationFilter.class)
.build();
}
三、原理阐释:责任链设计模式
3.1 责任链模式
Spring Security 采用经典的责任链设计模式(Chain of Responsibility):
请求到达
↓
CaptchaFliter(验证码校验)← 我们的自定义过滤器
↓ 通过
UsernamePasswordAuthenticationFilter(用户名密码认证)
↓ 通过
CsrfFilter(CSRF 防护)
↓ 通过
AuthorizationFilter(权限检查)
↓
目标 Controller
每个 Filter 只负责自己的职责,处理完后调用 chain.doFilter() 放行到下一个 Filter。
3.2 Filter 插入位置
addFilterBefore(filter, Position):插入到指定 Filter 之前addFilterAfter(filter, Position):插入到指定 Filter 之后addFilterAt(filter, Position):替换指定位置的 Filter
验证码过滤器必须插入到 UsernamePasswordAuthenticationFilter 之前确保登录请求先经过验证码校验。
3.3 OncePerRequestFilter
所有 Filter 的基类,提供以下特性:
- 保证每个请求只执行一次(即使转发也不会重复执行)
- 提供泛型方法
doFilterInternal() - 自动获取 FilterConfig
四、验证码生成接口
@Controller
public class CaptchaController {
@RequestMapping("/common/captcha")
public void captcha(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.setContentType("image/jpeg");
ICaptcha captcha = CaptchaUtil.createShearCaptcha(100, 40, new MyCodeGenerator(), 4);
request.getSession().setAttribute("captcha", captcha.getCode());
captcha.write(response.getOutputStream());
}
}
为简化演示,这里使用随机字符串作为验证码。实际项目中可使用专业验证码库生成图片验证码。
五、前端登录页添加验证码
<form action="/user/login" method="post">
用户名:<input type="text" name="username"> <br/>
密码:<input type="password" name="password"> <br/>
验证码:<input type="text" name="captcha">
<img src="/common/captcha"> <br/>
<input type="hidden" name="_csrf" th:value="${_csrf.token}">
<input type="submit" value="登录">
</form>
六、为什么要手动配置 PasswordEncoder(以 BCrypt 为例)
在使用表单登录和数据库认证时,我们通常会手动写出下面这段配置:
@Bean
public PasswordEncoder passwordEncoder(){
// BCrypt 密码加密:一种单向、慢哈希、自带随机盐的安全密码加密算法
return new BCryptPasswordEncoder();
}
先说“为什么要手动写”:
- 明确密码策略:告诉项目“密码要用什么算法存储和校验”。
- 避免明文或弱加密:不再允许数据库存明文密码,也避免继续使用 MD5/SHA1 这类不安全方案。
- 与历史数据对齐:当你的用户表是你自己维护时,必须明确编码器,否则登录比对会失败。
- 便于后续升级:后续要切换参数(如 BCrypt cost)或迁移到其他编码器时,有统一入口。
配置好之后,才进入“自动使用”阶段:
- Spring Security 在认证时会自动从容器里拿
PasswordEncoderBean。 - 你不需要在 Controller 里手动调用
matches()。 - 认证链路中由
DaoAuthenticationProvider负责调用passwordEncoder.matches(明文, 密文)。
登录时的链路如下:
UsernamePasswordAuthenticationFilter读取表单中的用户名和明文密码。AuthenticationManager把请求交给DaoAuthenticationProvider。DaoAuthenticationProvider调用UserDetailsService查询数据库中的密文密码。DaoAuthenticationProvider使用已注入的PasswordEncoder做密码比对。- 比对成功后认证通过,比对失败则抛出
BadCredentialsException。
6.1 BCrypt 为什么更安全
BCrypt 不是“加密后可解密”的算法,而是单向哈希,核心特点是:
- 单向性:无法从密文反推明文。
- 自带随机盐(salt):同一个密码每次编码结果都可能不同,能有效抵抗彩虹表攻击。
- 慢哈希(可调成本):通过 cost 参数增加计算量,抬高暴力破解成本。
BCrypt 典型密文形态如下(示意):
$2a$10$wH2x0...(中间省略)...Qf3u9e
$2a$:算法版本标识。10:cost(工作因子),表示大约2^10轮计算复杂度。- 后续内容:包含 salt 与哈希结果。
6.2 BCrypt 的哈希原理(简化版)
BCrypt 底层基于 Blowfish 的扩展密钥调度算法(EksBlowfish),核心思路不是“快速算完”,而是“故意算慢一点”:
- 生成随机 salt:每次编码先生成随机 salt(可理解为每个密码独有的扰动因子)。
- 设置 cost:cost 越大,内部迭代次数越多,计算越慢。
- 密码 + salt 参与密钥扩展:通过 EksBlowfish 反复混合,构造出代价较高的哈希过程。
- 输出结构化密文:把算法版本、cost、salt、哈希结果编码成一个字符串存库。
这就是为什么 BCrypt 能同时抵抗两类常见攻击:
- 彩虹表攻击:同密码因为 salt 不同,哈希结果不同,预计算表难以复用。
- 暴力破解:慢哈希使单次猜测成本上升,攻击吞吐量显著下降。
6.3 BCrypt 的密码匹配原理
登录校验时,系统并不会“解密数据库密码”,而是做“同条件重算再比较”:
- 从数据库取出已存 BCrypt 密文(其中已包含版本、cost、salt 信息)。
- 解析出该密文里的 cost 和 salt。
- 用“用户本次输入的明文密码 + 解析出的 salt/cost”再执行一次 BCrypt。
- 将新计算结果与数据库密文中的哈希结果做安全比较(防时序攻击的比较方式)。
- 一致则通过,不一致则失败。
换句话说,matches(raw, encoded) 的本质是“重算并比较”,而不是“解密并比较”。
6.4 BCrypt 在业务中的几个关键点
- 注册/改密时:先
encode(明文),再把密文存库。 - 登录时:不做“密文对密文”字符串比较,而是
matches(明文, 库中密文)。 - 同密码不同密文是正常现象:因为每次都会生成新 salt。
- 数据库字段长度要够:建议
varchar(100)或更长,避免截断。
注意:如果当前配置是
BCryptPasswordEncoder,数据库里就必须是 BCrypt 密文;若仍是 MD5 或明文,登录校验会失败。
七、核心概念总结
| 概念 | 说明 |
|---|---|
| OncePerRequestFilter | 自定义 Filter 基类,确保请求只执行一次 |
| 责任链设计模式 | 多个 Filter 按顺序执行,每个负责特定功能 |
| addFilterBefore() | 将自定义 Filter 插入到指定位置之前 |
| 验证码逻辑 | 拦截登录请求,校验验证码参数(session 中验证码) |
代码命名提示:当前项目类名是
CaptchaFliter(拼写为 Fliter)。文章保持与代码一致,后续可统一重构为CaptchaFilter。
八、总结
本篇文章介绍了以下核心概念:
- OncePerRequestFilter:自定义 Filter 基类
- 责任链设计模式:多个 Filter 按顺序执行
- addFilterBefore():将自定义 Filter 插入到指定位置
- 验证码逻辑:过滤登录请求,校验验证码参数
通过自定义 Filter,我们可以实现各种安全防护功能,如:
- 请求频率限制(防止暴力破解)
- IP 黑名单
- 异地登录检测
- 多次失败账户锁定
后续可以探索的内容:
- 基于 Redis 的分布式 Session 管理
- JWT 无状态认证
- OAuth2 社交登录
- Spring Authorization Server 授权服务
编辑者:Flittly
更新时间:2026年4月