目录
1.1、导入Maven依赖
1.2、写一个生成图片的工具类
1.3、编写接口生成验证码并存入Redis
2、实现图形验证码判断是否正确
2.1、编写验证图形验证码接口
2.2、前端代码
2.3、请求发送
3、实现访问频率限制
3.1、创建自定义注解
3.2、创建自定义aop切面类
背景:我们在做项目登录功能的时候,为了防止被恶意攻击,发送大量请求,我们通常有一些需要人工进行验证
例如:短信验证登录,以及图形验证码登录这两者加上访问频率限制,用这种方式做到防止大量请求进来,对后端造成拥堵以及阻塞
接下来上图演示一下这种没有验证的功能和加上了验证的功能
这是没有加上验证功能,如果是这样子的话,很容易被人发送大量请求,导致服务器瘫痪
接下来我们来看看这个加上验证以及频率访问看看有什么效果吧
我们发现可以通过访问频率以及加上图形验证码来做防止大量请求进入和攻击
接下来上教程
我们可以使用这个hutool工具类来提供图形验证码的生成
cn.hutool hutool-all5.8.6
在这里的工具类,我们有一个图片的唯一IDkey和验证码以及图片的Base64值
我们将这三个数据以数组字符串的方式返回出去
package com.sxy.recordnetwork.common; import cn.hutool.captcha.CaptchaUtil; import cn.hutool.captcha.LineCaptcha; import cn.hutool.core.util.RandomUtil; import lombok.extern.slf4j.Slf4j; import javax.imageio.ImageIO; import java.io.ByteArrayOutputStream; import java.util.Base64; /** * 图形验证码生成工具类 * * @author Administrator */ @Slf4j public class ImgCodeUtil { /** * 画一个图片得到图片的唯一ID,和验证码,并且得到图片的base64 * * @return */ public String[] getImgIdCodeBase64() { try { // 生成一个宽度为80,高度为30的验证码图片 LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(80, 30); // 生成唯一的验证码ID String captchaId = RandomUtil.randomString(6); ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(lineCaptcha.getImage(), "png", baos); // 变为base64返回出去 String base64 = Base64.getEncoder().encodeToString(baos.toByteArray()); return new String[]{captchaId, lineCaptcha.getCode(), base64}; } catch (Exception e) { log.error(e.getMessage()); } return null; } }
下面这段代码中两个方法:
一个生成图形验证码,
一个验证图形验证码。
还有两段重要的代码,RedisTemplate和IMG_KEY:存储唯一key
这两个方法我们先看第一个 生成图形验证码,
请求为Get请求,请求参数是路径参数,并且标注了一个自定义注解,这个注解后面会说到
另外一个方法后面再看
key:图形验证码的唯一key
email:前端传递过来的email邮箱
参数待会儿说,先说具体实现
通过redis创建一个操作普通字符串的方法
然后判断前端传过来的唯一key去查询是否存在该key如果存在则删除
接着,通过调用创建的图片生成工具类得到String类型的数组,数组内容长度为3,分别是,key,code,以及图片转base64
然后将key和code存入到Redis中形成键值对
至于base64,我们只需要将,key和base64构造成一个json对象,返回给前端即可展示即可
下面说一下这两个参数的作用:key和email
也就是说这两个参数必须得通过前端传递过来的。
为了防止第一次进入这个方法体,绕过上面的if判断,因为一开始是没有验证码的,直接删除可能会导致程序报错。所以这里的判断是这个key必须是前端传过来的值,并且根据这个key查找redis中是否存在这条数据,如果存在,则就删除。
所以我们在前端第一次传过来一个随机的数字,因为是第一次,所以第一次并不会进入这个if,所以就很显然进入了下面的代码,下面的代码通过创建图片,返回数据就可以了。
而当我们第二次,进入这个方法的时候,由于第一次创建的图片生成的Key返回给了前端,所以前端会携带这个key来到这个方法,这个时候key不为空并且redis数据库中根据这个key找到了这个数据,也就证明这是第二次重新获取图片验证码,这个时候,程序就会根据用户传递过来的key将原有的数据删除,然后再重新生成新的验证码并且返回给前端。这样以此类推,第三次、第四次、第五次等,都会按照这个流程来走。
email的作用到下面的访问频率再细说
package com.sxy.recordnetwork.controller.pcUser; // import ... import java.util.concurrent.TimeUnit; /** * 第三方短信验证以及其他相关操作 */ @RequestMapping("/pcClientUser/code") @RestController @Slf4j public class CodeController { @Autowired private RedisTemplate redisTemplate; // ImgCodeKey private static String IMG_KEY = ""; /** * 生成图形验证码存入redis * @param key * @return */ @AccessLimit @GetMapping("/getImgBase64/{key}/{email}") public Result getImgBase64(@PathVariable String key,@PathVariable String email) { //TODO 当我前端连续发送多次请求时,会有一部分key拿不到,就无法删除上一个 ValueOperations valueOperations = redisTemplate.opsForValue(); // 重复发送验证码,如果key存在则删除,否则就成为第一次继续生成 if(key != null && redisTemplate.hasKey(key)){ redisTemplate.delete(key); } ImgCodeUtil imgCodeUtil = new ImgCodeUtil(); String[] date = imgCodeUtil.getImgIdCodeBase64(); // 将验证码存入到Redis中 valueOperations.set(date[0], date[1]); // 构造json字符串,将唯一标识和base64数据存入返回给前端 JSONObject entries = new JSONObject(); entries.set("base64Image", "data:image/png;base64," + date[2]); entries.set("captchaKey", date[0]); // 返回给前端 return Result.success("图形验证码获取成功", entries); } /** * 验证图形验证码,得到唯一标识和验证码 * @param userDtoEmailLogin * @return */ @PostMapping("/getEmailCode/") public Result getEmailCode(@RequestBody UserDtoEmailLogin userDtoEmailLogin) { // ...... } }
接下来我们看看第二个接口,
参数为一个DTO:我们创建一个DTO用于接收邮箱登录
参数列表中的emailCode先不管
package com.sxy.recordnetwork.DTO.USER; import lombok.*; /** * QQ邮箱验证登录的数据传输层 */ @Data @ToString @AllArgsConstructor @NoArgsConstructor public class UserDtoEmailLogin { private String email; // email private String imgCode; // 图形验证码 private String key; // 图形验证码的唯一key private String emailCode; // email验证码 }
package com.sxy.recordnetwork.controller.pcUser; // import ... import java.util.concurrent.TimeUnit; /** * 第三方短信验证以及其他相关操作 */ @RequestMapping("/pcClientUser/code") @RestController @Slf4j public class CodeController { @Autowired private RedisTemplate redisTemplate; // ImgCodeKey private static String IMG_KEY = ""; /** * 生成图形验证码存入redis * @param key * @return */ @AccessLimit @GetMapping("/getImgBase64/{key}/{email}") public Result getImgBase64(@PathVariable String key,@PathVariable String email) { // ...... } /** * 验证图形验证码,得到唯一标识和验证码 * @param userDtoEmailLogin * @return */ @PostMapping("/getEmailCode/") public Result getEmailCode(@RequestBody UserDtoEmailLogin userDtoEmailLogin) { ValueOperations valueOperations = redisTemplate.opsForValue(); System.out.println(userDtoEmailLogin); // 得到唯一标识key IMG_KEY = userDtoEmailLogin.getKey(); // 通过key查找值看是否与传递过来的code相等, if (!userDtoEmailLogin.getImgCode().equals(valueOperations.get(IMG_KEY))) { // 不相等抛出异常 throw new EmailCodeException(Constants.EMAIL_IMG_CODE_ERROR.getValue()); } // 先不删除,等登陆成功后再删除相等就成功并将数据清空 // redisTemplate.delete(key); // 返回成功 return Result.success(); } }
我们来看看这个方法,这个方法的参数是一个DTO,上面演示了这个DTO中的内容
我们可以通过前端的参数,传入固定的email,前端传递过来的唯一标识key(固定传入),图形验证码
然后创建一个操作普通字符串的方法
同门通过对象点出这个唯一的key存入本方法中的常量IMG_KEY,用来做持久化后续操作,只有在真正登陆后才会清除这个唯一key。
通过传递过来的key在Redis中查询出对应的value,判断是否与用户传递过来的code验证码匹配,不匹配则抛出异常。
注意:这个异常我用的全局异常处理器,并且声明了自定义异常类,在我往期的文章中讲到。
并且我们验证成功过后先不用删除,我们等后面真正登录成功之后再删除
注意:注意:注意:我这里只是演示了这两个方法,我并没有登陆方法,这个后续可以自己写,我就不写了
package com.sxy.recordnetwork.controller.pcUser; // import ... import java.util.concurrent.TimeUnit; /** * 第三方短信验证以及其他相关操作 */ @RequestMapping("/pcClientUser/code") @RestController @Slf4j public class CodeController { @Autowired private RedisTemplate redisTemplate; // ImgCodeKey private static String IMG_KEY = ""; /** * 生成图形验证码存入redis * @param key * @return */ @AccessLimit @GetMapping("/getImgBase64/{key}/{email}") public Result getImgBase64(@PathVariable String key,@PathVariable String email) { // ...... } /** * 验证图形验证码,得到唯一标识和验证码 * @param userDtoEmailLogin * @return */ @PostMapping("/getEmailCode/") public Result getEmailCode(@RequestBody UserDtoEmailLogin userDtoEmailLogin) { ValueOperations valueOperations = redisTemplate.opsForValue(); System.out.println(userDtoEmailLogin); // 得到唯一标识key IMG_KEY = userDtoEmailLogin.getKey(); // 通过key查找值看是否与传递过来的code相等, if (!userDtoEmailLogin.getImgCode().equals(valueOperations.get(IMG_KEY))) { // 不相等抛出异常 throw new EmailCodeException(Constants.EMAIL_IMG_CODE_ERROR.getValue()); } // 先不删除,等登陆成功后再删除相等就成功并将数据清空 // redisTemplate.delete(key); // 返回成功 return Result.success(); } }
api接口: 前端代码就不讲解了
import request from '@/utils/request.js' // 得到图形验证码的base64状态 export const getCaptchaService = (key,email) => { return request.get('/code/getImgBase64/' + key + '/' + email) } // 验证图形验证码是否输入正确 export const checkCaptchaService = (data) => { return request.post('/code/getEmailCode/', data, { headers: { 'Content-Type': 'application/json' } }) }
第三方登录 返回用户登录 获取图形码 发送验证码 登录
这里有两个默认值,一个为允许访问的次数为5次,一个是在默认的60秒时间范围内访问的次数。也就是设置在Redis中数据的TTL(存活时间)
package com.sxy.recordnetwork.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * 设置访问次数限制的注解 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface AccessLimit { int count() default 5; // 允许被访问的次数 int seconds() default 60; // 时间范围,秒为单位,表示在60秒内访问的次数 }
好,到了这里,我们上面第一个方法中的参数列表是如下以及注解
这个email有什么用呢,以及这个切面的自定义注解
我们先来说一下这个自定义切面的实现逻辑
给这个类标注上aop切面类和Component注解,交给spring管理
添加一个名为AccessLimitBefore这个方法
参数列表为:
JoinPoint:用于获取指定方法上的参数
AccessLimit:用于获取注解内的参数
在这个方法中添加了一个注解:@Before("@annotation(accessLimit)")
表示:添加一个前置通知(@Before)在方法执行之前执行,切入点是:满足了添加AccessLimit注解的方法进行拦截。
在方法内部创建了一个操作普通字符串的redis方法
获取一下自定义注解AccessLimit的属性 times(访问次数)、sencods(TTL过期时间)
使用参数列表中的第一个参数获取方法上的参数为一个Object类型,因为不知道是什么类型嘛,所以就设置为Object类型。
因为我们这里的方法参数列表有两个参数,一个key,一个email,这里的email就有用了 由于是两个参数,所以整体的参数列表就是0,1我们需要获取到email作为指定用户的key,防止多个用户同时操作且会覆盖,所以就需要用到email
然后我们判断是否为空,防止空指针报错
接着这里的key在参数列表中的第0个位置,而email在数组的1位置,所以判断必须要通过获取的列表的参数,拿到email,强制转换为Stiring。
将key设置为:user_ + email
接着直接从Redis获取用户的访问次数
如果次数为空,则将次数设置为0
如果超出了访问次数times,则抛出异常,这里也是一样用到了常量类 + 全局异常处理器
注意:这个times表示的不是时间,而是默认的访问次数频率5,也就是自定义注解中的times属性
如果没有超出次数,则先获取上一次的TTL时间,重新设置一下时长,并且访问次数 + 1
package com.sxy.recordnetwork.aspect; // ...... import java.time.Duration; import java.util.concurrent.TimeUnit; /** * 设置访问次数限制的AOP切面 */ @Aspect @Component @Slf4j public class AccessLimitAspect { @Autowired // redis private RedisTemplate redisTemplate; // 添加一个前置通知,在方法执行之前执行 切入点为满足添加了这个AccessLimit注解的方法 @Before("@annotation(accessLimit)") public void AccessLimitBefore(JoinPoint joinPoint, AccessLimit accessLimit){ // 设置一下redis的访问 ValueOperations valueOperations = redisTemplate.opsForValue(); // 获取注解属性 int times = accessLimit.count(); // 访问次数 int seconds = accessLimit.seconds(); // 在一定时间内 // 获取方法上的参数 Object[] args = joinPoint.getArgs(); // 判断是否为空 if (args == null || args.length == 0){ return; } // 获取当前用户的邮箱号,来判断改用户是否已经访问过 参数为实体类的第二个参数 String email = (String) args[1]; // 设置key String key = "user_" + email; // 从Redis获取用户的访问次数 Integer count = (Integer)valueOperations.get(key); System.out.println(count); if(count == null){ count = 0; } // 如果超出了允许的访问次数,则抛出异常 if(count >= times){ throw new EmailCodeException(Constants.FREQUENT_VISITS.getValue()); } // 获取到redis中指定key的过期时间 Long expire = redisTemplate.getExpire(key); // 判断TTL如果大于零表示存在,则设置过期时间为获取上一次的 if(expire > 0){ valueOperations.set(key,count + 1,expire,TimeUnit.SECONDS); }else{ // 更新Redis中的计数器 单位为秒 valueOperations.set(key,count + 1,seconds,TimeUnit.SECONDS); } } }
总结:以上这种方式,可以方法攻击者,伪造大量请求进行攻击,导致服务器瘫痪。
使用了自定义注解和AOP切面,以及使用图形验证码生成的工具类,加上Redis做数据缓存,设置频率访问
上一篇:Nginx解析漏洞复现