自訂.NET Core Identity身份驗證和授權教學:學習 Manager 方法 自訂.NET Core Identity身份驗證和授權教學:學習 Manager 方法

Published on Saturday, April 15, 2023

.NET Core Identity Manager

在上一篇的文章中我們學習到了 .NET Core Identity 中底層的組件 Store,負責取得 Database 資料的操作邏輯 但是同時也了解到直接使用 Store 意義並不大,這是因為 Identity 設計就是會將商業邏輯與存取邏輯分開
所以今天我們來看看 Identity 中常用的 Manager

  • SignInManager
  • UserManager
  • RoleManager

首先我們先看看 UserManager 的原始碼 Github 會發現 UserManager 之中有注入 IUserStore<TUser>,代表它跟我們上一篇文章建立的 Controller 一樣有能力透過 Store 去存取資料庫的資料 同樣發現也有兩個方法 CreateAsync FindByNameAsync,在上一篇的文章中有建立一個 AccountController 並且注入 IUserStore 然後使用 _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 = _services.GetService<ILookupProtectorKeyRing>();
        var protector = _services.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;
}

會發現這段 FindByNameAsync 方法會利用輸入的會員名稱進行搜尋並且內部會多了額外的檢查邏輯

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

了解 Store 與 manager 的差別之後回到我們的專案對 Controller 進行改寫,額外註入 UserManager

[HttpGet(Name = "GetUser")]
public async Task<IdentityUser> GetUser()
{
    var userName = "User1";
    return await _userManager.FindByNameAsync(userName);
}

另外來看一下 UserManagerCreateAsync 方法

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. 檢查目前這個實例是否已經被標注 _disposed = true
  2. 如果 Store 有實現 IUserSecurityStampStore 界面就利用 store 的 SetSecurityStampAsync 方法更新 User 的 SecurityStamp
  3. 呼叫 ValidateUserAsync 方法,內部會根據註冊的 UserValidators 進行基本的帳號不重複檢查或 Email不重複檢查
  4. 檢查系統設定是否有開啟 UserLockout(登入錯誤次數過多自動鎖定)
  5. 利用 Store 的 SetNormalizedUserNameAsync 方法更新 User 的 NormalizedUserName
  6. 如果 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;
}

完成之後回到 Controller 修改 CreateUser 方法

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

接下來就可以進行測試了,先 Post http://localhost:5204/Account 建立一個新的會員,這邊因為沒有實現 IUserSecurityStampStore 與 IUserEmailStore 所以會略過這部份的設定,不過 NormalizedUserName 還是會進行賦值,建立完成後在 Get http://localhost:5204/Account 取得資料庫的會員,發現 NormalizedUserName 有確實更新到資料庫內

如果再次 Post http://localhost:5204/Account 會回傳錯誤結果提示 Username 重複了

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

Summary

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

今天的進度 Github