自訂.NET Core Identity身份驗證和授權教學:AspNetUserTokens 自訂.NET Core Identity身份驗證和授權教學:AspNetUserTokens

Published on Friday, April 28, 2023

.NET Core Identity AspNetUserTokens

在前幾篇的文章中我們成功實踐了本地登入與外部登入的功能 分別使用到 SignInManager 提供的 PasswordSignInAsyncExternalLoginSignInAsync 方法

查看原始碼之後發現這兩個方法底層都會使用 SignInOrTwoFactorAsync 方法
方法內部在執行登入邏輯之前需要先確認帳號是否有開啟二次驗證或者強制跳過二次驗證邏輯

今天就來看看 .Net Identity 內部是如何實做二次驗證的 首先我們可以傳入 bypassTwoFactor 強制跳過二次驗證的邏輯

// SignInManager.cs
if (!bypassTwoFactor && await IsTfaEnabled(user))
{
    if (!await IsTwoFactorClientRememberedAsync(user))
    {
        // Store the userId for use after two factor check
        var userId = await UserManager.GetUserIdAsync(user);
        await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, StoreTwoFactorInfo(userId, loginProvider));
        return SignInResult.TwoFactorRequired;
    }
}

如果要使用二次驗證則需要在 UserStore 額外實做 IUserTwoFactorStore 界面 實做之後我們可以對指定的 User 開啟或關閉二次驗證設定

// SignInManager.cs
private async Task<bool> IsTfaEnabled(TUser user)
    => UserManager.SupportsUserTwoFactor &&
    await UserManager.GetTwoFactorEnabledAsync(user) &&
    (await UserManager.GetValidTwoFactorProvidersAsync(user)).Count > 0;

最後需要在系統內注入相對應的 Provider 如同我們之前實做的 Google Login Provider 二次驗證也有許多預設的 Provider 可以使用 AddDefaultTokenProviders 快速添加幾個預設的 Provider 到系統內

//IdentityBuilderExtensions.cs
public static IdentityBuilder AddDefaultTokenProviders(this IdentityBuilder builder)
{
    var userType = builder.UserType;
    var dataProtectionProviderType = typeof(DataProtectorTokenProvider<>).MakeGenericType(userType);
    var phoneNumberProviderType = typeof(PhoneNumberTokenProvider<>).MakeGenericType(userType);
    var emailTokenProviderType = typeof(EmailTokenProvider<>).MakeGenericType(userType);
    var authenticatorProviderType = typeof(AuthenticatorTokenProvider<>).MakeGenericType(userType);
    return builder.AddTokenProvider(TokenOptions.DefaultProvider, dataProtectionProviderType)
        .AddTokenProvider(TokenOptions.DefaultEmailProvider, emailTokenProviderType)
        .AddTokenProvider(TokenOptions.DefaultPhoneProvider, phoneNumberProviderType)
        .AddTokenProvider(TokenOptions.DefaultAuthenticatorProvider, authenticatorProviderType);
}

此方法會添加四個 Provider

  • TokenOptions.DefaultProvider("Default") = DataProtectorTokenProvider
  • TokenOptions.DefaultEmailProvider("Email") = EmailTokenProvider
  • TokenOptions.DefaultPhoneProvider("Phone") = PhoneNumberTokenProvider
  • TokenOptions.DefaultAuthenticatorProvider("Authenticator") = AuthenticatorTokenProvider

大致了解後我們可以來實做看看

首先新增 AspNetUserTokens 資料表

CREATE TABLE dbo.AspNetUserTokens (
    UserId text NOT NULL,
    LoginProvider character varying(128) NOT NULL,
    Name character varying(128) NOT NULL,
    Value text NULL,
    CONSTRAINT PK_AspNetUserTokens PRIMARY KEY (UserId, LoginProvider, Name),
    CONSTRAINT FK_AspNetUserTokens_AspNetUsers_UserId FOREIGN KEY (UserId) REFERENCES dbo.AspNetUsers (Id) ON DELETE CASCADE
);

Program.cs 註冊新的 Cookie 以及 TokenProvider 這邊只註冊 AuthenticatorTokenProvider

.AddCookie(IdentityConstants.TwoFactorRememberMeScheme, o =>
{
    o.Events = new CookieAuthenticationEvents
    {
        OnValidatePrincipal = SecurityStampValidator.ValidateAsync<ITwoFactorSecurityStampValidator>
    };
})
.AddCookie(IdentityConstants.TwoFactorUserIdScheme, options =>
{
    options.ExpireTimeSpan = TimeSpan.FromMinutes(10);
})

builder.Services.AddIdentityCore<IdentityUser>()
    .AddUserStore<CustomUserStore>()
    .AddSignInManager<SignInManager<IdentityUser>>();
    .AddSignInManager<SignInManager<IdentityUser>>()
    .AddTokenProvider<AuthenticatorTokenProvider<IdentityUser>>(TokenOptions.DefaultAuthenticatorProvider);

接下来到 CustomUserStore.cs 實做新的界面

IUserTwoFactorStore<IdentityUser>,
IUserAuthenticatorKeyStore<IdentityUser>,
IUserAuthenticationTokenStore<IdentityUser>

這邊我們參考 UserStoreBase 的寫法 Github

IUserAuthenticatorKeyStore 這個界面下的兩個方法 SetAuthenticatorKeyAsync 與 GetAuthenticatorKeyAsync 會去使用 IUserAuthenticationTokenStore 界面下的 SetTokenAsync 與 GetTokenAsync 方法

// CustomUserStore.cs
private const string InternalLoginProvider = "[AspNetUserStore]";
private const string AuthenticatorKeyTokenName = "AuthenticatorKey";
    
public Task SetAuthenticatorKeyAsync(IdentityUser user, string key, CancellationToken cancellationToken)
    => SetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, key, cancellationToken);

public Task<string?> GetAuthenticatorKeyAsync(IdentityUser user, CancellationToken cancellationToken)
    => GetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, cancellationToken);

目前只有 UserManager 的以下兩個方法會使用到 GetAuthenticatorKeyAsync 與 SetAuthenticatorKeyAsync GetAuthenticatorKeyAsync 用來取得 Token ResetAuthenticatorKeyAsync 用來設定新的 Token

只要呼叫 ResetAuthenticatorKeyAsync 方法就會幫使用者在資料庫內快速的新增一筆資料

UserId = "586d12f3-5ee5-4953-b855-0b7b900cb81a"
LoginProvider = "[AspNetUserStore]"
Name = "AuthenticatorKey"
Value = "XYBNE4FPX4OM5PPYV6CRZ7ZZNYBI3GPK"

不過新增資料也可以透過其他方式來做到

await _userManager.ResetAuthenticatorKeyAsync(user);
await _userManager.SetAuthenticationTokenAsync(
    user,
    "[AspNetUserStore]",
    "AuthenticatorKey",
    "XYBNE4FPX4OM5PPYV6CRZ7ZZNYBI3GPK");

取得 Token 也有兩種方式

await _userManager.GetAuthenticatorKeyAsync(user);
await _userManager.GetAuthenticationTokenAsync(
    user, 
    "[AspNetUserStore]", 
    "AuthenticatorKey");

從結果來看 IUserAuthenticatorKeyStore 界面也可選擇不實做

處理完後 CustomUserStore 接下來修改 AccountController GetUserAuthenticatorKey 會取得 User 的 AuthenticatorKey 如果找不到則新增一筆 TwoFactorSignIn 負責檢查傳入的二次驗證碼成功驗證後才登入

[HttpGet(template: "~/userAuthenticatorKey", Name = "GetUserAuthenticatorKey")]
public async Task<string?> GetUserAuthenticatorKey(string userName)
{
    var user = await _userManager.FindByNameAsync(userName);
    
    await _userManager.SetTwoFactorEnabledAsync(user, true);
    
    var key = await _userManager.GetAuthenticatorKeyAsync(user);
    if (string.IsNullOrWhiteSpace(key))
    {
        await _userManager.ResetAuthenticatorKeyAsync(user);
        key = await _userManager.GetAuthenticatorKeyAsync(user);
  
    return key;
}

[HttpPost(template: "~/twoFactorSignIn", Name = "TwoFactorSignIn")]
public async Task<SignInResult> TwoFactorSignIn(string code)
{
    return await _signInManager.TwoFactorAuthenticatorSignInAsync(
        code,
        false,
        false);
}

完成後就可以進行測試了 首先呼叫 GetUserAuthenticatorKey 方法幫傳入的會員新增一個 AuthenticatorKey 我們收到 AuthenticatorKey 後可以手動輸入到 Google Authenticator 會產生六位數字

接下來呼叫 SignIn 方法並帶入帳號密碼,如果設定都正確會進入到二次驗證的邏輯 並且瀏覽器會新增 .AspNetCore.Identity.TwoFactorUserId cookie 並且會回傳結果 requiresTwoFactor 為 true 這時需要彈出輸入頁面給使用者輸入六位數字

{
  "succeeded": false,
  "isLockedOut": false,
  "isNotAllowed": false,
  "requiresTwoFactor": true
}

當使用者輸入並按下確認後就必須執行後半段的登入邏輯 此時需要將驗證碼傳入到 TwoFactorSignIn 內部的 TwoFactorAuthenticatorSignInAsync 方法 會檢查目前瀏覽器有沒有 .AspNetCore.Identity.TwoFactorUserId cookie 有的話才會繼續執行 檢查驗證通過之後才會回到原本的流程並且刪除 .AspNetCore.Identity.TwoFactorUserId cookie 然後新增 .AspNetCore.Identity.Application cookie 完成這一次的登入


Summary

今天了解了如何使用內建的 2FA 方法,我們也可以使用其他內建的方法例如手機驗證碼與Email 驗證碼 同樣也會把資料保存在 AspNetUserTokens 這個表,我們可以按照這個邏輯實踐出自己的方法

今天的進度 Github