一.前言
大家好,許久沒有更新博客了,最近從重慶來到了成都,換了個工作環境,前面都比較忙沒有什么時間,這次趁着清明假期有時間,又可以分享一些知識給大家。在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