1. 程式人生 > >IdentityServer4實戰 - 基於角色的許可權控制及Claim詳解

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

原文: 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