IdentityServer4實戰 - 基於角色的權限控制及Claim詳解

一.前言

你們好,許久沒有更新博客了,最近從重慶來到了成都,換了個工做環境,前面都比較忙沒有什麼時間,此次趁着清明假期有時間,又能夠分享一些知識給你們。在QQ羣裏有許多人都問過IdentityServer4怎麼用Role(角色)來控制權限呢?還有關於Claim這個是什麼呢?下面我帶你們一塊兒來揭開它的神祕面紗!html

二.Claim詳解

咱們用過IdentityServer4或者熟悉ASP.NET Core認證的都應該知道有Claim這個東西,Claim咱們經過在線翻譯有如下解釋:git

(1)百度翻譯github

(2)谷歌翻譯數據庫

這裏我理解爲聲明,咱們每一個用戶都有多個Claim,每一個Claim聲明瞭用戶的某個信息好比:Role=Admin,UserID=1000等等,這裏Role,UserID每一個都是用戶的Claim,都是表示用戶信息的單元 ,咱們不妨把它稱爲用戶信息單元api

建議閱讀楊總的Claim相關的解析 http://www.cnblogs.com/savorboard/p/aspnetcore-identity.htmlide

三.測試環境中添加角色Claim

這裏咱們使用IdentityServer4的QuickStart中的第二個Demo:ResourceOwnerPassword來進行演示(代碼地址放在文末),因此項目的建立配置就不在這裏演示了。測試

這裏咱們須要自定義IdentityServer4(後文簡稱id4)的驗證邏輯,而後在驗證完畢以後,將咱們本身須要的Claim加入驗證結果。即可以向API資源服務進行傳遞。id4定義了IResourceOwnerPasswordValidator接口,咱們實現這個接口就好了。ui

Id4爲咱們提供了很是方便的In-Memory測試支持,那咱們在In-Memory測試中是否能夠實現自定義添加角色Claim呢,答案當時是能夠的。this

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

相關文章
相關標籤/搜索