IdentityServer4實戰 - 基於角色的許可權控制及Claim詳解
一.前言
大家好,許久沒有更新部落格了,最近從重慶來到了成都,換了個工作環境,前面都比較忙沒有什麼時間,這次趁著清明假期有時間,又可以分享一些知識給大家。在QQ群裡有許多人都問過IdentityServer4怎麼用Role(角色)來控制權限呢?還有關於Claim這個是什麼呢?下面我帶大家一起來揭開它的神祕面紗!
二.Claim詳解
我們用過IdentityServer4或者熟悉ASP.NET Core認證的都應該知道有Claim這個東西,Claim我們通過線上翻譯有以下解釋:
(1)百度翻譯
(2)谷歌翻譯
這裡我理解為宣告
,我們每個使用者都有多個Claim,每個Claim聲明瞭使用者的某個資訊比如:Role=Admin,UserID=1000等等,這裡Role,UserID每個都是使用者的Claim,都是表示使用者資訊的單元
,我們不妨把它稱為使用者資訊單元
。
建議閱讀楊總的Claim相關的解析 http://www.cnblogs.com/savorboard/p/aspnetcore-identity.html
三.測試環境中新增角色Claim
這裡我們使用IdentityServer4的QuickStart中的第二個Demo:
ResourceOwnerPassword
來進行演示(程式碼地址放在文末),所以專案的建立配置就不在這裡演示了。
這裡我們需要自定義IdentityServer4(後文簡稱id4)的驗證邏輯,然後在驗證完畢之後,將我們自己需要的Claim加入驗證結果。便可以向API資源服務進行傳遞。id4定義了IResourceOwnerPasswordValidator
介面,我們實現這個介面就行了。
Id4為我們提供了非常方便的In-Memory測試支援,那我們在In-Memory測試中是否可以實現自定義新增角色Claim呢,答案當時是可以的。
1.首先我們需要在定義TestUser
測試使用者時,定義使用者Claims屬性,意思就是為我們的測試使用者新增額外的身份資訊單元,這裡我們新增角色身份資訊單元:
new TestUser { SubjectId = "1", Username = "alice", Password = "password", Claims = new List<Claim>(){new Claim(JwtClaimTypes.Role,"superadmin") } }, new TestUser { SubjectId = "2", Username = "bob", Password = "password", Claims = new List<Claim>(){new Claim(JwtClaimTypes.Role,"admin") } }
JwtClaimTypes
是一個靜態類在IdentityModel程式集下,裡面定義了我們的jwt token的一些常用的Claim,JwtClaimTypes.Role是一個常量字串public const string Role = "role";
如果JwtClaimTypes
定義的Claim型別沒有我們需要的,那我們直接寫字串即可。
2.分別啟動 QuickstartIdentityServer、Api、ResourceOwnerClient 檢視 執行結果:
可以看見我們定義的API資源通過HttpContext.User.Claims
並沒有獲取到我們為測試使用者新增的Role Claim,那是因為我們為API資源做配置。
3.配置API資源需要的Claim
在QuickstartIdentityServer專案下的Config
類的GetApiResources
做出如下修改:
public static IEnumerable<ApiResource> GetApiResources()
{
return new List<ApiResource>
{
// new ApiResource("api1", "My API")
new ApiResource("api1", "My API",new List<string>(){JwtClaimTypes.Role})
};
}
我們添加了一個Role Claim,現在再次執行(需要重新QuickstartIdentityServer方可生效)檢視結果。
可以看到,我們的API服務已經成功獲取到了Role Claim。
這裡有個疑問,為什麼需要為APIResource配置Role Claim,我們的API Resource才能獲取到呢,我們檢視ApiResource
的原始碼:
public ApiResource(string name, string displayName, IEnumerable<string> claimTypes)
{
if (name.IsMissing()) throw new ArgumentNullException(nameof(name));
Name = name;
DisplayName = displayName;
Scopes.Add(new Scope(name, displayName));
if (!claimTypes.IsNullOrEmpty())
{
foreach (var type in claimTypes)
{
UserClaims.Add(type);
}
}
}
從上面的程式碼可以分析出,我們自定義的Claim新增到了一個名為UserClaims
的屬性中,檢視這個屬性:
/// <summary>
/// List of accociated user claims that should be included when this resource is requested.
/// </summary>
public ICollection<string> UserClaims { get; set; } = new HashSet<string>();
根據註釋我們便知道了原因:請求此資源時應包含的相關使用者身份單元資訊列表。
四.通過角色控制API訪問許可權
我們在API專案下的IdentityController
做出如下更改
[Route("[controller]")]
public class IdentityController : ControllerBase
{
[Authorize(Roles = "superadmin")]
[HttpGet]
public IActionResult Get()
{
return new JsonResult(from c in HttpContext.User.Claims select new { c.Type, c.Value });
}
[Authorize(Roles = "admin")]
[Route("{id}")]
[HttpGet]
public string Get(int id)
{
return id.ToString();
}
}
我們定義了兩個API通過Authorize
特性賦予了不同的許可權(我們的測試使用者只添加了一個角色,通過訪問具有不同角色的API來驗證是否能通過角色來控制)
我們在ResourceOwnerClient專案下,Program
類最後新增如下程式碼:
response = await client.GetAsync("http://localhost:5001/identity/1");
if (!response.IsSuccessStatusCode)
{
Console.WriteLine(response.StatusCode);
Console.WriteLine("沒有許可權訪問 http://localhost:5001/identity/1");
}
else
{
var content = response.Content.ReadAsStringAsync().Result;
Console.WriteLine(content);
}
這裡我們請求第二個API的程式碼,正常情況應該會沒有許可權訪問的(我們使用的使用者只具有superadmin角色,而第二個API需要admin角色),執行一下:
可以看到提示我們第二個,無權訪問,正常。
五.如何使用已有使用者資料自定義Claim
我們前面的過程都是使用的TestUser來進行測試的,那麼我們正式使用時肯定是使用自己定義的使用者(從資料庫中獲取),這裡我們可以實現IResourceOwnerPasswordValidator
介面,來定義我們自己的驗證邏輯。
/// <summary>
/// 自定義 Resource owner password 驗證器
/// </summary>
public class CustomResourceOwnerPasswordValidator: IResourceOwnerPasswordValidator
{
/// <summary>
/// 這裡為了演示我們還是使用TestUser作為資料來源,
/// 正常使用此處應當傳入一個 使用者倉儲 等可以從
/// 資料庫或其他介質獲取我們使用者資料的物件
/// </summary>
private readonly TestUserStore _users;
private readonly ISystemClock _clock;
public CustomResourceOwnerPasswordValidator(TestUserStore users, ISystemClock clock)
{
_users = users;
_clock = clock;
}
/// <summary>
/// 驗證
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
{
//此處使用context.UserName, context.Password 使用者名稱和密碼來與資料庫的資料做校驗
if (_users.ValidateCredentials(context.UserName, context.Password))
{
var user = _users.FindByUsername(context.UserName);
//驗證通過返回結果
//subjectId 為使用者唯一標識 一般為使用者id
//authenticationMethod 描述自定義授權型別的認證方法
//authTime 授權時間
//claims 需要返回的使用者身份資訊單元 此處應該根據我們從資料庫讀取到的使用者資訊 新增Claims 如果是從資料庫中讀取角色資訊,那麼我們應該在此處新增 此處只返回必要的Claim
context.Result = new GrantValidationResult(
user.SubjectId ?? throw new ArgumentException("Subject ID not set", nameof(user.SubjectId)),
OidcConstants.AuthenticationMethods.Password, _clock.UtcNow.UtcDateTime,
user.Claims);
}
else
{
//驗證失敗
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "invalid custom credential");
}
return Task.CompletedTask;
}
在Startup類裡配置一下我們自定義的驗證器:
實現了IResourceOwnerPasswordValidator
還不夠,我們還需要實現IProfileService
介面,他是專門用來裝載我們需要的Claim資訊的,比如在token建立期間和請求使用者資訊終結點是會呼叫它的GetProfileDataAsync
方法來根據請求需要的Claim型別,來為我們裝載資訊,下面是一個簡單實現:
這裡特別說明一下:本節講的是“如何使用已有使用者資料自定義Claim”,實現 IResourceOwnerPasswordValidator 是為了對接已有的使用者資料,然後才是實現 IProfileService 以新增自定義 claim,這兩步共同完成的是 “使用已有使用者資料自定義Claim”,並不是自定義 Claim 就非得把兩個都實現。
public class CustomProfileService: IProfileService
{
/// <summary>
/// The logger
/// </summary>
protected readonly ILogger Logger;
/// <summary>
/// The users
/// </summary>
protected readonly TestUserStore Users;
/// <summary>
/// Initializes a new instance of the <see cref="TestUserProfileService"/> class.
/// </summary>
/// <param name="users">The users.</param>
/// <param name="logger">The logger.</param>
public CustomProfileService(TestUserStore users, ILogger<TestUserProfileService> logger)
{
Users = users;
Logger = logger;
}
/// <summary>
/// 只要有關使用者的身份資訊單元被請求(例如在令牌建立期間或通過使用者資訊終點),就會呼叫此方法
/// </summary>
/// <param name="context">The context.</param>
/// <returns></returns>
public virtual Task GetProfileDataAsync(ProfileDataRequestContext context)
{
context.LogProfileRequest(Logger);
//判斷是否有請求Claim資訊
if (context.RequestedClaimTypes.Any())
{
//根據使用者唯一標識查詢使用者資訊
var user = Users.FindBySubjectId(context.Subject.GetSubjectId());
if (user != null)
{
//呼叫此方法以後內部會進行過濾,只將使用者請求的Claim加入到 context.IssuedClaims 集合中 這樣我們的請求方便能正常獲取到所需Claim
context.AddRequestedClaims(user.Claims);
}
}
context.LogIssuedClaims(Logger);
return Task.CompletedTask;
}
/// <summary>
/// 驗證使用者是否有效 例如:token建立或者驗證
/// </summary>
/// <param name="context">The context.</param>
/// <returns></returns>
public virtual Task IsActiveAsync(IsActiveContext context)
{
Logger.LogDebug("IsActive called from: {caller}", context.Caller);
var user = Users.FindBySubjectId(context.Subject.GetSubjectId());
context.IsActive = user?.IsActive == true;
return Task.CompletedTask;
}
同樣在Startup
類裡啟用我們自定義的ProfileService
:AddProfileService<CustomProfileService>()
值得注意的是如果我們直接將使用者的所有Claim加入 context.IssuedClaims
集合,那麼使用者所有的Claim都將會無差別返回給請求方。比如預設情況下請求使用者終結點(http://Identityserver4地址/connect/userinfo)只會返回sub(使用者唯一標識)資訊,如果我們在此處直接 context.IssuedClaims=User.Claims,那麼所有Claim都將被返回,而不會根據請求的Claim來進行篩選,這樣做雖然省事,但是損失了我們精確控制的能力,所以不推薦。
上述說明配圖:
如果直接 context.IssuedClaims=User.Claims
,那麼返回結果如下:
/// <summary>
/// 只要有關使用者的身份資訊單元被請求(例如在令牌建立期間或通過使用者資訊終點),就會呼叫此方法
/// </summary>
/// <param name="context">The context.</param>
/// <returns></returns>
public virtual Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var user = Users.FindBySubjectId(context.Subject.GetSubjectId());
if (user != null)
context.IssuedClaims .AddRange(user.Claims);
return Task.CompletedTask;
}
使用者的所有Claim都將被返回。這樣降低了我們控制的能力,我們可以通過下面的方法來實現同樣的效果,但卻不會丟失控制的能力。
(1).自定義身份資源資源
身份資源的說明:身份資源也是資料,如使用者ID,姓名或使用者的電子郵件地址。 身份資源具有唯一的名稱,您可以為其分配任意身份資訊單元(比如姓名、性別、身份證號和有效期等都是身份證的身份資訊單元)型別。 這些身份資訊單元將被包含在使用者的身份標識(Id Token)中。 客戶端將使用scope引數來請求訪問身份資源。
public static IEnumerable<IdentityResource> GetIdentityResourceResources()
{
var customProfile = new IdentityResource(
name: "custom.profile",
displayName: "Custom profile",
claimTypes: new[] { "role"});
return new List<IdentityResource>
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
customProfile
};
}
(2).配置Scope
通過上面的程式碼,我們自定義了一個名為“customProfile“的身份資源,他包含了"role" Claim(可以包含多個Claim),然後我們還需要配置Scope,我們才能訪問到:
new Client
{
ClientId = "ro.client",
AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
ClientSecrets =
{
new Secret("secret".Sha256())
},
AllowedScopes = { "api1" ,IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,"custom.profile"}
}
我們在Client物件的AllowedScopes屬性里加入了我們剛剛定義的身份資源,下載訪問使用者資訊終結點將會得到和上面一樣的結果。
六. Client Claims
新增於2018.12.14
在定義 Client 資源的時候發現,Client也有一個Claims屬性,根據註釋得知,在此屬性上設定的值將會被直接新增到AccessToken,程式碼如下:
new Client
{
ClientId = "client",
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets =
{
new Secret("secret".Sha256())
},
AllowedScopes =
{
"api1", IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile
},
Claims = new List<Claim>
{
new Claim(JwtClaimTypes.Role, "admin")
}
};
只用在客戶端資源這裡設定就行,其他地方不用設定,然後請求AccessToken就會被帶入。
值得注意的是Client這裡設定的Claims預設都會被帶一個client_字首。如果像前文一樣使用 [Authorize(Roles ="admin")] 是行的,因為 [Authorize(Roles ="admin")] 使用的Claim是role而不是client_role
七.總結
寫這篇文章,簡單分析了一下相關的原始碼,如果因為有本文描述不清楚或者不明白的地方建議閱讀一下原始碼,或者加下方QQ群在群內提問。如果我們的根據角色的許可權認證沒有生效,請檢查是否正確獲取到了角色的使用者資訊單元。我們需要接入已有使用者體系,只需實現IProfileService
和IResourceOwnerPasswordValidator
介面即可,並且在Startup配置Service時不再需要AddTestUsers
,因為將使用我們自己的使用者資訊。
Demo地址:https://github.com/stulzq/IdentityServer4.Samples/tree/master/Practice/01_RoleAndClaim