第一节:连续登录失败锁定 和 利用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 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。
 
posted @ 2025-04-17 14:11  Yaopengfei  阅读(91)  评论(1)    收藏  举报