用Abp實現雙因素認證(Two-Factor Authentication, 2FA)登入(一):認證模組

2023-04-08 06:01:21

@


在之前的博文 用Abp實現簡訊驗證碼免密登入(一):簡訊校驗模組 一文中,我們實現了使用者驗證碼校驗模組,今天來拓展這個模組,使Abp使用者系統支援雙因素認證(Two-Factor Authentication)功能。

雙因素認證(Two-Factor Authentication,簡稱 2FA)是使用兩個或多個因素的任意組合來驗證使用者身份,例如使用者提供密碼後,還要提供短訊息傳送的驗證碼,以證明使用者確實擁有該手機。

國內大多數網站在登入屏正常登入後,檢查是否有必要進行二次驗證,如果有必要則進入二階段驗證屏,如下圖:

接下來就來實踐這個小專案

本範例基於之前的博文內容,你需要登入並繫結正確的手機號,才能使用雙因素認證。範例程式碼已經放在了GitHub上:Github:matoapp-samples

原理

檢視Abp原始碼,Abp幫我們定義了幾個Setting,用於設定雙因素認證的相關功能。確保在資料庫中將Abp.Zero.UserManagement.TwoFactorLogin.IsEnabled開啟。

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中,將啟用郵箱驗證方式。

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

在遷移中新增雙因素認證的設定項

//雙因素認證
AddSettingIfNotExists(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsEnabled, "true", tenantId);
AddSettingIfNotExists(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsSmsProviderEnabled, "true", tenantId);
AddSettingIfNotExists(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsEmailProviderEnabled, "true", tenantId);

將預設User的IsTwoFactorEnabled欄位設為true

public User()
{
    this.IsTwoFactorEnabled= true;
}

使用者驗證碼校驗模組

使用AbpBoilerplate.Sms作為簡訊服務庫。

之前定義了DomainService介面,已經實現了驗證碼的傳送、驗證碼校驗、解綁手機號、繫結手機號

這4個功能,通過定義用途(purpose)欄位以校驗區分簡訊模板

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

public const string TWO_FACTOR_AUTHORIZATION = "TWO_FACTOR_AUTHORIZATION";

在SMS服務商管理端後臺申請一個簡訊模板,用於雙因素認證。

開啟簡訊驗證碼的領域服務類SmsCaptchaManager, 新增TWO_FACTOR_AUTHORIZATION對應簡訊模板的編號

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、使用者未開啟雙因素認證,或沒有新增驗證提供者,則跳過雙因素認證。

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值校驗使用者是否通過雙因素認證。

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,此方用於傳送驗證碼。

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傳入

public class SendTwoFactorAuthenticateCaptchaModel
{
    [Range(1, long.MaxValue)]
    public long UserId { get; set; }

    [Required]
    public string Provider { get; set; }
}

將驗證碼Token,和驗證碼提供者Provider的定義新增到AuthenticateModel中

public string TwoFactorAuthenticationToken { get; set; }

public string TwoFactorAuthenticationProvider { get; set; }

將提供者列表TwoFactorAuthenticationProviders,和是否需要雙因素認證RequiresTwoFactorAuthenticate的定義新增到AuthenticateResultModel中

public bool RequiresTwoFactorAuthenticate { get; set; }

public IList<string> TwoFactorAuthenticationProviders { get; set; }

開啟TokenAuthController,注入UserManager和TwoFactorAuthorizationManager服務物件

新增終節點SendTwoFactorAuthenticateCaptcha,用於前端呼叫傳送驗證碼

[HttpPost]
public async Task SendTwoFactorAuthenticateCaptcha([FromBody] SendTwoFactorAuthenticateCaptchaModel model)
{
    await twoFactorAuthorizationManager.SendCaptchaAsync(model.UserId, model.Provider);
}

改寫Authenticate方法如下:

[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,
    };
}

至此,雙因素認證的後端邏輯已經完成,接下來我們將補充「記住」功能,實現一段時間內免驗證。