Abp.Zero 手機號免密登入驗證與號碼繫結功能的實現(一):驗證碼模組

2022-11-01 21:01:31

這是一篇系列博文,我將使用Abp.Zero搭建一套整合手機號免密登入驗證與號碼繫結功能的使用者系統:

第三方身份驗證在Abp中稱之為外部身份驗證(ExternalAuthentication), 區別於Abp的外部身份授權(ExternalAuth),這裡Auth的全稱應為Authorization,即授權。

首先來釐清這兩個不同的業務在Abp中的實現,我之前寫的這篇 Abp.Zero 搭建第三方登入模組 系列文章中描述的業務,即使用的Abp外部身份授權(ExternalAuth)的相關擴充套件而實現的。還記得我們實現的WeChatAuthProvider嗎?它繼承於ExternalAuthProviderApi這個抽象類,實現的微信授權功能。所以微信登入這個動作,實際是在授權(Authorization)已有的微信賬號,存取伺服器端資源,而身份驗證(Authentication)步驟,已在其他端完成了(手機微信掃碼),在伺服器端獲取已驗證好身份的第三方賬戶並生成Token則可以抽象的認為是授權(Authorization)行為。

所以「搭建第三方登入模組」應該更準確地描述為「第三方授權模組」。

從Abp介面設計上,也能看得出來兩者的差別。

外部身份驗證(ExternalAuthentication)關注的是校驗,實現TryAuthenticateAsync並返回是否成功,而CreateUserAsync和UpdateUserAsync僅是校驗流程裡的一部分,不實現它並不影響身份驗證結果,外部授權源的介面定義如下,

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並沒有在預設實現中使用到

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個功能,並且定義用途以校驗行為合法性,和用它來區分簡訊模板

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");
}
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快取管理類,以及對應的快取條目類,用於承載驗證碼的校驗內容

public class SmsCaptchaTokenCache : MemoryCacheBase<SmsCaptchaTokenCacheItem>, ISingletonDependency
{
    public SmsCaptchaTokenCache() : base(nameof(SmsCaptchaTokenCache))
    {
    }
}

快取條目將儲存電話號碼,使用者Id(非登入用途)以及用途

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

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值存入

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));
}

繫結手機號功能實現

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);
}

解綁手機號功能實現

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);

}

驗證功能實現

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,並寫好介面

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