.NET Core Identity Manager

在上一篇的文章中 實做了 .NET Core Identity 提供的 IUserStore 介面,用來存取資料庫中的資料, 但是同時也了解到直接使用 Store 意義並不大,這是因為 Identity 當初的設計就是將商業邏輯與底層存取功能分開, 所以我們不該直接使用 IUserStore 而是該使用的是各種的 Manager 方法。

我們來看看 Identity 中常用的 Manager:

  • SignInManager
  • UserManager
  • RoleManager

首先我們先看看 UserManager 的原始碼 Github 會發現 UserManager 之中有注入 IUserStore<TUser>,在上一篇的文章中建立一個 AccountController 並且註冊了 IUserStore<IdentityUser> 介面, 然後使用 _userStore 存取資料。

但是在實務中,我們應該要在新增會員前做其他額外的檢查,例如確認帳號是否已經存在、密碼是否符合要求等等,需要額外確認後才插入到資料中,
沒有使用 .NET Core Identity 就必須自己實踐這一段的商業邏輯,由於認證與授權是整個系統之中最重要也最基本的功能, 並且沒有經驗的開發人員也可能會寫出有漏洞的程式碼,這時就可以直接使用 .NET Core Identity 中的 UserManager 準備的商業邏輯。

我們先來看看 FindByNameAsync 方法 Github

public virtual async Task<TUser?> FindByNameAsync(string userName)
{
    ThrowIfDisposed();
    ArgumentNullThrowHelper.ThrowIfNull(userName);
    userName = NormalizeName(userName);

    var user = await Store.FindByNameAsync(userName, CancellationToken).ConfigureAwait(false);

    // Need to potentially check all keys
    if (user == null && Options.Stores.ProtectPersonalData)
    {
        var keyRing = ServiceProvider.GetService<ILookupProtectorKeyRing>();
        var protector = ServiceProvider.GetService<ILookupProtector>();
        if (keyRing != null && protector != null)
        {
            foreach (var key in keyRing.GetAllKeyIds())
            {
                var oldKey = protector.Protect(key, userName);
                user = await Store.FindByNameAsync(oldKey, CancellationToken).ConfigureAwait(false);
                if (user != null)
                {
                    return user;
                }
            }
        }
    }
    return user;
}

會發現 UserManagerFindByNameAsync 方法會除了將輸入的會員名稱進行搜尋還額外新增了其它檢查邏輯

  1. 檢查傳入的 userName 是否為空,空值就拋出錯誤
  2. 將 userName 正規化 (透過注入的 ILookupNormalizer 決定要全部轉成大寫或全部小寫)
  3. 透過 Store 的 FindByNameAsync 方法取得 User (會使用之前建立的 CustomUserStoreFindByNameAsync 方法)
  4. 確認系統有沒有開啟 ProtectPersonalData 加密,如果有的話要先去 KeyRing 取得密鑰在對 userName 進行加密後查詢 (因為資料庫的 userName 是加密過的直接用明文查詢會查不到)
  5. 回傳查到的 User 或 Null

另外看一下 CreateAsync 方法

public virtual async Task<IdentityResult> CreateAsync(TUser user)
{
    ThrowIfDisposed();
    await UpdateSecurityStampInternal(user).ConfigureAwait(false);
    var result = await ValidateUserAsync(user).ConfigureAwait(false);
    if (!result.Succeeded)
    {
        return result;
    }
    if (Options.Lockout.AllowedForNewUsers && SupportsUserLockout)
    {
        await GetUserLockoutStore().SetLockoutEnabledAsync(user, true, CancellationToken).ConfigureAwait(false);
    }
    await UpdateNormalizedUserNameAsync(user).ConfigureAwait(false);
    await UpdateNormalizedEmailAsync(user).ConfigureAwait(false);

    return await Store.CreateAsync(user, CancellationToken).ConfigureAwait(false);
}
  1. 如果 Store 有實現 IUserSecurityStampStore 界面就利用 store 的 SetSecurityStampAsync 方法更新 User 的 SecurityStamp
  2. 呼叫 ValidateUserAsync 方法,內部會根據註冊的 UserValidators 進行基本的帳號不重複檢查或 Email不重複檢查
  3. 檢查系統設定是否有開啟 UserLockout(登入錯誤次數過多自動鎖定)
  4. 利用 Store 的 SetNormalizedUserNameAsync 方法更新 User 的 NormalizedUserName
  5. 如果 Store 有實現 IUserEmailStore 界面就利用 store 的 SetNormalizedEmailAsync 方法更新 User 的 NormalizedEmail

看玩 CreateAsyncFindByNameAsync 它們的內部商業邏輯後發現還有幾個 Store 方法目前還沒有實做所以需要先回到 CustomUserStore 實做這幾個方法

  • GetUserIdAsync
  • GetUserNameAsync
  • SetNormalizedUserNameAsync
public Task<string> GetUserIdAsync(IdentityUser user, CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();
    if (user == null)
    {
        throw new ArgumentNullException(nameof(user), $"Parameter {nameof(user)} cannot be null.");
    }
    return Task.FromResult(user.Id);
}
    
public Task<string?> GetUserNameAsync(IdentityUser user, CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();
    if (user == null)
    {
        throw new ArgumentNullException(nameof(user), $"Parameter {nameof(user)} cannot be null.");
    }
    return Task.FromResult(user.UserName);
}
    
public Task SetNormalizedUserNameAsync(
        IdentityUser user,
        string? normalizedName,
        CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();
    if (user == null)
    {
        throw new ArgumentNullException(nameof(user), $"Parameter {nameof(user)} cannot be null.");
    }
    user.NormalizedUserName = normalizedName;
    return Task.CompletedTask;
}

完成之後準備修改 AccountController,從原本的 IUserStore 改成註冊 UserManager,因為 UserManager 內部會自行處理帳號大小寫問題 所以自己就不用額外處理了。

[ApiController]
[Route("[controller]")]
public class AccountController : ControllerBase
{
    private readonly UserManager<IdentityUser> _userManager;
    public AccountController(UserManager<IdentityUser> userManager)
    {
        _userManager = userManager;
    }

    [HttpPost("CreateUser")]
    public async Task<IdentityResult> CreateUser(string userName)
    {
        var identityUser = new IdentityUser(userName);
        return await _userManager.CreateAsync(identityUser);
    }

    [HttpGet("GetUser")]
    public async Task<IdentityUser?> GetUser(string userName)
    {
        return await _userManager.FindByNameAsync(userName);
    }
}

接下來就可以進行測試,先 Post https://localhost:7057/Account/CreateUser?userName=user2 建立一個新的會員 user2, 由於沒有實現 IUserSecurityStampStore 與 IUserEmailStore 所以會略過這部份的設定。

建立完成後 Get https://localhost:7057/Account/GetUser?userName=user2 取得資料庫的會員,發現 NormalizedUserName 有確實更新到資料庫內

如果再次 Post https://localhost:7057/Account/CreateUser?userName=user2 會回傳錯誤結果提示 Username 重複了

{
  "succeeded": false,
  "errors": [
    {
      "code": "DuplicateUserName",
      "description": "Username 'user2' is already taken."
    }
  ]
}

Summary

今天學習了 UserManager 中 CreateAsyncFindByNameAsync 內部的邏輯並且成功改寫我們的 AccountController 將原本的 UserStore 改成註冊 UserManager
這樣就多了許多保護邏輯讓我們的 Api 不用寫額外的程式就能有可靠的商業邏輯可以使用,但如果還是覺得預設的 UserManager 方法不夠用 我們可以建立另一個 Class 繼承 UserManager, 因為原本的方法都是 virtual 所以可以輕易的用 override 對方法進行改寫加入自己的商業邏輯,客製化程度相當高
關於 .NET Core Identity 還有許多的功能之後的文章會再進行討論。