.NET Core Identity Authorization

在上一篇的文章中我們使用 .NET Core Identity 的 SignInManager 搭配 Cookie 成功實做出保護 API 的功能 現在我們可以在需要的 API 上添加 [Authorize] 標籤,就能將我們的 API 保護起來只有通過驗證的人可以使用

不過我們沒有在專案設定授權相關的功能,那為什麼我們可以在我們專案直接使用 [Authorize] 標籤?
首先看到底下 WebApplicationBuilder 底下有使用 UseAuthorization 方法,這個是專案建立就有的設定
UseAuthorization 方法裡面有一段特殊的邏輯,會去 DI 檢查是否有注入這個服務 AuthorizationPolicyMarkerService Github

這個服務 AuthorizationPolicyMarkerService 很特殊是專門用來檢查的,服務裡面沒有任何內容 Github
目前只有在使用 AddAuthorization 才會註冊這個服務,所以這個服務是用來確保在系統在註冊 AuthorizationMiddleware 之前,強制檢查有沒有先呼叫 AddAuthorization 方法 不然直接註冊 Middleware 可以會造成錯誤

不過在我們專案中並沒有呼叫 AddAuthorization 方法,怎麼專案沒有報錯呢? 是因為我們開頭有使用 AddControllers 方法,此方法內部已經幫我們使用 AddAuthorization 方法,所以我們才有辦法在不用設定的前提下直接使用 [Authorize] 標籤來保護我們的 API

直接使用 [Authorize] 標籤雖然可以達成我們的需求但是功能比較單一只能進行二分法,也就是只能分成有登入跟沒登入兩種狀態 要進行更複雜的授權之前那就需要先了解什麼是 ClaimPolicy


Claim

在製作網站通常會有收集會員資料的需求例如會員的生日或電話號碼,以前在關聯式資料庫可能會額外建立一個會員資料表來保存這些資料
不過在 .NET Core 中我們可以利用 Claim 的特性來直接實踐這樣的功能,首先我們先到 Github 上看看原始碼
跟我們之前學習到的 IUserStore界面與 IUserPasswordStore 密碼相關功能的界面類似
今天我們要看的是 IUserClaimStore 界面,從名稱也可以猜到這是與 Claim 相關的功能界面

我們選一個 AddClaimsAsync 方法來查看,要使用這個方法需要輸入一個 IdentityUser 與輸入列舉的 Claim

Task AddClaimsAsync(TUser user, IEnumerable<Claim> claims, CancellationToken cancellationToken);

Claim 內部提供了許多 Constructor,其中最簡單的方式就是輸入兩個 String,分別代表Key:Value

public Claim(string type, string value)

拿開頭的生日作為例子,我們可以建立一個生日的 Claim 來紀錄這個會員的生日

new Claim("birthday","1995-01-01")

這邊有一個問題因為都是 string 所以有可能造成一個生日有多種寫法,value 值也有相同的問題 所以像這種常用的類型我們可以使用微軟提供的 ClaimTypesClaimValueTypes 裡面已經定義好常用的 Claim 我們可以對生日的 Claim 進行改寫

new Claim(ClaimTypes.DateOfBirth, "1995-01-01", ClaimValueTypes.Date);

有了 Claim 之後現在需要搞懂 AddClaimsAsync 內部是如何把 Claim 跟傳入的 User 連結在一起的 這邊先參考 EFCore 是怎麼實做的,專案直接使用 EFCore 的可以跳過這一步驟 Github 了解到會先利用我們傳入的 user 與 claim 建立出一個新的類型 TUserClaim

//UserStoreBase.cs
protected virtual TUserClaim CreateUserClaim(TUser user, Claim claim)
{
    var userClaim = new TUserClaim { UserId = user.Id };
    userClaim.InitializeFromClaim(claim);
    return userClaim;
}

//IdentityUserClaim.cs
public virtual void InitializeFromClaim(Claim claim)
{
    ClaimType = claim.Type;
    ClaimValue = claim.Value;
}

正如我們一直在使用的 IdentityUser Class 這個泛型的 TUserClaim 會繼承另一個預設的 IdentityUserClaim Class,我們先來看看他的內容 Github
簡化來看這個 Class 只有四個 Property, 並且會與 IdentityUser 的 Id 關聯

int Id
TKey UserId
string? ClaimType
string? ClaimValue

由此可知我們將 Claim 轉換成 IdentityUserClaim 之後會將資料添加資料到 DbSet UserClaims 最後由 EFCore 添加到資料庫內

接下來回到我們的專案進行實做,首先新建立 AspNetUserClaims

CREATE TABLE dbo.AspNetUserClaims (
    Id integer GENERATED BY DEFAULT AS IDENTITY,
    UserId text NOT NULL,
    ClaimType text NULL,
    ClaimValue text NULL,
    CONSTRAINT PK_AspNetUserClaims PRIMARY KEY (Id),
    CONSTRAINT FK_AspNetUserClaims_AspNetUsers_UserId FOREIGN KEY (UserId) REFERENCES dbo.AspNetUsers (Id) ON DELETE CASCADE
);

CREATE INDEX IX_AspNetUserClaims_UserId ON dbo.AspNetUserClaims (UserId);

並且到 CustomUserStore 實做新界面 IUserClaimStore<IdentityUser>

Task<IList<Claim>> GetClaimsAsync(TUser user, CancellationToken cancellationToken);
Task AddClaimsAsync(TUser user, IEnumerable<Claim> claims, CancellationToken cancellationToken);

最後到 AccountController 新增幾個 controller,負責幫 user 添加新的 claim 與列出 user 擁有的 claim

HttpGet(template: "~/addClaim", Name = "AddClaim")]
public async Task<IdentityResult> AddClaim()
{
    var userName = "User1";
    var user = await _userManager.FindByNameAsync(userName);
    var claim = new Claim(ClaimTypes.DateOfBirth, "1995-01-01", ClaimValueTypes.Date);
    return await _userManager.AddClaimAsync(user, claim);
}

[HttpGet(template: "~/listClaim", Name = "ListClaim")]
public async Task<IList<Claim>> ListClaim()
{
    var userName = "User1";
    var user = await _userManager.FindByNameAsync(userName);
    return await _userManager.GetClaimsAsync(user);
}

[HttpGet(template: "~/listHttpContextClaim", Name = "ListHttpContextClaim")]
public string ListHttpContextClaim()
{
    var claims = HttpContext.User.Claims.ToList();
    var simpleClaims = claims.Select(claim => new
    {
        claim = new { claim.Type, claim.Value},
    }).ToList() ;

    return JsonSerializer.Serialize(simpleClaims);
}

完成之後我們需要先使用 AddClaim 方法內部會利用 UserManagerAddClaimAsync 方法會幫 User1 添加新的 Claim 添加之後使用 ListClaim 方法內部會利用 UserManagerGetClaimsAsync 列出 User1 擁有的 Claim 比較特別的是 ListHttpContextClaim 方法,內部會利用 HttpContext 取得目前登入 User 身上的 Claim,這是因為使用 signInManager 登入時內部會幫忙把 Claim 添加回 HttpContext

[
  {
    "claim": {
      "Type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
      "Value": "14da945b-0a45-4240-947b-606b480fc6c2"
    }
  },
  {
    "claim": {
      "Type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
      "Value": "User1"
    }
  },
  {
    "claim": {
      "Type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/dateofbirth",
      "Value": "1995-01-01"
    }
  },
  {
    "claim": {
      "Type": "amr",
      "Value": "pwd"
    }
  }
]

Policy

有了 Claim 之後並不能夠給授權系統使用我們還需要搭配 Policy 才能使用 Policy 主要是一組限制規則,例如我們可以建立一個 Policy 要求好幾個規則

  • 會員需要通過驗證
  • 驗證的規則限定為 Cookie 驗證
  • 需要擁有 DateOfBirth Claim

回到 Program 註冊新的 Policy

builder.Services.AddAuthorization(option =>
{
    option.AddPolicy("birthday", builder =>
    {
        builder
            .RequireAuthenticatedUser()
            .AddAuthenticationSchemes(IdentityConstants.ApplicationScheme)
            .RequireClaim(ClaimTypes.DateOfBirth);
    });
});

完成後我們就能夠使用 [Authorize] 標籤指定要使用的 Policy 就會要求 Controller 需要符合我們的規則

[Authorize(policy: "birthday")]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase

如果條件都符合的話 API 才能正常回傳,否則會回傳 404


Summary

今天學習到了更進階的授權規則,Claim 這個概念之後在使用 JWT 時會常常提到,之後的文章會在討論

今天的進度 Github