第一节:连续登录失败锁定 和 利用redis实现滑动窗口限流
一. 连续登录失败锁定
1. 需求分析
5分钟内连续登录出错3次(第4次出错直接锁定),账号锁定10分钟
2. 原理剖析
(1) 技术储备
【zremrangebyscore key min max】移除有序集 key 中,所有 score 值介于 min 和 max 之间的成员
【zadd key score value】向有序集合key中添加value-score,value自动去重
【zcard key】获取有序集合key中元素的个数
(2) 核心原理
利用ZSET结构, 构建滑动窗口,先删除当前窗口外的所有数据,然后统计当前窗口中数据的个数,决定是否继续进行。
3. 核心步骤
(1) 数据结构分析
A. 首先借助ZSet结构,构建一个滑动窗口, key-value-score, key可以使用userId,value没有实际意义,但又不能重复,所以 **value和score 都设置为登录的时间戳**。
B. 借助String结构,记录该用户是否被锁定,key为:"lock_"+userId , value可以为任意值,无意义。 只要有这条记录,表示被锁定。结合实际业务设置一个过期时间
(2) 流程分析
A. 在**输入密码错误的情况下**才进入下面逻辑,输入正确的话,直接就登录系统了
B. 先借助zremrangebyscore指令,移除 (当前时间-5min) 以前的所有记录,保证后面统计的都是最近5min中内的登录记录
local fiveMinutesAgoTime=ARGV[2] --5min前的时间戳
redis.call('ZREMRANGEBYSCORE',KEYS[1],'-inf',fiveMinutesAgoTime) --删除5分钟前的错误记录,-inf表示负无穷
C 获取该用户最近5min中内的登录次数 (5min以前的记录A中已经删除了)
local count=redis.call('ZCARD',KEYS[1]) --统计5min内的登录次数
D 如果登录次数小于限制次数(3),那么就插入一条登录记录,返回允许请求
如果登录次数大于等于限制(3), 那么就直接锁定该用户(并设置过期时间),不允许记录尝试登录了
if count < 3 then
redis.call('ZADD',KEYS[1],ARGV[1],ARGV[1]) --插入错误记录,返回1,表示允许继续请求
return 1
else
redis.call('SET','lock_'..KEYS[1],1,'EX',60*10) --直接锁定,返回0,表示已锁定
return 0
end
4. 代码分享
(1) 非lua脚本
/// <summary>
/// 校验登录
/// 实现:5分钟内连续登录出错3次,账号锁定10分钟
/// 原理:滑动窗口
/// (第4次出错的时候就锁定,锁定后即使账号密码正确也不能登录)
/// </summary>
/// <param name="userAccount">账号</param>
/// <param name="pwd">密码</param>
/// <returns></returns>
[HttpPost]
public IActionResult CheckLogin(string userAccount, string pwd)
{
//一. 前置-判断该账号是否锁定
string lockKey = "lock_" + userAccount;
var myValue = RedisHelper.Get(lockKey);
//表示已经锁定了
if (myValue != null)
{
var expireTime = RedisHelper.Ttl(lockKey);
return Json(new { status = "error", msg = $"该账户已经锁定,还有{expireTime / 60}分{expireTime % 60}秒后解锁" });
}
//二. 常规业务
//1.校验密码准确性(模拟DB)
if (userAccount == "admin" && pwd == "123456")
{
//执行一些业务,比如获取token等等
return Json(new { status = "ok", msg = "登录成功" });
}
//三. 密码错误后的业务--没有被锁
else
{
//当前时间的时间戳(秒)
long currentTimestamp = (long)(DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1))).TotalSeconds;
//5分钟前的时间戳(秒)
long fiveMinutesAgoTimestamp = (long)(DateTime.UtcNow.AddMinutes(-5).Subtract(new DateTime(1970, 1, 1))).TotalSeconds;
//1.删除5分钟前的错误记录,保证后面统计的都是5min内的登录记录(decimal.MinValue表示最小值)
RedisHelper.ZRemRangeByScore(userAccount, decimal.MinValue, fiveMinutesAgoTimestamp);
//2.统计5min内的登录次数(当前滑动窗口内)
var count = RedisHelper.ZCard(userAccount);
if (count < 3)
{
//插入错误的登录记录,返回登录失败(密码不正确)
RedisHelper.ZAdd(userAccount, (currentTimestamp, currentTimestamp)); //score和member都设为当前时间即可
return Json(new { status = "error", msg = "登录失败,密码不正确" });
}
else
{
//直接锁定, 并且返回该账号已经锁定
RedisHelper.Set(lockKey, 1, 10 * 60); //value设为1,没有实际意义
return Json(new { status = "error", msg = $"登录失败,账号已被锁定,解锁时间为{10 * 60}秒" });
}
}
}
(2) lua脚本
/// <summary>
/// 校验登录-lua脚本实现
/// 实现:5分钟内连续登录出错3次,账号锁定10分钟
/// (第4次出错的时候就锁定,锁定后即使账号密码正确也不能登录)
/// </summary>
/// <param name="userAccount">账号</param>
/// <param name="pwd">密码</param>
/// <returns></returns>
[HttpPost]
public IActionResult CheckLoginByLua(string userAccount, string pwd)
{
//一. 前置-判断该账号是否锁定
string lockKey = "lock_" + userAccount;
var myValue = RedisHelper.Get(lockKey);
//表示已经锁定了
if (myValue != null)
{
var expireTime = RedisHelper.Ttl(lockKey);
return Json(new { status = "error", msg = $"该账户已经锁定,还有{expireTime / 60}分{expireTime % 60}秒后解锁" });
}
//二. 常规业务
//1.校验密码准确性(模拟DB)
if (userAccount == "admin" && pwd == "123456")
{
//执行一些业务,比如获取token等等
return Json(new { status = "ok", msg = "登录成功" });
}
//三. 密码错误后的业务--没有被锁
else
{
//当前时间的时间戳(秒)
long currentTimestamp = (long)(DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1))).TotalSeconds;
//5分钟前的时间戳(秒)
long fiveMinutesAgoTimestamp = (long)(DateTime.UtcNow.AddMinutes(-5).Subtract(new DateTime(1970, 1, 1))).TotalSeconds;
//解释:..KEYS[1] 中的..是字符串的连接符
string luaScript = $@"
local fiveMinutesAgoTime=ARGV[2] --5min前的时间戳
redis.call('ZREMRANGEBYSCORE',KEYS[1],'-inf',fiveMinutesAgoTime) --删除5分钟前的错误记录,-inf表示负无穷
local count=redis.call('ZCARD',KEYS[1]) --统计5min内的登录次数
if count < 3 then
redis.call('ZADD',KEYS[1],ARGV[1],ARGV[1]) --插入错误记录,返回1,表示允许继续请求
return 1
else
redis.call('SET','lock_'..KEYS[1],1,'EX',60*10) --直接锁定,返回0,表示已锁定
return 0
end
";
//KEYS[1]对应userAccount、ARGV[1]对应currentTimestamp、ARGV[2]对应fiveMinutesAgoTimestamp
var result = Convert.ToInt32(RedisHelper.Eval(luaScript, userAccount, [currentTimestamp, fiveMinutesAgoTimestamp]));
if (result == 1)
{
return Json(new { status = "error", msg = "登录失败,密码不正确" });
}
else {
return Json(new { status = "error", msg = $"登录失败,账号已被锁定,解锁时间为{10 * 60}秒" });
}
}
}
二. redis实现滑动窗口限流
1. 需求分析
发送验证码,1个手机号1min只允许发送一次,利用redis实现滑动窗口限流
2 原理剖析
利用ZSET结构,构建滑动窗口,先删除当前窗口外的所有数据,然后统计当前窗口中数据的个数,决定是否继续进行。 【同上】
3 步骤实操
(1) 封装SlidingWindowRateLimiter类和TryAcquire方法,实现滑动窗口限流
代码分享:
/// <summary>
/// 滑动窗口限流
/// </summary>
public class SlidingWindowRateLimiter(CSRedisClient redisClient)
{
private readonly CSRedisClient _redisClient = redisClient;
/// <summary>
/// 是否允许请求
/// </summary>
/// <param name="key">限制key</param>
/// <param name="limit">限制次数</param>
/// <param name="windowSize">窗口大小,单位:秒</param>
/// <returns></returns>
public bool TryAcquire(string key, int limit, int windowSize)
{
var currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); //当前时间戳(毫秒)
var windowStart = currentTime - windowSize * 1000; //当前窗口外的开始时间戳(毫秒)
// 移除当前窗口外的请求记录,保证后面统计的都是当前窗口内的数据(decimal.MinValue表示最小值)
_redisClient.ZRemRangeByScore(key, decimal.MinValue, windowStart);
// 获取当前窗口内的请求数量
var currentCount = _redisClient.ZCard(key);
if (currentCount < limit)
{
// 添加当前请求记录 key-member-score
_redisClient.ZAdd(key, (currentTime, currentTime)); //,其中score和member都插入当前时间戳即可
return true;
}
return false;
}
}
(2) 将SlidingWindowRateLimiter类在Program中注册成单例类
//滑动窗口限流注册
builder.Services.AddSingleton<SlidingWindowRateLimiter>();
(3) 发送验证码接口
A 利用redis滑动窗口限流【目的:防止短信并发发送,灰产盗刷】
B 生成随机数字6位验证码
C 调用第三方短信接口发送,成功 or 失败 都返回给前端通知,比如发送成功 or 发送失败通知,发送成功前端点击按钮变灰60s
D 发送成功,需保存验证码:利用redis存储,key为regcode_+phone,value为验证码,过期时间为5min。
(登录发送到验证码存储key为: logincode_ ; 注册的key为:regcode_ )
补充:
60s后再次发送,依旧是上述流程,不需要判断redis中是否存在,直接set覆盖即可,同时也重置了过期时间。
/// <summary>
/// 01-发送验证码
/// </summary>
/// <param name="phoneNumber">手机号</param>
/// <param name="flag">1表示注册验证码、2表示登录验证码</param>
/// <returns></returns>
[HttpPost]
public IActionResult SendVerificationCode(string phoneNumber, string flag = "1")
{
try
{
int limit = 1; //次数
int windowSize = 60; //1分钟
//1. 滑动窗口限流(1min内一个手机号只能发送一次)
if (!rateLimiter.TryAcquire($"limitCounts_{phoneNumber}", limit, windowSize))
{
return Json(new { status = "error", msg = "超过限流次数,不能发送验证码" });
}
//2. 生成验证码,调用第三方短信接口
var code = Random.Shared.Next(10000, 99999); //生成验证码
if (!SendSms(phoneNumber, code))
{
return Json(new { status = "error", msg = "短信接口调用失败" });
}
//3 将验证码存入redis中,并设置过期时间
var key = flag == "1" ? $"regcode_{phoneNumber}" : $"logincode_{phoneNumber}";
RedisHelper.Set(key, code, 5 * 60); //5分钟有效期
//4 返回
return Json(new { status = "ok", msg = "发送成功", data = code });
}
catch (Exception ex)
{
return Json(new { status = "error", msg = "发送失败_" + ex.Message });
}
}
private bool SendSms(string phoneNumber, int code)
{
// 这里可以调用实际的短信发送服务
return true;
}
(4) 注册接口
A 通过key=regcode_+phone,去redis中获取验证码,如果验证码不存在,证明已经过期了 或者 根本没有发送。
B 如果验证码存在,则比较验证码的准确性,验证码正确,走后续的注册流程
C 注册成功,删除redis中的验证码 【可以不删,走redis的过期删除策略】
/// <summary>
/// 02-通过验证码注册
/// </summary>
/// <param name="phoneNumber">手机号</param>
/// <param name="code">验证码</param>
/// <returns></returns>
[HttpPost]
public IActionResult RegisterByCode(string phoneNumber, string code)
{
try
{
//1 判断验证码是否正确
string key = $"regcode_{phoneNumber}";
var myCode = RedisHelper.Get(key);
if (code != myCode)
{
return Json(new { status = "error", msg = "验证码错误" });
}
//2 执行DB层次的注册业务--假设注册成功
//3.删除验证码(可以不删,等着5min自动过期)
RedisHelper.Del(key);
//4 返回
return Json(new { status = "ok", msg = "注册成功" });
}
catch (Exception ex)
{
return Json(new { status = "error", msg = "注册失败" + ex.Message });
}
}
(5) 登录接口
和上述注册接口中的校验流程相同,只不过是存放redis的前缀不同,登录的key=logincode_+phone,后续流程都相同。
/// <summary>
/// 03-通过验证码登录
/// </summary>
/// <param name="phoneNumber">手机号</param>
/// <param name="code">验证码</param>
/// <returns></returns>
[HttpPost]
public IActionResult LoginByCode(string phoneNumber, string code)
{
try
{
//1 判断验证码是否正确
string key = $"logincode_{phoneNumber}";
var myCode = RedisHelper.Get(key);
if (code != myCode)
{
return Json(new { status = "error", msg = "验证码错误" });
}
//2 执行DB层次的注册业务--假设登录成功
//3.删除验证码(可以不删,等着5min自动过期)
RedisHelper.Del(key);
//4 返回
return Json(new { status = "ok", msg = "登录成功" });
}
catch (Exception ex)
{
return Json(new { status = "error", msg = "登录失败" + ex.Message });
}
}
!
- 作 者 : Yaopengfei(姚鹏飞)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 声 明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
- 声 明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。