用Abp實現兩步驗證(Two-Factor Authentication,2FA)登入(三):免登入驗證

2023-04-13 06:01:07

@

免登入驗證是使用者在首次兩步驗證通過後,在常用的裝置(瀏覽器)中,在一定時間內不需要再次輸入驗證碼直接登入。

常見的網頁上提示「7天免登入驗證」或「信任此裝置,7天內無需兩步驗證」等內容。
這樣可以提高使用者的體驗。但同時也會帶來一定的安全風險,因此需要使用者自己決定是否開啟。

原理

常用的實現方式是在使用者登入成功後,生成一個隨機的字串Token,將此Token儲存在使用者瀏覽器的 cookie 中,同時將這個字串儲存在使用者的資料庫中。當用戶再次存取時,如果 cookie 中的字串和資料庫中的字串相同,則免登入驗證通過。流程圖如下:

為了安全,Token採用對稱加密傳輸儲存,同時參與校驗的還有使用者Id,以進一步驗證資料一致性。Token儲存於資料庫中並設定過期時間(ExpireDate)
認證機制由JSON Web Token(JWT)實現,通過自定義Payload宣告中新增Token和使用者Id欄位,實現校驗。

下面來看程式碼實現:

修改請求報文

專案新增對Microsoft.AspNetCore.Authentication.JwtBearer包的參照

<packagereference include="Microsoft.AspNetCore.Authentication.JwtBearer" version="7.0.4">

在Authenticate方法引數AuthenticateModel中新增RememberClient和RememberClientToken屬性,

當首次登入時,若使用者選擇免登入,RememberClient為true,
非首次登入時,系統校驗RememberClientToken合法性,是否允許跳過兩步驗證。

public class AuthenticateModel
{
  ..

    public bool RememberClient { get; set; }

    public string RememberClientToken { get; set; }
}

同時返回值中新增RememberClientToken,用於首次登入生成的Token

public class AuthenticateResultModel
{
    ...

    public string RememberClientToken { get; set; }
}

設定JwtBearerOptions

在TokenAuthController的Authenticate方法中,新增validation引數:

var validationParameters = new TokenValidationParameters
{
    ValidAudience = _configuration.Audience,
    ValidIssuer = _configuration.Issuer,
    IssuerSigningKey = _configuration.SecurityKey
};

在預設的AbpBoilerplate模板專案中已經為我們生成了預設設定

 "Authentication": {
    "JwtBearer": {
      "IsEnabled": "true",
      "SecurityKey": "MatoAppSample_C421AAEE0D114E9C",
      "Issuer": "MatoAppSample",
      "Audience": "MatoAppSample"
    }
  },

生成Token

在TokenAuthController類中

新增自定義Payload宣告型別

public const string USER_IDENTIFIER_CLAIM = "USER_IDENTIFIER_CLAIM";
public const string REMEMBER_CLIENT_TOKEN = "REMEMBER_CLIENT_TOKEN";

新增生成Token的方法CreateAccessToken,它將根據自定義Payload宣告,validationParameters生成經過SHA256加密的Token,過期時間即有效期為7天:

private string CreateAccessToken(IEnumerable<claim> claims, TokenValidationParameters validationParameters)
{
    var now = DateTime.UtcNow;
    var expiration = TimeSpan.FromDays(7);
    var signingCredentials = new SigningCredentials(validationParameters.IssuerSigningKey, SecurityAlgorithms.HmacSha256);


    var jwtSecurityToken = new JwtSecurityToken(
        issuer: validationParameters.ValidIssuer,
        audience: validationParameters.ValidAudience,
        claims: claims,
        notBefore: now,
        expires: now.Add(expiration),
        signingCredentials: signingCredentials
    );

    return new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);
}

更改方法TwoFactorAuthenticateAsync的簽名,新增rememberClient和validationParameters形參

在該方法中新增生成Token的程式碼

if (rememberClient)
{
    if (await settingManager.GetSettingValueAsync<bool>(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsRememberBrowserEnabled))
    {
        var expiration = TimeSpan.FromDays(7);

        var tokenValidityKey = Guid.NewGuid().ToString("N");
        var accessToken = CreateAccessToken(new[]
            {
                new Claim(USER_IDENTIFIER_CLAIM, user.ToUserIdentifier().ToString()),
                new Claim(REMEMBER_CLIENT_TOKEN, tokenValidityKey)
            }, validationParameters
        );
        await _userManager.AddTokenValidityKeyAsync(user, tokenValidityKey,
        DateTime.Now.Add(expiration));
        return accessToken;
    }
}

校驗Token

新增校驗方法TwoFactorClientRememberedAsync,它表示校驗結果是否允許跳過兩步驗證

public async Task<bool> TwoFactorClientRememberedAsync(UserIdentifier userIdentifier, string TwoFactorRememberClientToken, TokenValidationParameters validationParameters)
{
    if (!await settingManager.GetSettingValueAsync<bool>(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsRememberBrowserEnabled))
    {
        return false;
    }

    if (string.IsNullOrWhiteSpace(TwoFactorRememberClientToken))
    {
        return false;
    }

    try
    {
        var tokenHandler = new JwtSecurityTokenHandler();


        if (tokenHandler.CanReadToken(TwoFactorRememberClientToken))
        {
            try
            {
                SecurityToken validatedToken;
                var principal = tokenHandler.ValidateToken(TwoFactorRememberClientToken, validationParameters, out validatedToken);
                var userIdentifierString = principal.Claims.First(c =&gt; c.Type == TwoFactorAuthorizationManager.USER_IDENTIFIER_CLAIM);
                if (userIdentifierString == null)
                {
                    throw new SecurityTokenException(TwoFactorAuthorizationManager.USER_IDENTIFIER_CLAIM + " invalid");
                }

                var tokenValidityKeyInClaims = principal.Claims.First(c =&gt; c.Type == TwoFactorAuthorizationManager.REMEMBER_CLIENT_TOKEN);


                var currentUserIdentifier = UserIdentifier.Parse(userIdentifierString.Value);

                var user = _userManager.GetUserById(currentUserIdentifier.UserId);
                var isValidityKetValid = AsyncHelper.RunSync(() =&gt; _userManager.IsTokenValidityKeyValidAsync(user, tokenValidityKeyInClaims.Value));

                if (!isValidityKetValid)
                {
                    throw new SecurityTokenException(REMEMBER_CLIENT_TOKEN + " invalid");

                }

                return userIdentifierString.Value == userIdentifier.ToString();
            }
            catch (Exception ex)
            {
                LogHelper.LogException(ex);
            }
        }

    }
    catch (Exception ex)
    {
        LogHelper.LogException(ex);
    }

    return false;
}

更改方法IsTwoFactorAuthRequiredAsync新增twoFactorRememberClientToken和validationParameters形參

新增對TwoFactorClientRememberedAsync的呼叫

public async Task<bool> IsTwoFactorAuthRequiredAsync(AbpLoginResult<tenant, user=""> loginResult, string TwoFactorRememberClientToken, TokenValidationParameters validationParameters)
{
    if (!await settingManager.GetSettingValueAsync<bool>(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsEnabled))
    {
        return false;
    }

    if (!loginResult.User.IsTwoFactorEnabled)
    {
        return false;
    }
    if ((await _userManager.GetValidTwoFactorProvidersAsync(loginResult.User)).Count &lt;= 0)
    {
        return false;
    }

    if (await TwoFactorClientRememberedAsync(loginResult.User.ToUserIdentifier(), TwoFactorRememberClientToken, validationParameters))
    {
        return false;
    }

    return true;
}

修改認證EndPoint

在TokenAuthController的Authenticate方法中,找到校驗程式碼片段,對以上兩個方法的呼叫傳入實參

...
await userManager.InitializeOptionsAsync(loginResult.Tenant?.Id);
string twoFactorRememberClientToken = null;
if (await twoFactorAuthorizationManager.IsTwoFactorAuthRequiredAsync(loginResult, model.RememberClientToken, validationParameters))
{
    if (string.IsNullOrEmpty(model.TwoFactorAuthenticationToken))
    {
        return new AuthenticateResultModel
        {
            RequiresTwoFactorAuthenticate = true,
            UserId = loginResult.User.Id,
            TwoFactorAuthenticationProviders = await userManager.GetValidTwoFactorProvidersAsync(loginResult.User),

        };
    }
    else
    {
        twoFactorRememberClientToken = await twoFactorAuthorizationManager.TwoFactorAuthenticateAsync(loginResult.User, model.TwoFactorAuthenticationToken, model.TwoFactorAuthenticationProvider, model.RememberClient, validationParameters);
    }
}

完整的TwoFactorAuthorizationManager程式碼如下:

public class TwoFactorAuthorizationManager : ITransientDependency
{
    public const string USER_IDENTIFIER_CLAIM = "USER_IDENTIFIER_CLAIM";
    public const string REMEMBER_CLIENT_TOKEN = "REMEMBER_CLIENT_TOKEN";

    private readonly UserManager _userManager;
    private readonly ISettingManager settingManager;
    private readonly SmsCaptchaManager smsCaptchaManager;
    private readonly EmailCaptchaManager emailCaptchaManager;

    public TwoFactorAuthorizationManager(
        UserManager userManager,
        ISettingManager settingManager,
        SmsCaptchaManager smsCaptchaManager,
        EmailCaptchaManager emailCaptchaManager)
    {
        this._userManager = userManager;
        this.settingManager = settingManager;
        this.smsCaptchaManager = smsCaptchaManager;
        this.emailCaptchaManager = emailCaptchaManager;
    }



    public async Task<bool> IsTwoFactorAuthRequiredAsync(AbpLoginResult<tenant, user=""> loginResult, string TwoFactorRememberClientToken, TokenValidationParameters validationParameters)
    {
        if (!await settingManager.GetSettingValueAsync<bool>(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsEnabled))
        {
            return false;
        }

        if (!loginResult.User.IsTwoFactorEnabled)
        {
            return false;
        }
        if ((await _userManager.GetValidTwoFactorProvidersAsync(loginResult.User)).Count &lt;= 0)
        {
            return false;
        }

        if (await TwoFactorClientRememberedAsync(loginResult.User.ToUserIdentifier(), TwoFactorRememberClientToken, validationParameters))
        {
            return false;
        }

        return true;
    }

    public async Task<bool> TwoFactorClientRememberedAsync(UserIdentifier userIdentifier, string TwoFactorRememberClientToken, TokenValidationParameters validationParameters)
    {
        if (!await settingManager.GetSettingValueAsync<bool>(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsRememberBrowserEnabled))
        {
            return false;
        }

        if (string.IsNullOrWhiteSpace(TwoFactorRememberClientToken))
        {
            return false;
        }

        try
        {
            var tokenHandler = new JwtSecurityTokenHandler();


            if (tokenHandler.CanReadToken(TwoFactorRememberClientToken))
            {
                try
                {
                    SecurityToken validatedToken;
                    var principal = tokenHandler.ValidateToken(TwoFactorRememberClientToken, validationParameters, out validatedToken);
                    var userIdentifierString = principal.Claims.First(c =&gt; c.Type == TwoFactorAuthorizationManager.USER_IDENTIFIER_CLAIM);
                    if (userIdentifierString == null)
                    {
                        throw new SecurityTokenException(TwoFactorAuthorizationManager.USER_IDENTIFIER_CLAIM + " invalid");
                    }

                    var tokenValidityKeyInClaims = principal.Claims.First(c =&gt; c.Type == TwoFactorAuthorizationManager.REMEMBER_CLIENT_TOKEN);


                    var currentUserIdentifier = UserIdentifier.Parse(userIdentifierString.Value);

                    var user = _userManager.GetUserById(currentUserIdentifier.UserId);
                    var isValidityKetValid = AsyncHelper.RunSync(() =&gt; _userManager.IsTokenValidityKeyValidAsync(user, tokenValidityKeyInClaims.Value));

                    if (!isValidityKetValid)
                    {
                        throw new SecurityTokenException(REMEMBER_CLIENT_TOKEN + " invalid");

                    }

                    return userIdentifierString.Value == userIdentifier.ToString();
                }
                catch (Exception ex)
                {
                    LogHelper.LogException(ex);
                }
            }

        }
        catch (Exception ex)
        {
            LogHelper.LogException(ex);
        }

        return false;
    }

    public async Task<string> TwoFactorAuthenticateAsync(User user, string token, string provider, bool rememberClient, TokenValidationParameters validationParameters)
    {
        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("驗證碼提供者錯誤");
        }


        if (rememberClient)
        {
            if (await settingManager.GetSettingValueAsync<bool>(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsRememberBrowserEnabled))
            {
                var expiration = TimeSpan.FromDays(7);

                var tokenValidityKey = Guid.NewGuid().ToString("N");
                var accessToken = CreateAccessToken(new[]
                    {
                        new Claim(USER_IDENTIFIER_CLAIM, user.ToUserIdentifier().ToString()),
                        new Claim(REMEMBER_CLIENT_TOKEN, tokenValidityKey)
                    }, validationParameters
                );

                await _userManager.AddTokenValidityKeyAsync(user, tokenValidityKey,
                DateTime.Now.Add(expiration));
                return accessToken;


            }
        }

        return null;
    }

    private string CreateAccessToken(IEnumerable<claim> claims, TokenValidationParameters validationParameters)
    {
        var now = DateTime.UtcNow;
        var expiration = TimeSpan.FromDays(7);
        var signingCredentials = new SigningCredentials(validationParameters.IssuerSigningKey, SecurityAlgorithms.HmacSha256);


        var jwtSecurityToken = new JwtSecurityToken(
            issuer: validationParameters.ValidIssuer,
            audience: validationParameters.ValidAudience,
            claims: claims,
            notBefore: now,
            expires: now.Add(expiration),
            signingCredentials: signingCredentials
        );

        return new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);
    }


    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("驗證碼提供者錯誤");
        }
    }



}

至此我們就完成了後端部分的開發

修改前端

登入

在兩步驗證的頁面中新增一個checkbox,用於選擇是否記住使用者端

<el-checkbox v-model="loginForm.rememberClient">
    7天內不再要求兩步驗證
</el-checkbox>

JavaScript部分新增對rememberClientToken的處理,儲存於cookie中,即便在網頁重新整理後也能保持免兩步驗證的狀態

const rememberClientTokenKey = "main_rememberClientToken";
const setRememberClientToken = (rememberClientToken: string) =&gt;
  Cookies.set(rememberClientTokenKey, rememberClientToken);
const cleanRememberClientToken = () =&gt; Cookies.remove(rememberClientTokenKey);
const getRememberClientToken = () =&gt; Cookies.get(rememberClientTokenKey);

在請求body中新增rememberClientToken, rememberClient的值

 var rememberClientToken = getRememberClientToken();
var rememberClient=this.loginForm.rememberClient;

userNameOrEmailAddress = userNameOrEmailAddress.trim();
await request(`${this.host}api/TokenAuth/Authenticate`, "post", {
    userNameOrEmailAddress,
    password,
    twoFactorAuthenticationToken,
    twoFactorAuthenticationProvider,
    rememberClientToken,
    rememberClient
})

請求成功後,返回報文中包含rememberClientToken,將其儲存於cookie中

setRememberClientToken(data.rememberClientToken);

登出

登出的邏輯不用做其他的修改,只需要將頁面的兩步驗證的token清空即可,

this.loginForm.twoFactorAuthenticationToken = "";
this.loginForm.password = "";

rememberClientToken是儲存於cookie中的,當用戶登出時不需要清空cookie中的rememberClientToken,以便下次登入跳過兩步驗證

除非在瀏覽器設定中清空cookie,下次登入時,rememberClientToken就會失效。

最終效果

專案地址

Github:matoapp-samples</tenant,></tenant,>