阅前提示
此文章基于Spring Security 6.0
一、什么是验证码
根据百度百科的解释:验证码(CAPTCHA)是“Completely Automated Public Turing test to tell Computers and Humans Apart”(全自动区分计算机和人类的图灵测试)的缩写,是一种区分用户是计算机还是人的公共全自动程序。可以防止:恶意破解密码、刷票、论坛灌水,有效防止某个黑客对某一个特定注册用户用特定程序暴力破解方式进行不断的登陆尝试,实际上用验证码是现在很多网站通行的方式,我们利用比较简易的方式实现了这个功能。这个问题可以由计算机生成并评判,但是必须只有人类才能解答。由于计算机无法解答CAPTCHA的问题,所以回答出问题的用户就可以被认为是人类。
二、验证码流程分析
将百度百科的解释翻译成我们程序员的话来说就是:在请求一个接口前,先请求另一个接口,这个接口返回给用户机器无法轻易识别的图片,用户根据图片内容填写相应信息,再访问这个接口,以到达保护这个接口的作用。
根据以上需求分析,完成以下功能
- 生成验证码,在服务端保存一份,发给客户端一份加密的(用人眼解密)
- 拦截受保护的接口,获取验证码
- 若验证码不存在,或验证码校验错误,刷新验证码并返回错误信息
- 若验证码校验通过,销毁验证码,并放行请求
三、验证码过滤器的实现
导入验证码依赖
关于验证码的生成及校验,可以去maven仓库找别人写好的,这样就不用自己写了,还能防止bug过多(快说谢谢开源大佬)
这边用了一个hutool-captcha
<!-- https://mvnrepository.com/artifact/cn.hutool/hutool-captcha -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-captcha</artifactId>
<version>5.8.11</version>
</dependency>
本来想用easy-capcha的,但是遇到一个坑。oracle在19年把javax捐给eclipse基金会,但不允许使用javax的命名空间。spring6与spring boot3采用Jakarta作为新的命名空间,然后easy-captcha使用了javax.servlet.http.HttpServletRequest与javax.servlet.http.HttpServletResponse。所以,easy-captcha的作者如果不更新easy-captcha的话,那么无法在Spring Boot3与Spring6的环境中使用。也不是不能自己下载源码改改用,但是有能用的,干嘛还要自己动手呢
验证码获取接口
首先来写这个分发验证码的接口
@RestController
public class LoginController{
@RequestMapping("captcha")
public void captcha(HttpServletRequest request, HttpServletResponse response) throws IOException {
ICaptcha captcha = CaptchaUtil.createGifCaptcha(200,100);
response.setContentType("image/gif");
response.setHeader("Pragma","No-cache");
response.setHeader("Cache-Control","no-cache");
response.setDateHeader("Expires",0);
request.getSession().setAttribute("captcha",captcha);
captcha.write(response.getOutputStream());
response.getOutputStream().flush();
response.getOutputStream().close();
}
}
之所以想用easy-captcha,就是因为hutool-captcha它只管生成验证码,不管你输出,连close都要自己写。上面的流程是从easy-captcha抄来的。思考是不可能自己思考的,会长脑子的
页面获取验证码
<img src="/captcha"/>
自定义验证码过滤器
@Component
public class SecurityCaptchaFilter extends OncePerRequestFilter {
private static String defaultFilterProcessUrl = "/login";
private static String method = "POST";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (method.equalsIgnoreCase(request.getMethod()) && defaultFilterProcessUrl.equals(request.getServletPath())){
String captcha = request.getParameter("captcha");
if (StringUtils.hasLength(captcha)){
response.getWriter().println("{success:false,code:403,message:\"验证码不能为空\"}");
return;
}
if (!((ICaptcha)request.getSession().getAttribute("captcha")).verify(captcha)){
response.getWriter().println("{success:false,code:403,message:'验证码错误'}");
return;
}
request.getSession().removeAttribute("captcha");
}
filterChain.doFilter(request,response);
}
}
这里跟UsernamePasswordAuthenticationFilter一样,只拦截POST方法请求的/login路径。毕竟是登录界面的验证码嘛
别忘了在Spring Security的配置中放行验证码的路径
在校验用户名密码前先校验验证码。SecurityFilterChain中配置addFilterBefore()
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Autowired
private SecurityCaptchaFilter securityCaptchaFilter;
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.addFilterBefore(securityCaptchaFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
在禁用session的应用中使用验证码
说到这里,这不就是之前讲的csrf吗。那干嘛把Spring Security的csrf给disable了呢。没错,验证码确实能起到csrf的防护功能,但是Spring Security提供的csrf防护它只管session和cookie的方面啊。所以要自己引入验证码功能啊。
因为要用到缓存,所以把之前的hutool-captcha直接改成hutool-all(懒得再启动redis,就这样吧)
<!-- https://mvnrepository.com/artifact/cn.hutool/hutool-all -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.11</version>
</dependency>
建立一个CaptchaService,把关于captcha的内容都丢进去
@Service
public class CaptchaService {
@Autowired
private CaptchaCache cache;
public SecurityCaptcha createCaptcha(HttpServletRequest request) {
AbstractCaptcha captcha = CaptchaUtil.createGifCaptcha(100, 50);
//把key作为临时令牌发给前端
String key = UUID.randomUUID().toString();
if (StringUtils.hasLength(request.getParameter("captchaToken"))){
key = request.getParameter("captchaToken");
}
String code = captcha.getCode();
cache.put(key, code);
return new SecurityCaptcha(key, "data:image/gif;base64," + captcha.getImageBase64());
}
public String verifyCaptcha(String key, String code){
String cacheCode = cache.get(key);
if (!StringUtils.hasLength(cacheCode)) {
//验证码过期
return "1";
}else if (!code.equals(cacheCode)){
//验证码错误
return "2";
} else {
//验证码通过
return "3";
}
}
public void destroyCaptcha(String key) {
cache.remove(key);
}
}
其实就是对验证码缓存的CRUD
单例的缓存,自己懒得写单例了,交给Spring弄一下吧
@Component
public class CaptchaCache {
private TimedCache<String, String> timedCache;
public CaptchaCache() {
this.timedCache = CacheUtil.newTimedCache(60 * 1000);
}
public String get(String key) {
return timedCache.get(key);
}
public void put(String key, String value) {
timedCache.put(key, value);
}
public void remove(String key) {
timedCache.remove(key);
}
}
改造一下controller和前端
@RequestMapping("captcha")
public SecurityCaptcha captcha(HttpServletRequest request, HttpServletResponse response) throws IOException {
return captchaService.createCaptcha(request);
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Login</title>
</head>
<body>
<form action="/login" method="post">
<table>
<tr>
<td>用户名</td>
<td><input type="text" name="username"/> </td>
</tr>
<tr>
<td>密码</td>
<td><input type="password" name="password"/></td>
</tr>
<tr>
<td>验证码</td>
<td><input id="captcha" name="captcha"/><img id="captchaImg" src=""/></td>
</tr>
<tr>
<td>临时token</td>
<td><input id="captchaToken" name="captchaToken"></td>
</tr>
<tr>
<td colspan="2"><input type="submit" value="提交"/></td>
</tr>
</table>
</form>
</body>
</html>
<script type="text/javascript" src="/js/jquery.js"></script>
<script>
$(function(){
$.ajax({
url:"/captcha",
type:"GET",
dataType:"json",
success:callback
})
})
function callback(response) {
let captchaImg = document.getElementById('captchaImg');
captchaImg.src = response.image;
let captchaToken = document.getElementById('captchaToken');
captchaToken.value = response.key;
}
</script>
定义的验证码实体
public class SecurityCaptcha {
private String key;
private String image;
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public SecurityCaptcha(String key, String image) {
this.key = key;
this.image = image;
}
}
还有过滤器当然也得改了
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (method.equalsIgnoreCase(request.getMethod()) && defaultFilterProcessUrl.equals(request.getServletPath())){
String captcha = request.getParameter("captcha");
String captchaToken = request.getParameter("captchaToken");
if (!StringUtils.hasLength(captcha) || !StringUtils.hasLength(captchaToken)){
response.getWriter().println("{success:false,code:403,message:\"验证码丢失\"}");
return;
}
String type = captchaService.verifyCaptcha(captchaToken, captcha);
if ("1".equals(type)) {
response.getWriter().println("{success:false,code:403,message:\"验证码过期\"}");
return;
} else if ("2".equals(type)) {
response.getWriter().println("{success:false,code:403,message:\"验证码错误\"}");
return;
} else if ("3".equals(type)) {
captchaService.destroyCaptcha(captchaToken);
}
}
filterChain.doFilter(request,response);
}
在SecurityFilterChain中的校验不通过,最好是抛出异常,这里用type示意一下。请大家不要好的不学学坏的。还有命名,一会儿key,一会儿token的,懒得统一了,就这样吧