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類里啟用我們自定義的ProfileServiceAddProfileService<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群在群內提問。如果我們的根據角色的權限認證沒有生效,請檢查是否正確獲取到了角色的用戶信息單元。我們需要接入已有用戶體系,只需實現IProfileServiceIResourceOwnerPasswordValidator接口即可,並且在Startup配置Service時不再需要AddTestUsers,因為將使用我們自己的用戶信息。

Demo地址:https://github.com/stulzq/IdentityServer4.Samples/tree/master/Practice/01_RoleAndClaim


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM