这是一篇系列博文,我将使用Abp.Zero搭建一套集成手机号免密登录验证与号码绑定功能的用户系统:
第三方身份验证在Abp中称之为外部身份验证(ExternalAuthentication) , 区别于Abp的**外部身份授权(ExternalAuth)**,这里Auth的全称应为Authorization,即授权。
首先来厘清这两个不同的业务在Abp中的实现,我之前写的这篇 Abp.Zero 搭建第三方登录模块 系列文章中描述的业务,即使用的Abp外部身份授权(ExternalAuth)的相关扩展而实现的。还记得我们实现的WeChatAuthProvider吗?它继承于ExternalAuthProviderApi这个抽象类,实现的微信授权功能。所以微信登录这个动作,实际是在授权(Authorization)已有的微信账号,访问服务端资源,而身份验证(Authentication)步骤,已在其他端完成了(手机微信扫码),在服务端获取已验证好身份的第三方账户并生成Token则可以抽象的认为是授权(Authorization)行为。
所以“搭建第三方登录模块”应该更准确地描述为“第三方授权模块”。
从Abp接口设计上,也能看得出来两者的差别。
外部身份验证(ExternalAuthentication)关注的是校验,实现TryAuthenticateAsync并返回是否成功,而CreateUserAsync和UpdateUserAsync仅是校验流程里的一部分,不实现它并不影响身份验证结果,外部授权源的接口定义如下,
1 2 3 4 5 6 7 8 9 10 public interface IExternalAuthenticationSource<TTenant, TUser> where TTenant : AbpTenant<TUser> where TUser : AbpUserBase { ... Task<bool> TryAuthenticateAsync(string userNameOrEmailAddress, string plainPassword, TTenant tenant); Task<TUser> CreateUserAsync(string userNameOrEmailAddress, TTenant tenant); Task UpdateUserAsync(TUser user, TTenant tenant); }
外部授权(ExternalAuth)这一步关注的业务是拿到外部账号,如微信的OpenId,所以IExternalAuthManager重点则是GetUserInfo,而IsValidUser并没有在默认实现中使用到
1 2 3 4 5 6 public interface IExternalAuthManager { Task<bool> IsValidUser(string provider, string providerKey, string providerAccessCode); Task<ExternalAuthUserInfo> GetUserInfo(string provider, string accessCode); }
然而这些是从LoginManager原本实现看出的,我们可以重写这个类原本的方法,加入电话号码的处理逻辑。
在搞清楚这两个接口后,相信你会对Abp用户系统的理解更加深刻
短信获取验证码来校验,是比较常用的第三方身份验证方式,今天来做一个手机号码免密登录,并且具有绑定/解绑手机号功能的小案例,效果如图:
示例代码已经放在了GitHub上:Github:matoapp-samples
用户验证码校验模块 首先定义DomainService接口,我们将实现手机验证码的发送、验证码校验、解绑手机号、绑定手机号
这4个功能,并且定义用途以校验行为合法性,和用它来区分短信模板
1 2 3 4 5 6 7 public interface ICaptchaManager { Task BindAsync(string token); Task UnbindAsync(string token); Task SendCaptchaAsync(long userId, string phoneNumber, string purpose); Task<bool> VerifyCaptchaAsync(string token, string purpose = "IDENTITY_VERIFICATION"); }
1 2 3 4 5 6 7 public const string LOGIN = "LOGIN"; public const string IDENTITY_VERIFICATION = "IDENTITY_VERIFICATION"; public const string BIND_PHONENUMBER = "BIND_PHONENUMBER"; public const string UNBIND_PHONENUMBER = "UNBIND_PHONENUMBER";
定义一个验证码Token缓存管理类,以及对应的缓存条目类,用于承载验证码的校验内容
1 2 3 4 5 6 public class SmsCaptchaTokenCache : MemoryCacheBase<SmsCaptchaTokenCacheItem>, ISingletonDependency { public SmsCaptchaTokenCache() : base(nameof(SmsCaptchaTokenCache)) { } }
缓存条目将存储电话号码,用户Id(非登录用途)以及用途
1 2 3 4 5 6 7 8 9 public class SmsCaptchaTokenCacheItem { public string PhoneNumber { get; set; } public long UserId { get; set; } public string Purpose { get; set; } }
阿里云和腾讯云提供了短信服务Sms,是国内比较常见的短信服务提供商,不需要自己写了,网上有大把的封装好的库,这里使用AbpBoilerplate.Sms 作为短信服务库。
创建短信验证码的领域服务类SmsCaptchaManager并实现ICaptchaManager接口,同时注入短信服务ISmsService,用户管理服务UserManager,验证码Token缓存管理服务SmsCaptchaTokenCache
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class SmsCaptchaManager : DomainService, ICaptchaManager { private readonly ISmsService SmsService; private readonly UserManager _userManager; private readonly SmsCaptchaTokenCache captchaTokenCache; public static TimeSpan TokenCacheDuration = TimeSpan.FromMinutes(5); public SmsCaptchaManager(ISmsService SmsService, UserManager userManager, SmsCaptchaTokenCache captchaTokenCache ) { this.SmsService=SmsService; _userManager=userManager; this.captchaTokenCache=captchaTokenCache; } }
新建SendCaptchaAsync方法,作为短信发送和缓存Token方法,CommonHelp中的GetRandomCaptchaNumber()用于生成随机6位验证码,发送完毕后,将此验证码作为缓存条目的Key值存入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public async Task SendCaptchaAsync(long userId, string phoneNumber, string purpose) { var captcha = CommonHelper.GetRandomCaptchaNumber(); var model = new SendSmsRequest(); model.PhoneNumbers= phoneNumber; model.SignName="MatoApp"; model.TemplateCode= purpose switch { CaptchaPurpose.BIND_PHONENUMBER => "SMS_255330989", CaptchaPurpose.UNBIND_PHONENUMBER => "SMS_255330923", CaptchaPurpose.LOGIN => "SMS_255330901", CaptchaPurpose.IDENTITY_VERIFICATION => "SMS_255330974" }; model.TemplateParam= JsonConvert.SerializeObject(new { code = captcha }); var result = await SmsService.SendSmsAsync(model); if (string.IsNullOrEmpty(result.BizId) && result.Code!="OK") { throw new UserFriendlyException("验证码发送失败,错误信息:"+result.Message); } await captchaTokenCache.SetAsync(captcha, new SmsCaptchaTokenCacheItem() { PhoneNumber=phoneNumber, UserId=userId, Purpose=purpose }, absoluteExpireTime: DateTimeOffset.Now.Add(TokenCacheDuration)); }
绑定手机号功能实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public async Task BindAsync(string token) { SmsCaptchaTokenCacheItem currentItem = await GetToken(token); if (currentItem==null || currentItem.Purpose!=CaptchaPurpose.BIND_PHONENUMBER) { throw new UserFriendlyException("验证码不正确或已过期"); } var user = await _userManager.GetUserByIdAsync(currentItem.UserId); if (user.IsPhoneNumberConfirmed) { throw new UserFriendlyException("已绑定手机,请先解绑后再绑定"); } user.PhoneNumber=currentItem.PhoneNumber; user.IsPhoneNumberConfirmed=true; await _userManager.UpdateAsync(user); await RemoveToken(token); }
解绑手机号功能实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public async Task UnbindAsync(string token) { SmsCaptchaTokenCacheItem currentItem = await GetToken(token); if (currentItem==null|| currentItem.Purpose!=CaptchaPurpose.UNBIND_PHONENUMBER) { throw new UserFriendlyException("验证码不正确或已过期"); } var user = await _userManager.GetUserByIdAsync(currentItem.UserId); user.IsPhoneNumberConfirmed=false; await _userManager.UpdateAsync(user); await RemoveToken(token); }
验证功能实现
1 2 3 4 5 6 7 8 9 10 public async Task<bool> VerifyCaptchaAsync(string token, string purpose = CaptchaPurpose.IDENTITY_VERIFICATION) { SmsCaptchaTokenCacheItem currentItem = await GetToken(token); if (currentItem==null || currentItem.Purpose!=purpose) { return false; } await RemoveToken(token); return true; }
实际业务中可能还需要Email验证,我也建立了电子邮箱验证码的领域服务类,只不过没有实现它,动手能力强的读者可以试着完善这个小案例:)
Api实现 AppService层创建CaptchaAppService.cs,并写好接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public class CaptchaAppService : ApplicationService { private readonly SmsCaptchaManager captchaManager; public CaptchaAppService(SmsCaptchaManager captchaManager) { this.captchaManager=captchaManager; } [HttpPost] public async Task SendAsync(SendCaptchaInput input) { await captchaManager.SendCaptchaAsync(input.UserId, input.PhoneNumber, input.Type); } [HttpPost] public async Task VerifyAsync(VerifyCaptchaInput input) { await captchaManager.VerifyCaptchaAsync(input.Token); } [HttpPost] public async Task UnbindAsync(VerifyCaptchaInput input) { await captchaManager.UnbindAsync(input.Token); } [HttpPost] public async Task BindAsync(VerifyCaptchaInput input) { await captchaManager.BindAsync(input.Token); } }
至此我们就完成了验证码相关逻辑的接口 下一章将介绍如何重写Abp默认方法,以集成手机号登录功能。
注意!不要将本示例作为生产级代码使用 本示例中,验证码校验的接口并没有做严格加密,6位验证码也很容易被破解,因此需要考虑这些安全问题。在实际生产代码中,验证的参数常用手机号+验证码做哈希运算保证安全。
项目地址 Github:matoapp-samples