在日常業務場景中,有很多安全性操作例如密碼修改、身份認證等等類似的業務,需要先簡訊驗證通過再進行下一步。

一種直接的方案是提供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);
        }
    }

&n