Abp vnext 6.0手机号验证码登录

abp vnext6.0之后官方替换了原来的ids4,采用了openIddict的oauth认证框架。最近有一个需求是要做手机号+短信验证码登录,故需要对openiddict的授权流程进行扩展,下面记录流程

1、短信服务

首先需要在云服务提供商注册短信验证服务,我才用的是阿里云的短信服务,而且abp提供了Volo.Abp.Sms.Aliyun的包,做一下相关的配置就行:

Configure<AbpAliyunSmsOptions>(options =>
	{
	   options.AccessKeyId = ".....";
	   options.AccessKeySecret = ".......";
	   options.EndPoint = "dysmsapi.aliyuncs.com";
	});

2、扩展ITokenExtensionGrant

然后扩展abp提供的一个接口:ITokenExtensionGrant,这个接口是一个空接口,用于标记一个扩展的openiddict的认证授权流程;同时我们需要为手机号验证登录的这个流程定义相关的名字,我们把这个名字放到一个静态类中:

public static class SmsTokenExtensionGrantConsts
	{
		public const string GrantType = "phone_verify";

		public const string ParamName = "phone_number";

		public const string TokenName = "phone_verify_code";

		public const string Purpose = "phone_verify";

		public const string SecurityCodeFailed = "SecurityCodeFailed";
	}

下面是ITokenExtensionGrant这个接口的实现,abp提供了AbpOpenIdDictControllerBase这个基类,我们基于这个类重写HandleAsync就可以了:

        [IgnoreAntiforgeryToken]
	[ApiExplorerSettings(IgnoreApi = true)]
	public class SmsTokenController : AbpOpenIdDictControllerBase, ITokenExtensionGrant
	{
		protected IOptions<IdentityOptions> IdentityOptions => LazyServiceProvider.LazyGetRequiredService<IOptions<IdentityOptions>>();
		protected IUniquePhoneNumberIdentityUserRepository UserRepository => LazyServiceProvider.LazyGetRequiredService<IUniquePhoneNumberIdentityUserRepository>();
		protected IdentitySecurityLogManager IdentitySecurityLogManager => LazyServiceProvider.LazyGetRequiredService<IdentitySecurityLogManager>();
		protected IdentityUserManager IdentityUserManager => LazyServiceProvider.LazyGetRequiredService<IdentityUserManager>();
		protected IVerificationCodeManager VerificationCodeManager => LazyServiceProvider.LazyGetRequiredService<IVerificationCodeManager>();

		public string Name => SmsTokenExtensionGrantConsts.GrantType;

		public async virtual Task<IActionResult> HandleAsync(ExtensionGrantContext context)
		{
			LazyServiceProvider = context.HttpContext.RequestServices.GetRequiredService<IAbpLazyServiceProvider>();

			var phoneNumberParam = context.Request.GetParameter(SmsTokenExtensionGrantConsts.ParamName);
			var phoneTokenParam = context.Request.GetParameter(SmsTokenExtensionGrantConsts.TokenName);

			if (!phoneNumberParam.HasValue || !phoneTokenParam.HasValue)
			{
				Logger.LogInformation("Invalid grant type: phone number or token code not found");

				var properties = new AuthenticationProperties(new Dictionary<string, string>
				{
					[OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant,
					[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = L["InvalidGrant:PhoneOrTokenCodeNotFound"]
				});

				return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
			}

			var phoneToken = phoneTokenParam.Value.ToString();
			var phoneNumber = phoneNumberParam.Value.ToString();

			await IdentityOptions.SetAsync();

			var currentUser = await UserRepository.FindByConfirmedPhoneNumberAsync(phoneNumber);

			if (currentUser == null)
			{
				//Logger.LogInformation("Invalid grant type: phone number not register");

				//var properties = new AuthenticationProperties(new Dictionary<string, string>
				//{
				//	[OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant,
				//	[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = L["InvalidGrant:PhoneNumberNotRegister"]
				//});

				//return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

				currentUser = await GenerateIdentityUserByPhoneNumber(phoneNumber);
			}

			if (await UserManager.IsLockedOutAsync(currentUser))
			{
				Logger.LogInformation("Authentication failed for username: {username}, reason: locked out", currentUser.UserName);

				var properties = new AuthenticationProperties(new Dictionary<string, string>
				{
					[OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant,
					[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = L["Volo.Abp.Identity:UserLockedOut"]
				});

				await SaveSecurityLogAsync(context, currentUser, OpenIddictSecurityLogActionConsts.LoginLockedout);

				return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
			}

			var validResult = await VerificationCodeManager.VerifiCodeAsync(phoneNumber, phoneToken);

			if (!validResult)
			{
				Logger.LogWarning("Authentication failed for token: {0}, reason: invalid token", phoneToken);
				string errorDescription;

				var identityResult = await UserManager.AccessFailedAsync(currentUser);
				if (identityResult.Succeeded)
				{
					errorDescription = L["InvalidGrant:PhoneVerifyInvalid"];
				}
				else
				{
					Logger.LogInformation("Authentication failed for username: {username}, reason: access failed", currentUser.UserName);


					errorDescription = identityResult.LocalizeErrors(L);
				}

				await SaveSecurityLogAsync(context, currentUser, SmsTokenExtensionGrantConsts.SecurityCodeFailed);

				var properties = new AuthenticationProperties(new Dictionary<string, string>
				{
					[OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant,
					[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = errorDescription
				});

				return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
			}

			(await UserManager.UpdateSecurityStampAsync(currentUser)).CheckErrors();

			return await SetSuccessResultAsync(context, currentUser);
		}

		protected virtual async Task<IActionResult> SetSuccessResultAsync(ExtensionGrantContext context, IdentityUser user)
		{
			Logger.LogInformation("Credentials validated for username: {username}", user.UserName);

			var principal = await SignInManager.CreateUserPrincipalAsync(user);

			principal.SetScopes(context.Request.GetScopes());
			principal.SetResources(await GetResourcesAsync(context.Request.GetScopes()));

			await SetClaimsDestinationsAsync(principal);

			await SaveSecurityLogAsync(
				context,
				user,
				OpenIddictSecurityLogActionConsts.LoginSucceeded);

			return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
		}

		protected async virtual Task SaveSecurityLogAsync(
			ExtensionGrantContext context,
			IdentityUser user,
			string action)
		{
			var logContext = new IdentitySecurityLogContext
			{
				Identity = OpenIddictSecurityLogIdentityConsts.OpenIddict,
				Action = action,
				UserName = user.UserName,
				ClientId = await FindClientIdAsync(context)
			};
			logContext.WithProperty("GrantType", Name);

			await IdentitySecurityLogManager.SaveAsync(logContext);
		}

		protected virtual Task<string> FindClientIdAsync(ExtensionGrantContext context)
		{
			return Task.FromResult(context.Request.ClientId);
		}

		protected virtual async Task<IdentityUser> GenerateIdentityUserByPhoneNumber(string phoneNumber)
		{
			var identityUser = new IdentityUser(GuidGenerator.Create(), phoneNumber, "fake@financing.com", CurrentTenant.Id);
			identityUser.SetPhoneNumber(phoneNumber, true);

			(await IdentityUserManager.CreateAsync(identityUser, null, false)).CheckErrors();

			return identityUser;
		}
	}

在上面这个类中,我们定义了Name,也就是这个扩展的这个openiddict的认证流程的名字。

然后,我们需要配置OpenIddictServerBuilder,将这个自定义的认证流程添加进去:

//定义一个扩展方法
public static OpenIddictServerBuilder AllowSmsFlow(this OpenIddictServerBuilder builder)
		{
			return builder.AllowCustomFlow(SmsTokenExtensionGrantConsts.GrantType);
		}
//然后添加它

PreConfigure<OpenIddictServerBuilder>(builder =>
			{
				builder.AllowSmsFlow();
			});

3、实现一个IUserValidator<IdentityUser>来限制手机号重复注册

采用手机号登录的方式需要 core identity中的手机号保持唯一,这个需要扩展IUserValidator这个接口来进行检查:

public class UniquePhoneNumberUserValidator : IUserValidator<IdentityUser>
	{
		public const string PhoneNumberStartsWithZeroErrorCode = "PhoneNumberStartsWithZero";
		public const string NonNumericPhoneNumberErrorCode = "NonNumericPhoneNumber";
		public const string DuplicatePhoneNumberErrorCode = "DuplicatePhoneNumber";

		private readonly IUniquePhoneNumberIdentityUserRepository _userRepository;
		public UniquePhoneNumberUserValidator(
			IUniquePhoneNumberIdentityUserRepository userRepository)
		{
			_userRepository = userRepository;
		}

		public virtual async Task<IdentityResult> ValidateAsync(UserManager<IdentityUser> manager, IdentityUser user)
		{
			var errors = new List<IdentityError>();

			var phoneNumber = await manager.GetPhoneNumberAsync(user);

			if (string.IsNullOrWhiteSpace(phoneNumber))
			{
				return IdentityResult.Success;
			}

			CheckNotStartsWithZero(phoneNumber, errors);

			CheckIsNumeric(phoneNumber, errors);

			// PhoneNumber can be duplicated but confirmed PhoneNumber can't.
			if (user.PhoneNumberConfirmed)
			{
				await CheckIsNotDuplicateAsync(phoneNumber, manager, user, errors);
			}

			return errors.Count > 0 ? IdentityResult.Failed(errors.ToArray()) : IdentityResult.Success;
		}

		protected virtual async Task CheckIsNotDuplicateAsync(string phoneNumber, UserManager<IdentityUser> userManager,
			IdentityUser user, List<IdentityError> errors)
		{
			Volo.Abp.Identity.IdentityUser owner = await _userRepository.FindByConfirmedPhoneNumberAsync(phoneNumber);

			if (owner != null &&
				!string.Equals(await userManager.GetUserIdAsync(owner), await userManager.GetUserIdAsync(user)))
			{
				errors.Add(new IdentityError
				{
					Code = DuplicatePhoneNumberErrorCode,
					Description = "手机号以被注册"
				});
			}
		}

		protected virtual void CheckIsNumeric(string phoneNumber, List<IdentityError> errors)
		{
			if (!phoneNumber.All(char.IsDigit))
			{
				errors.Add(new IdentityError
				{
					Code = NonNumericPhoneNumberErrorCode,
					Description = "手机号格式错误"
				});
			}
		}

		protected virtual void CheckNotStartsWithZero(string phoneNumber, List<IdentityError> errors)
		{
			if (phoneNumber.StartsWith("0"))
			{
				errors.Add(new IdentityError
				{
					Code = PhoneNumberStartsWithZeroErrorCode,
					Description = "手机号格式错误"
				});
			}
		}
	}

abp默认的IdentityUserRepository没有提供通过手机号检索IdentityUser的方法,所以我们需要自己写一个接口来实现这个功能,代码就不放了,自己实现一个即可。

此外,需要重写登录和注册页面,将手机号的字段添加到上面,我这里是使用vue进行的客户端登录,就没有重写mvc相关的登录和注册逻辑了。

 

转 https://zhuanlan.zhihu.com/p/582835400

posted @   dreamw  阅读(540)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)
点击右上角即可分享
微信分享提示