自訂.NET Core Identity身份驗證和授權教學:從零開始 自訂.NET Core Identity身份驗證和授權教學:從零開始

Published on Thursday, April 13, 2023

.NET Core Identity

在開發新專案時,身份驗證和授權通常為第一個實做的功能,在 dotnet 生態系中微軟官方會推薦你使用 ASP.NET Core Identity 來實踐 大部分的文章只會要你使用 Visual Studio 直接勾選使用樣板,或是直接給你一段命令直接產生專案

dotnet new webapp --auth Individual -uld -o WebApp1

就可以產生一個能夠進行註冊與登入的簡易網站,這樣雖然可以快速產生一個可以用的專案, 假如我想要在 User 表加入自定義的欄位,或者客製化授權方面的邏輯,那就必須調整一下樣板

首先我們看看一般的 webapp 樣板建立的專案(WebApp2),與我們剛剛建立的 WebApp1 進行比較,觀察 dotnet 額外幫我們新增了什麼程式碼

dotnet new webapp -o WebApp2

發現多參考了幾個 package

  • Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore //開發時期使用,可以顯示詳細錯誤頁面
  • Microsoft.AspNetCore.Identity.EntityFrameworkCore //Identity 可以經由 EFCore 保存資料到資料庫
  • Microsoft.AspNetCore.Identity.UI //Identity 提供的預設 Razor 頁面
  • Microsoft.EntityFrameworkCore.SqlServer //EFCore SQLServer Package
  • Microsoft.EntityFrameworkCore.Tools //開發時期使用,Code First 相關功能

並且新增了一個 ApplicationDbContext 以及產生相對應的 Migrations 與UI相關頁面多了 _LoginPartial.cshtml 確認目前登入狀態並產生相對應的 Header

最後在 Program.cs 中多了一段與 Identity 相關的程式碼

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

從以上的資訊可以得知,系統預設產生的樣板會直接依賴 EFCoreRazor 這兩個主要功能,那就會產生幾個疑問

  1. 如果今天專案不想使用 EFCore 只想使用 Dapper 或者乾脆不使用 ORM 有沒有辦法實現?
  2. 如果團隊沒有人會 Razor 語法或者不想使用 Razor 那該怎麼辦?

要處理以上的需求那就只能客製化出自己的 ASP.NET Core Identity 與撰寫 Controller 提供資料給前端團隊使用

首先我們將之前的資訊刪除一些不需要的東西,那就是刪除 EFCore 相關模組與移除 Razor 相關模組 有問題的是 Microsoft.AspNetCore.Identity.EntityFrameworkCore 這個 package,這邊可以看看 nuget 檢查這個 package 的相關依賴 得知這個 package 是建立在 Microsoft.Extensions.Identity.Stores 這個 package 之上,所以我們可以改成依賴這個模組

接下來就只剩下 Program.cs 的相關設定了,其他 DBContext 與 Razor 都可以遺除了

這邊直接新增一個乾淨的 webapi 專案來進行測試,添加新的 Package

dotnet new webapi -o IdentityDapper
dotnet add package Microsoft.Extensions.Identity.Stores --version 7.0.5

Program.cs 新增以下程式碼

builder.Services.AddIdentityCore<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true);

在繼續之前這邊有三個類似的方法經常出現 AddDefaultIdentity<TUser>,AddIdentity<TUser, TRole>,AddIdentityCore<TUser>

AddDefaultIdentity 內部也是使用 AddIdentityCore 方法並且額外使用 AddDefaultUI 與 AddDefaultTokenProviders 方法 AddIdentity 會直接註冊大部分實用的服務例如 cookie 與 2fa 與 role AddIdentityCore 只會註冊一些核心的功能,客製化程度比較高可以案自己的需求註冊功能

# AddIdentity

...

// Identity services
services.TryAddScoped<IUserValidator<TUser>, UserValidator<TUser>>();
services.TryAddScoped<IPasswordValidator<TUser>, PasswordValidator<TUser>>();
services.TryAddScoped<IPasswordHasher<TUser>, PasswordHasher<TUser>>();
services.TryAddScoped<ILookupNormalizer, UpperInvariantLookupNormalizer>();
services.TryAddScoped<IRoleValidator<TRole>, RoleValidator<TRole>>();
// No interface for the error describer so we can add errors without rev'ing the interface
services.TryAddScoped<IdentityErrorDescriber>();
services.TryAddScoped<ISecurityStampValidator, SecurityStampValidator<TUser>>();
services.TryAddScoped<ITwoFactorSecurityStampValidator, TwoFactorSecurityStampValidator<TUser>>();
services.TryAddScoped<IUserClaimsPrincipalFactory<TUser>, UserClaimsPrincipalFactory<TUser, TRole>>();
services.TryAddScoped<IUserConfirmation<TUser>, DefaultUserConfirmation<TUser>>();
services.TryAddScoped<UserManager<TUser>>();
services.TryAddScoped<SignInManager<TUser>>();
services.TryAddScoped<RoleManager<TRole>>();

...

# AddIdentityCore

...

// Services used by identity
services.TryAddScoped<IUserValidator<TUser>, UserValidator<TUser>>();
services.TryAddScoped<IPasswordValidator<TUser>, PasswordValidator<TUser>>();
services.TryAddScoped<IPasswordHasher<TUser>, PasswordHasher<TUser>>();
services.TryAddScoped<ILookupNormalizer, UpperInvariantLookupNormalizer>();
services.TryAddScoped<IUserConfirmation<TUser>, DefaultUserConfirmation<TUser>>();
// No interface for the error describer so we can add errors without rev'ing the interface
services.TryAddScoped<IdentityErrorDescriber>();
services.TryAddScoped<IUserClaimsPrincipalFactory<TUser>, UserClaimsPrincipalFactory<TUser>>();
services.TryAddScoped<UserManager<TUser>>();

...

比較兩段程式碼會發現 AddIdentityCore 有註冊的方法在 AddIdentity 中都有註冊並且多了許多功能,可以按照自己的需求做選擇 假如不需要這麼多功能可以選擇 AddIdentityCore 在挑選自己要的功能註冊 如果兩個都不滿意的話可以寫一個自己的方法並且回傳 IdentityBuilder 也可以達成同樣的功能

回到我們的 API 專案,目前我們只有添加一個 Microsoft.Extensions.Identity.Stores Package 接下來準備設定 Program.cs

builder.Services.AddIdentityCore<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)

這邊如果直接將專案運行起來會報錯,根據報錯內容可以得知我們並沒有在 DI 註冊 IUserStore 相關服務 所以在初始化 UserManagerUserClaimsPrincipalFactory 時會發生異常
在我們可以回到 WebApp1 專案看一下為什麼預設的專案不會有這個問題

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

發現下方還有呼叫另一個方法 AddEntityFrameworkStores Github 底部有一段程式碼會透過 IdentityDbContext(EFCore) 註冊 <IUserStore, UserOnlyStore>,所以專案有使用 EFCore 的話我們就不用管這個問題

services.TryAddScoped(typeof(IUserStore<>).MakeGenericType(userType), userStoreType);

但是我們的專案並沒有使用 EFCore,我們就需要實做一個客制的 IUserStore 並且註冊到 DI 內部,先參考一下微軟官方的文檔 Customize the user store 得知 UserStore 是負責處理程式到資料庫的這一段邏輯,所以我們需要建立一個類型並且實現 IUserStore<TUser> 這個界面(底下有許多可選的界面)的方法,程式才能知道要怎麼得到資料庫的資料
根據上一段的內容我們得知 AddEntityFrameworkStores 實際上會註冊的類型為 UserOnlyStore,可以參考微軟是怎麼實現的 UserOnlyStore

了解背後邏輯後我們可以來實做看看自己的 UserStore,先建立一個 CustomUserStore 並且實現 IUserStore 的所有方法 這邊因為方法很多就只實做兩個方法 CreateAsync FindByNameAsync

public class CustomUserStore : IUserStore<IdentityUser>
{
    public Task<IdentityResult> CreateAsync(IdentityUser user, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task<IdentityUser?> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }
    
    ...
}

到這邊就要按照自己的需求來選擇安裝的 Driver 例如:

  • MSSQL 可以安裝 Microsoft.Data.SqlClient
  • PostgreSQL 可以安裝 Npgsql
  • MySQL 可以安裝 MySqlConnector
  • 想要 ORM 可以額外安裝 Dapper

本專案使用 NpgsqlDapper

dotnet add package Npgsql
dotnet add package Dapper

建立一個新的 Database IdentityDapper 並且建立新的 Schema 與 Table

CREATE SCHEMA dbo;

CREATE TABLE dbo.AspNetUsers (
    Id text NOT NULL,
    UserName character varying(256) NULL,
    NormalizedUserName character varying(256) NULL,
    Email character varying(256) NULL,
    NormalizedEmail character varying(256) NULL,
    EmailConfirmed boolean NOT NULL,
    PasswordHash text NULL,
    SecurityStamp text NULL,
    ConcurrencyStamp text NULL,
    PhoneNumber text NULL,
    PhoneNumberConfirmed boolean NOT NULL,
    TwoFactorEnabled boolean NOT NULL,
    LockoutEnd timestamp with time zone NULL,
    LockoutEnabled boolean NOT NULL,
    AccessFailedCount integer NOT NULL,
    CONSTRAINT PK_AspNetUsers PRIMARY KEY (Id)
);

CREATE INDEX EmailIndex ON dbo.AspNetUsers (NormalizedEmail);

CREATE UNIQUE INDEX UserNameIndex ON dbo.AspNetUsers (NormalizedUserName);

完成之後回到 CustomUserStore 準備實做 CreateAsyncFindByNameAsync 方法

public async Task<IdentityResult> CreateAsync(IdentityUser user, CancellationToken cancellationToken)
{
    var connString = _configuration.GetSection("ConnectionStrings").GetValue<string>("DefaultConnection");
    await using var conn = new NpgsqlConnection(connString);
    await conn.OpenAsync();

    int rows;

    await using (var sqlConnection = conn) {
        var command = $"INSERT INTO dbo.AspNetUsers " +
                      "VALUES (@Id, @UserName, @NormalizedUserName, @Email, @NormalizedEmail, @EmailConfirmed, @PasswordHash, @SecurityStamp, @ConcurrencyStamp, " +
                      "@PhoneNumber, @PhoneNumberConfirmed, @TwoFactorEnabled, @LockoutEnd, @LockoutEnabled, @AccessFailedCount);";

        rows = await sqlConnection.ExecuteAsync(command, new {
            user.Id,
            user.UserName,
            user.NormalizedUserName,
            user.Email,
            user.NormalizedEmail,
            user.EmailConfirmed,
            user.PasswordHash,
            user.SecurityStamp,
            user.ConcurrencyStamp,
            user.PhoneNumber,
            user.PhoneNumberConfirmed,
            user.TwoFactorEnabled,
            user.LockoutEnd,
            user.LockoutEnabled,
            user.AccessFailedCount
        });
    }

    return rows == 1 ? IdentityResult.Success : IdentityResult.Failed(new IdentityError {
        Code = nameof(CreateAsync),
        Description = $"Insert User Error"
    });
}
public async Task<IdentityUser?> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken)
{
    var connString = _configuration.GetSection("ConnectionStrings").GetValue<string>("DefaultConnection");
    await using var conn = new NpgsqlConnection(connString);
    await conn.OpenAsync();
    
    await using (var sqlConnection = conn)
    {
        var command = "SELECT * " +
                      "FROM dbo.AspNetUsers " +
                      "WHERE NormalizedUserName = @NormalizedUserName;";
        
        return await sqlConnection.QuerySingleOrDefaultAsync<IdentityUser>(command, new
        {
            NormalizedUserName = normalizedUserName
        });
    }
}

回到 Program.csCustomUserStore 註冊到 DI 內 可以使用擴充方法 AddUserStore 也可以自行註冊

builder.Services.AddIdentityCore<IdentityUser>(options => 
        options.SignIn.RequireConfirmedAccount = true)
    .AddUserStore<CustomUserStore>();

這樣就完成了,我們可以建立一個 Controller 來進行測試

[ApiController]
[Route("[controller]")]
public class AccountController : ControllerBase
{
    private readonly IUserStore<IdentityUser> _userStore;
    public AccountController(IUserStore<IdentityUser> userStore)
    {
        _userStore = userStore;
    }
    
    [HttpPost(Name = "CreateUser")]
    public async Task<IdentityResult> CreateUser()
    {
        var userName = "User1";
        var identityUser = new IdentityUser(userName)
        {
            NormalizedUserName = userName.ToUpper()
        };
        return await _userStore.CreateAsync(identityUser,CancellationToken.None);
    }
    
    [HttpGet(Name = "GetUser")]
    public async Task<IdentityUser> GetUser()
    {
        var userName = "User1";
        return await _userStore.FindByNameAsync(userName.ToUpper(),CancellationToken.None);
    }
}

先 Post http://localhost:5204/Account 建立一個 User 在 Get http://localhost:5204/Account 讀取第一個 User 有回傳就代表測試成功了

Response body
{
  "id": "cf2e4770-a25d-4486-bfaa-206412cd38e7",
  "userName": "User1",
  "normalizedUserName": "USER1",
  "email": null,
  "normalizedEmail": null,
  "emailConfirmed": false,
  "passwordHash": null,
  "securityStamp": "c57c56b7-7ad5-40f1-8608-2a1f72b98a91",
  "concurrencyStamp": "82661e4f-cccf-4b39-a2f9-10c2a04e8662",
  "phoneNumber": null,
  "phoneNumberConfirmed": false,
  "twoFactorEnabled": false,
  "lockoutEnd": null,
  "lockoutEnabled": false,
  "accessFailedCount": 0
}

Summary

今天初步了解了 dotnet core identity 的基礎,不過使用 UserStore 的方法雖然可行,但是這樣看起來好像沒什麼意義
跟自己寫一個倉庫的概念是一樣的根本不需要用到 dotnet core identity,這是因為實務上該使用的是各種的 Manager 方法 例如要註冊一個會員需要先檢查資料庫有沒有同樣名稱的會員,確認沒有才可以插入到資料庫內或者會員登入之前需要經過許多商業邏輯的檢查 這些邏輯已經都被包在各種的 Manager 方法內部了,之後的文章會在詳細探討

今天的進度 Github