.NET Core Identity Authorization

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

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


Claim

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

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

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 取出 UserId 與 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;
}

預設是直接使用 IdentityUserClaim 類別,我們先來看看他的內容 Github
簡化來看這個 Class 只有四個 Property, 並且會與 IdentityUser 的 UserId 關聯

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>

public async Task<IList<Claim>> GetClaimsAsync(IdentityUser user, CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();
    if (user == null)
    {
        throw new ArgumentNullException(nameof(user), $"Parameter {nameof(user)} cannot be null.");
    }

    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.AspNetUserClaims " +
                      "WHERE UserId = @UserId;";

        return (
                await sqlConnection.QueryAsync<IdentityUserClaim<string>>(command, new { UserId = user.Id })
            )
            .Select(e => new Claim(e.ClaimType, e.ClaimValue))
            .ToList();
    }
}

public async Task AddClaimsAsync(IdentityUser user, IEnumerable<Claim> claims, CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();
    if (user == null)
    {
        throw new ArgumentNullException(nameof(user), $"Parameter {nameof(user)} cannot be null.");
    }

    if (claims == null)
    {
        throw new ArgumentNullException(nameof(claims), $"Parameter {nameof(user)} cannot be null.");
    }

    var connString = _configuration.GetSection("ConnectionStrings").GetValue<string>("DefaultConnection");
    await using var conn = new NpgsqlConnection(connString);
    await conn.OpenAsync();

    await using (var sqlConnection = conn)
    {
        foreach (var claim in claims)
        {
            var insertClaimsCommand = $"INSERT INTO dbo.AspNetUserClaims (UserId, ClaimType, ClaimValue) " +
                                      "VALUES (@UserId, @ClaimType, @ClaimValue);";

            await sqlConnection.ExecuteAsync(insertClaimsCommand, new
            {
                UserId = user.Id,
                ClaimType = claim.Type,
                ClaimValue = claim.Value
            });
        }
    }
}

因為 AddClaimsAsync 最後會更新會員所以我們也將之前沒實做的 UpdateAsync 也補上

public async Task<IdentityResult> UpdateAsync(IdentityUser user, CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();
    if (user == null)
    {
        throw new ArgumentNullException(nameof(user), $"Parameter {nameof(user)} cannot be null.");
    }

    user.ConcurrencyStamp = Guid.NewGuid().ToString();

    var connString = _configuration.GetSection("ConnectionStrings").GetValue<string>("DefaultConnection");
    await using var conn = new NpgsqlConnection(connString);
    await conn.OpenAsync();

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

        await using var transaction = await sqlConnection.BeginTransactionAsync();
        await sqlConnection.ExecuteAsync(updateUserCommand, new
        {
            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,
            user.Id
        }, transaction);

        try
        {
            await transaction.CommitAsync();
        }
        catch
        {
            try
            {
                await transaction.RollbackAsync();
            }
            catch
            {
                return IdentityResult.Failed(new IdentityError
                {
                    Code = nameof(UpdateAsync),
                    Description =
                        $"User with email {user.Email} could not be updated. Operation could not be rolled back."
                });
            }

            return IdentityResult.Failed(new IdentityError
            {
                Code = nameof(UpdateAsync),
                Description = $"User with email {user.Email} could not be updated. Operation was rolled back."
            });
        }
    }

    return IdentityResult.Success;
}

最後回到 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 方法會幫 User 添加上新的 Claim

添加之後使用 ListClaim 方法,內部利用 UserManagerGetClaimsAsync 列出 User 擁有的 Claim,下面回傳的 type http://schemas.xmlsoap.org/ws/2005/05/identity/claims/dateofbirth 就是規範的生日類型

[
    {
        "issuer": "LOCAL AUTHORITY",
        "originalIssuer": "LOCAL AUTHORITY",
        "properties": {},
        "subject": null,
        "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/dateofbirth",
        "value": "1995-01-01",
        "valueType": "http://www.w3.org/2001/XMLSchema#string"
    }
]

比較特別的是 ListHttpContextClaim 方法,內部會利用 HttpContext 取得目前登入 User 身上的 Claim, 這是因為使用 signInManager 登入時內部會幫忙把 Claim 添加回 HttpContext,這樣我們就不用每次檢查權限時連回資料庫

[
  {
    "claim": {
      "Type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
      "Value": "5ad20e10-554d-45c4-842a-30e6ba4f65d7"
    }
  },
  {
    "claim": {
      "Type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
      "Value": "user3"
    }
  },
  {
    "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.cs 註冊新的 Policy

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

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

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

如果目前登入的會員所有條件都符合的話 API 才能正常回傳,否則會回傳錯誤


Summary

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