短信验证码验证机制 服务端独立接口实现

时间:2024-01-26 19:23:24

在日常业务场景中,有很多安全性操作例如密码修改、身份认证等等类似的业务,需要先短信验证通过再进行下一步。

一种直接的方案是提供2个接口:

1.SendActiveCodeFor密码修改,发送相应的短信+验证Code。

2.VerifyActiveCodeFor密码修改,参数带入手机接收到的短信验证Code,服务端进行验证,验证成功则开发 修改密码。

这种方案有一个缺点,即针对大量类似的业务,会出现非常多的SendMessageForXXX+VerifyMessageCodeForXXX这种组合接口,造成非常大的维护负担。

那么我们是否可以将短信验证码业务独立出来作为一个公用服务呢?

答:Yes!考虑只有一个 SendActiveCode接口和VerifyActiveCode,验证完成后返回一个token。具体的业务场景去拿这个token来作为判断验证码是否验证通过,来决定进行下一步业务逻辑操作。

为了业务逻辑完整性,我们还将加入一些短信发送安全性的考虑。(随便网上找了个在线制图,没想到有水印啊~~,,请忽略。)

主要有以下几个核心逻辑点。

安全性验证

主要为了防止短信滥发的情况出现,会针对手机号和手机设备号(能够标识手机唯一性的码)作一些检查限制。

  • 限制同一手机号发送次数,例如每天对多发送10次,或者每小时 最多发送5次,等等类似
  • 限制t同一手机号发送频率,例如每60秒最多发送一次
  • 限制同一手机设备号发送次数,例如每天最多发送20次
  • 限制同一手机号设备号发送频率,例如每分钟最多2次
  • 增加手机黑名单和手机设备号机制

接口上下文Token

该token主要是为了在VerifyActiveCode接口能正确获取第一步SendActiveCode接口中的一些数据用于验证。这些数据不能直接通过VerifyActiveCode接口带入!否则对于服务端接口,会有跳过第一步接口,直接调用第二个接口验证的漏洞。

通过token能够获取的内容应当至少包括以下:

  • 手机号,验证前后是否一致
  • 设备号,验证前后是否一致
  • Code,第一步接口生成的验证Code,用于和VerifyActiveCode接口参数传递的Code对比验证
  • 业务ID,标识哪个业务模块,可用与获取短信模板发送
  • 创建时间
  • 过期时间,这个根据具体业务设定,一般5分钟即可。一个验证场景差不多就是这个时间跨度

那么对从token如何获取内容也有2种方案,各有千秋

  • token为一个无任何含义的随机字符串(如Guid),服务端将token内容与token匹配关系存到分布式缓存中。第一步接口以token为key从缓存获取对应内容来验证。
  • token为一个有实质内容的加密字符串,服务端接收到token,进行解密获取内容来验证。

前者安全性更高,但是强依赖缓存依赖;后者更加独立无依赖,但是加密算法要够强,加密密钥需要严加保密。一旦加密被破解,会产生严重的安全问题。

验证成功Token

该token主要是为了标识验证结果,没有什么敏感性内容。但是需要有能验签、防篡改、时效性这些特性。所有jwt是一个很好的选择。

 

OK,设计部分就讲完了,如果对实现有兴趣的话,大家可以从这里直接下载:https://gitee.com/gt1987/gt.Microservice/tree/master/src/Services/ShortMessage/gt.ShortMessage

这些贴一些关键性代码。

1.安全性验证模块,IMessageSendValidator 负责检查和数据收集统计。注意,负责具体执行的是 IPhoneValidator和IUniqueIdValidator,具体的实现有PhoneBlackListValidator、PhonePerDayCountValidator、UniqueIdPerDayCountValidator。可扩展添加

public class MessageSendValidator : IMessageSendValidator
    {
        private readonly List<IPhoneValidator> _phoneValidators = null;
        private readonly List<IUniqueIdValidator> _uniqueIdValidators = null;
        private readonly ILogger _logger;
        public MessageSendValidator(List<IPhoneValidator> phoneValidators,
            List<IUniqueIdValidator> uniqueIdValidators,
            ILogger<MessageSendValidator> logger)
        {
            _phoneValidators = phoneValidators ?? new List<IPhoneValidator>();
            _uniqueIdValidators = uniqueIdValidators ?? new List<IUniqueIdValidator>();
            _logger = logger;
        }

        public bool Validate(string phone, string uniqueId)
        {
            if (string.IsNullOrEmpty(phone) || string.IsNullOrEmpty(uniqueId)) return false;
            bool result = true;
            foreach (var validator in _phoneValidators)
            {
                if (!validator.Validate(phone))
                {
                    _logger.LogDebug($"phone:{phone} validate failed by {validator.GetType()}");
                    result = false;
                    break;
                }
            }
            if (!result) return result;

            foreach (var validator in _uniqueIdValidators)
            {
                if (!validator.Validate(uniqueId))
                {
                    _logger.LogDebug($"uniqueId:{uniqueId} validate failed by {validator.GetType()}");
                    result = false;
                    break;
                }
            }
            return result;
        }

        public void AfterSend(string phone, string uniqueId)
        {
            if (string.IsNullOrEmpty(phone) || string.IsNullOrEmpty(uniqueId)) return;
            foreach (var validator in _phoneValidators)
            {
                validator.Statistics(phone);
            }

            foreach (var validator in _uniqueIdValidators)
            {
                validator.Statistics(uniqueId);
            }
        }
    }

2.Token模块,这里实现的是加密token方式。

    /// <summary>
    /// 加密token
    /// 生成一个加密字符串,用于上下文验证
    /// 优点:无状态,无依赖服务端存储
    /// 缺点:加密算法要够强,否则被破解会导致安全问题。
    /// </summary>
    public class EncryptTokenService : ITokenService
    {
        private ILogger _logger;
        private readonly string _tokenSecret = "secret234234287fdf4";
        public EncryptTokenService(ILogger<EncryptTokenService> logger)
        {
            _logger = logger;
        }

        public string CreateSuccessToken(string phone, string uniqueId)
        {
            //这里尝试生成一个jwt,没有敏感信息,主要用于验证
            var claims = new[] {
                new Claim(ClaimTypes.MobilePhone,phone),
                new Claim("uniqueId",uniqueId),
                new Claim("succ","true")
            };
            var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_tokenSecret));
            var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
            var token = new JwtSecurityToken("www.gt.com", null, claims, null, DateTime.Now.AddMinutes(10), creds);
            return new JwtSecurityTokenHandler().WriteToken(token);
        }

        public string CreateActiveCodeToken(ActiveCode code)
        {
            var json = JsonConvert.SerializeObject(code);
            return SecurityHelper.DesEncrypt(json);
        }

        public bool VerifyActiveCodeToken(string token, string code, ref ActiveCode activeCode)
        {
            string json = string.Empty;
            try
            {
                json = SecurityHelper.DesDecrypt(token);
                activeCode = JsonConvert.DeserializeObject<ActiveCode>(json);
            }
            catch (Exception ex)
            {
                _logger.LogDebug($"token:{token}.error:{ex.Message + ex.StackTrace}");
            }
            if (activeCode == null) return false;
            if (activeCode.ExpiredTimeStamp < DateTimeHelper.ToTimeStamp(DateTime.Now))
            {
                _logger.LogDebug($"token {json} expired.");
                return false;
            }
            if (!string.Equals(activeCode.Code, code, StringComparison.CurrentCultureIgnoreCase))
            {
                _logger.LogDebug($"token {json} code not match {code}.");
                return false;
            }
            return true;
        }
    }

具体的接口code为

    [Route("api/[controller]")]
    [ApiController]
    public class ShortMessageController : ApiControllerBase
    {
        private readonly IMessageSendValidator _validator;
        private readonly IActiveCodeService _activeCodeService;
        private readonly ITokenService _tokenService;
        private readonly IShortMessageService _shortMessageService;

        public ShortMessageController(IMessageSendValidator validator,
            IActiveCodeService activeCodeService,
            ITokenService tokenService,
            IShortMessageService shortMessageService)
        {
            _validator = validator;
            _activeCodeService = activeCodeService;
            _tokenService = tokenService;
            _shortMessageService = shortMessageService;
        }


        [Route("ping")]
        [HttpGet]
        public IActionResult Ping()
        {
            return Ok("ok");
        }
        /// <summary>
        /// 发送短信验证码
        /// </summary>
        /// <param name="request"></param>
        /// <returns></returns>
        [Route("activecode")]
        [HttpPost]
        public IActionResult ActiveCode(SendActiveCodeRequest request)
        {
            if (request == null ||
                string.IsNullOrEmpty(request.Phone) ||
                string.IsNullOrEmpty(request.UniqueId) ||
                string.IsNullOrEmpty(request.BusinessId))
                return BadRequest();

            if (!_validator.Validate(request.Phone, request.UniqueId))
                return Error(-1, "手机号或设备号发送次数受限!");

            var activeCode = _activeCodeService.GenerateActiveCode(request.Phone, request.UniqueId, request.BusinessId);
            var token = _tokenService.CreateActiveCodeToken(activeCode);
            var result = _shortMessageService.SendActiveCode(activeCode.Code, activeCode.BusinessId);

            if (!result)
                return Error(-2, "短信发送失败,请重新尝试!");

            _validator.AfterSend(request.Phone, request.UniqueId);

            return Success(token);
        }

        /// <summary>
        /// 短信验证码验证
        /// </summary>
        /// <param name="request"></param>
        /// <returns></returns>
        [Route("verifyActivecode")]
        [HttpPost]
        public IActionResult VerifyActiveCode(VerifyActiveCodeRequest request)
        {
            if (request == null ||
                string.IsNullOrEmpty(request.Code)
                || string.IsNullOrEmpty(request.Token))
                return BadRequest();

            ActiveCode activeCode = null;

            if (!_tokenService.VerifyActiveCodeToken(request.Token, request.Code, ref activeCode))
                return Error(-5, "验证失败!");

            //返回验证成功的token,用于后续处理业务。token应有 可验签、防篡改、时效性特征。这里jwt比较适合
            var successToken = _tokenService.CreateSuccessToken(activeCode.Phone, activeCode.UniqueId);
            return Success(successToken);
        }
    }