.NET Core Identity Authorization
在上一篇的文章
中我們使用 .NET Core Identity 的 SignInManager
搭配 Cookie
成功實做出保護 API 的功能
現在我們可以在需要的 API 上添加 [Authorize]
標籤,就能將我們的 API 保護起來只有通過驗證的人可以使用
直接使用 [Authorize]
標籤雖然可以達成我們的需求但是功能比較單一只能進行二分法,也就是只能分成有登入跟沒登入兩種狀態
要進行更複雜的授權之前那就需要先了解什麼是 Claim
與 Policy
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 值也有相同的問題
所以像這種常用的類型我們可以使用微軟提供的 ClaimTypes
與 ClaimValueTypes
裡面已經定義好常用的 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
方法,它內部會利用 UserManager
的 AddClaimAsync
方法會幫 User 添加上新的 Claim
添加之後使用 ListClaim
方法,內部利用 UserManager
的 GetClaimsAsync
列出 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 時會常常提到,之後的文章詳細討論