在之前的博文 用Abp实现短信验证码免密登录(一):短信校验模块 一文中,我们实现了用户验证码校验模块,今天来拓展这个模块,使Abp用户系统支持双因素认证(Two-Factor Authentication)功能。
两步验证,又称双重验证或双因素认证(Two-Factor Authentication,简称 2FA),本文称为“双因素认证”,它是使用两个或多个因素的任意组合来验证用户身份,例如用户提供密码后,还要提供短消息发送的验证码,以证明用户确实拥有该手机。
国内大多数网站在登录屏正常登录后,检查是否有必要进行二次验证,如果有必要则进入二阶段验证屏,如下图:
接下来就来实践这个小项目
本示例基于之前的博文内容,你需要登录并绑定正确的手机号,才能使用双因素认证。示例代码已经放在了GitHub上:Github:matoapp-samples
原理 双因素认证可以拆成两个阶段,第一阶段是普通的用户名+密码登录,一阶段验证是整个身份验证的基础,确保认证安全。整个认证流程如下
是否有必要开启双因素认证是由系统是否开启两步验证、用户是否启用两步验证以及是否通过免登录验证决定的,其中免登录验证将在后续文章中介绍。
查看Abp源码,Abp帮我们定义了几个Setting,用于配置双因素认证的相关功能。确保在数据库中将Abp.Zero.UserManagement.TwoFactorLogin.IsEnabled打开。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public static class TwoFactorLogin { /// <summary> /// "Abp.Zero.UserManagement.TwoFactorLogin.IsEnabled". /// </summary> public const string IsEnabled = "Abp.Zero.UserManagement.TwoFactorLogin.IsEnabled"; /// <summary> /// "Abp.Zero.UserManagement.TwoFactorLogin.IsEmailProviderEnabled". /// </summary> public const string IsEmailProviderEnabled = "Abp.Zero.UserManagement.TwoFactorLogin.IsEmailProviderEnabled"; /// <summary> /// "Abp.Zero.UserManagement.TwoFactorLogin.IsSmsProviderEnabled". /// </summary> public const string IsSmsProviderEnabled = "Abp.Zero.UserManagement.TwoFactorLogin.IsSmsProviderEnabled"; ... }
在AbpUserManager的GetValidTwoFactorProvidersAsync方法中
Abp.Zero.UserManagement.TwoFactorLogin.IsSmsProviderEnabled开启后将添加“Phone”到Provider中,将启用短信验证方式。
Abp.Zero.UserManagement.TwoFactorLogin.IsEmailProviderEnabled开启后将添加“Email”到Provider中,将启用邮箱验证方式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 var isEmailProviderEnabled = await IsTrueAsync( AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsEmailProviderEnabled, user.TenantId ); if (provider == "Email" && !isEmailProviderEnabled) { continue; } var isSmsProviderEnabled = await IsTrueAsync( AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsSmsProviderEnabled, user.TenantId ); if (provider == "Phone" && !isSmsProviderEnabled) { continue; }
在迁移中添加双因素认证的配置项
1 2 3 4 //双因素认证 AddSettingIfNotExists(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsEnabled, "true", tenantId); AddSettingIfNotExists(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsSmsProviderEnabled, "true", tenantId); AddSettingIfNotExists(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsEmailProviderEnabled, "true", tenantId);
将默认User的IsTwoFactorEnabled字段设为true
1 2 3 4 public User() { this.IsTwoFactorEnabled= true; }
用户验证码校验模块 使用AbpBoilerplate.Sms 作为短信服务库。
之前定义了DomainService接口,已经实现了验证码的发送、验证码校验、解绑手机号、绑定手机号
这4个功能,通过定义用途(purpose)字段以校验区分短信模板
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"); }
添加一个用于双因素认证的purpose,在CaptchaPurpose枚举类型中添加TWO_FACTOR_AUTHORIZATION
1 2 public const string TWO_FACTOR_AUTHORIZATION = "TWO_FACTOR_AUTHORIZATION";
在SMS服务商管理端后台申请一个短信模板,用于双因素认证。
打开短信验证码的领域服务类SmsCaptchaManager, 添加TWO_FACTOR_AUTHORIZATION
对应短信模板的编号
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public async Task SendCaptchaAsync(long userId, string phoneNumber, string purpose) { var captcha = CommonHelper.GetRandomCaptchaNumber(); var model = new SendSmsRequest(); model.PhoneNumbers = new string[] { 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" CaptchaPurpose.TWO_FACTOR_AUTHORIZATION => "SMS_1587660" //添加双因素认证对应短信模板的编号 }; ... }
双因素认证模块 创建双因素认证领域服务类TwoFactorAuthorizationManager。
创建方法IsTwoFactorAuthRequiredAsync,返回登录用户是否需要双因素认证,若未开启TwoFactorLogin.IsEnabled、用户未开启双因素认证,或没有添加验证提供者,则跳过双因素认证。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public async Task<bool> IsTwoFactorAuthRequiredAsync(AbpLoginResult<Tenant, User> loginResult) { if (!await settingManager.GetSettingValueAsync<bool>(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsEnabled)) { return false; } if (!loginResult.User.IsTwoFactorEnabled) { return false; } if ((await _userManager.GetValidTwoFactorProvidersAsync(loginResult.User)).Count <= 0) { return false; } return true; }
创建TwoFactorAuthenticateAsync,此方法根据回传的provider和token值校验用户是否通过双因素认证。
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 public async Task TwoFactorAuthenticateAsync(User user, string token, string provider) { if (provider == "Email") { var isValidate = await emailCaptchaManager.VerifyCaptchaAsync(token, CaptchaPurpose.TWO_FACTOR_AUTHORIZATION); if (!isValidate) { throw new UserFriendlyException("验证码错误"); } } else if (provider == "Phone") { var isValidate = await smsCaptchaManager.VerifyCaptchaAsync(token, CaptchaPurpose.TWO_FACTOR_AUTHORIZATION); if (!isValidate) { throw new UserFriendlyException("验证码错误"); } } else { throw new UserFriendlyException("验证码提供者错误"); } }
创建SendCaptchaAsync,此方用于发送验证码。
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 public async Task SendCaptchaAsync(long userId, string Provider) { var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) { throw new UserFriendlyException("找不到用户"); } if (Provider == "Email") { if (!user.IsEmailConfirmed) { throw new UserFriendlyException("未绑定邮箱"); } await emailCaptchaManager.SendCaptchaAsync(user.Id, user.EmailAddress, CaptchaPurpose.TWO_FACTOR_AUTHORIZATION); } else if (Provider == "Phone") { if (!user.IsPhoneNumberConfirmed) { throw new UserFriendlyException("未绑定手机号"); } await smsCaptchaManager.SendCaptchaAsync(user.Id, user.PhoneNumber, CaptchaPurpose.TWO_FACTOR_AUTHORIZATION); } else { throw new UserFriendlyException("验证提供者错误"); } }
改写登录 接下来将双因素认证逻辑添加到登录流程中。
在web.core项目中, 添加类SendTwoFactorAuthenticateCaptchaModel,发送验证码时将一阶段返回的userId和选择验证方式的provider传入
1 2 3 4 5 6 7 8 public class SendTwoFactorAuthenticateCaptchaModel { [Range(1, long.MaxValue)] public long UserId { get; set; } [Required] public string Provider { get; set; } }
将验证码Token,和验证码提供者Provider的定义添加到AuthenticateModel中
1 2 3 public string TwoFactorAuthenticationToken { get; set; } public string TwoFactorAuthenticationProvider { get; set; }
将提供者列表TwoFactorAuthenticationProviders,和是否需要双因素认证RequiresTwoFactorAuthenticate的定义添加到AuthenticateResultModel中
1 2 3 public bool RequiresTwoFactorAuthenticate { get; set; } public IList<string> TwoFactorAuthenticationProviders { get; set; }
打开TokenAuthController,注入UserManager和TwoFactorAuthorizationManager服务对象
添加终节点SendTwoFactorAuthenticateCaptcha,用于前端调用发送验证码
1 2 3 4 5 [HttpPost] public async Task SendTwoFactorAuthenticateCaptcha([FromBody] SendTwoFactorAuthenticateCaptchaModel model) { await twoFactorAuthorizationManager.SendCaptchaAsync(model.UserId, model.Provider); }
改写Authenticate方法如下:
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 38 39 40 41 42 43 44 [HttpPost] public async Task<AuthenticateResultModel> Authenticate([FromBody] AuthenticateModel model) { //用户名密码校验 var loginResult = await GetLoginResultAsync( model.UserNameOrEmailAddress, model.Password, GetTenancyNameOrNull() ); await userManager.InitializeOptionsAsync(loginResult.Tenant?.Id); //判断是否需要双因素认证 if (await twoFactorAuthorizationManager.IsTwoFactorAuthRequiredAsync(loginResult)) { //判断是否一阶段 if (string.IsNullOrEmpty(model.TwoFactorAuthenticationToken)) { //一阶登录完成,返回结果,等待二阶段登录 return new AuthenticateResultModel { RequiresTwoFactorAuthenticate = true, UserId = loginResult.User.Id, TwoFactorAuthenticationProviders = await userManager.GetValidTwoFactorProvidersAsync(loginResult.User), }; } //二阶段,双因素认证校验 else { await twoFactorAuthorizationManager.TwoFactorAuthenticateAsync(loginResult.User, model.TwoFactorAuthenticationToken, model.TwoFactorAuthenticationProvider); } } //二阶段完成,返回最终登录结果 var accessToken = CreateAccessToken(CreateJwtClaims(loginResult.Identity)); return new AuthenticateResultModel { AccessToken = accessToken, EncryptedAccessToken = GetEncryptedAccessToken(accessToken), ExpireInSeconds = (int)_configuration.Expiration.TotalSeconds, UserId = loginResult.User.Id, }; }
至此,双因素认证的后端逻辑已经完成。
项目地址 Github:matoapp-samples