在日常业务场景中,有很多安全性操作例如密码修改、身份认证等等类似的业务,需要先短信验证通过再进行下一步。
一种直接的方案是提供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); } }