在日常業務場景中,有很多安全性操作例如密碼修改、身份認證等等類似的業務,需要先短信驗證通過再進行下一步。
一種直接的方案是提供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); } }