IdentityServer4实战-基于⾓⾊的权限控制及Claim详解
⼀.前⾔
⼤家好,许久没有更新博客了,最近从重庆来到了成都,换了个⼯作环境,前⾯都⽐较忙没有什么时间,这次趁着清明假期有时间,⼜可以分享⼀些知识给⼤家。在QQ群⾥有许多⼈都问过IdentityServer4怎么⽤Role(⾓⾊)来控制权限呢?还有关于Claim这个是什么呢?下⾯我带⼤家⼀起来揭开它的神秘⾯纱!
⼆.C l a i m详解
我们⽤过IdentityServer4或者熟悉ASP Core认证的都应该知道有Claim这个东西,Claim我们通过在线翻译有以下解释:
(1)百度翻译2013年英语四级考试答案
(2)⾕歌翻译
这⾥我理解为声明,我们每个⽤户都有多个Claim,每个Claim声明了⽤户的某个信息⽐如:Role=Admin,UrID=1000等等,这⾥Role,UrID每个都是⽤户的Claim,都是表⽰⽤户信息的单元 ,我们不妨把它称为⽤户信息单元 。
三.测试环境中添加⾓⾊C l a i m
这⾥我们使⽤IdentityServer4的QuickStart中的第⼆个Demo:ResourceOwnerPassword来进⾏演⽰(代码地址放在⽂末),所以项⽬的创建配置就不在这⾥演⽰了。
这⾥我们需要⾃定义IdentityServer4(后⽂简称id4)的验证逻辑,然后在验证完毕之后,将我们⾃⼰需要的Claim加⼊验证结果。便可以向API资源服务进⾏传递。id4定义了IResourceOwnerPasswordValidator接⼝,我们实现这个接⼝就⾏了。
Id4为我们提供了⾮常⽅便的In-Memory测试⽀持,那我们在In-Memory测试中是否可以实现⾃定义添加⾓⾊Claim呢,答案当时是可以的。
1.⾸先我们需要在定义TestUr测试⽤户时,定义⽤户Claims属性,意思就是为我们的测试⽤户添加额外的⾝份信息单元,这⾥我们添加⾓⾊⾝份信息单元:
new TestUr
{
SubjectId = "1",
Urname = "alice",
Password = "password",
Claims = new List<Claim>(){new Claim(JwtClaimTypes.Role,"superadmin") }
},
new TestUr
{
SubjectId = "2",
Urname = "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.Ur.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;
Scopes.Add(new Scope(name, displayName));
if (!claimTypes.IsNullOrEmpty())
{
foreach (var type in claimTypes)
{
UrClaims.Add(type);
}
}
}
从上⾯的代码可以分析出,我们⾃定义的Claim添加到了⼀个名为UrClaims的属性中,查看这个属性:
/// <summary>
/// List of accociated ur claims that should be included when this resource is requested.
六级论坛/// </summary>
public ICollection<string> UrClaims { get; t; } = new HashSet<string>();
根据注释我们便知道了原因:请求此资源时应包含的相关⽤户⾝份单元信息列表。
monstrum
四.通过⾓⾊控制A P I访问权限
我们在API项⽬下的IdentityController做出如下更改
[Route("[controller]")]
public class IdentityController : ControllerBa
{
[Authorize(Roles = "superadmin")]
[HttpGet]
public IActionResult Get()
{
return new JsonResult(from c in HttpContext.Ur.Claims lect new { c.Type, c.Value });
}
[Authorize(Roles = "admin")]
[Route("{id}")]
[HttpGet]
public string Get(int id)
{
return id.ToString();
}
}
我们定义了两个API通过Authorize特性赋予了不同的权限(我们的测试⽤户只添加了⼀个⾓⾊,通过访问具有不同⾓⾊的API来验证是否能通过⾓⾊来控制)我们在ResourceOwnerClient项⽬下,Program类最后添加如下代码:
respon = await client.GetAsync("localhost:5001/identity/1");
if (!respon.IsSuccessStatusCode)
{
Console.WriteLine(respon.StatusCode);
Console.WriteLine("没有权限访问 localhost:5001/identity/1");
}
el
{
var content = respon.Content.ReadAsStringAsync().Result;
Console.WriteLine(content);
}
这⾥我们请求第⼆个API的代码,正常情况应该会没有权限访问的(我们使⽤的⽤户只具有superadmin⾓⾊,⽽第⼆个API需要admin⾓⾊),运⾏⼀下:可以看到提⽰我们第⼆个,⽆权访问,正常。
五.如何使⽤已有⽤户数据⾃定义C l a i m
我们前⾯的过程都是使⽤的TestUr来进⾏测试的,那么我们正式使⽤时肯定是使⽤⾃⼰定义的⽤户(从数据库中获取),这⾥我们可以实
现IResourceOwnerPasswordValidator接⼝,来定义我们⾃⼰的验证逻辑。
/// <summary>
/
// ⾃定义 Resource owner password 验证器
/// </summary>
public class CustomResourceOwnerPasswordValidator: IResourceOwnerPasswordValidator
{
/// <summary>
/// 这⾥为了演⽰我们还是使⽤TestUr作为数据源,
/// 正常使⽤此处应当传⼊⼀个⽤户仓储等可以从
/// 数据库或其他介质获取我们⽤户数据的对象
/// </summary>
international缩写private readonly TestUrStore _urs;
private readonly ISystemClock _clock;
public CustomResourceOwnerPasswordValidator(TestUrStore urs, ISystemClock clock)
{
_urs = urs;
_clock = clock;
/// <summary>
/// 验证
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
{
/
/此处使⽤context.UrName, context.Password ⽤户名和密码来与数据库的数据做校验
if (_urs.ValidateCredentials(context.UrName, context.Password))
{
var ur = _urs.FindByUrname(context.UrName);
//验证通过返回结果
//subjectId 为⽤户唯⼀标识⼀般为⽤户id
//authenticationMethod 描述⾃定义授权类型的认证⽅法
//authTime 授权时间
//claims 需要返回的⽤户⾝份信息单元此处应该根据我们从数据库读取到的⽤户信息添加Claims 如果是从数据库中读取⾓⾊信息,那么我们应该在此处添加此处只返回必要的Cl context.Result = new GrantValidationResult(
ur.SubjectId ?? throw new ArgumentException("Subject ID not t", nameof(ur.SubjectId)),
OidcConstants.AuthenticationMethods.Password, _clock.UtcNow.UtcDateTime,
ur.Claims);
}
el
{
//验证失败
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 urs
/// </summary>
protected readonly TestUrStore Urs;
/// <summary>
/// Initializes a new instance of the <e cref="TestUrProfileService"/> class.
/// </summary>
/// <param name="urs">The urs.</param>
investigation/// <param name="logger">The logger.</param>
public CustomProfileService(TestUrStore urs, ILogger<TestUrProfileService> logger)
{
Urs = urs;
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 ur = Urs.FindBySubjectId(context.Subject.GetSubjectId());
if (ur != null)
{
//调⽤此⽅法以后内部会进⾏过滤,只将⽤户请求的Claim加⼊到 context.IssuedClaims 集合中这样我们的请求⽅便能正常获取到所需Claim
context.AddRequestedClaims(ur.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 ur = Urs.FindBySubjectId(context.Subject.GetSubjectId());
context.IsActive = ur?.IsActive == true;
return Task.CompletedTask;
}
同样在Startup类⾥启⽤我们⾃定义的ProfileService :AddProfileService<CustomProfileService>()
上述说明配图:
如果直接 context.IssuedClaims=Ur.Claims,那么返回结果如下:
/// <summary>
tuesday的发音/// 只要有关⽤户的⾝份信息单元被请求(例如在令牌创建期间或通过⽤户信息终点),就会调⽤此⽅法
/// </summary>
/// <param name="context">The context.</param>
/// <returns></returns>
public virtual Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var ur = Urs.FindBySubjectId(context.Subject.GetSubjectId());
if (ur != null)
context.IssuedClaims .AddRange(ur.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("cret".Sha256())
},
AllowedScopes = { "api1" ,IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,"custom.profile"}
}
我们在Client对象的AllowedScopes属性⾥加⼊了我们刚刚定义的⾝份资源,下载访问⽤户信息终结点将会得到和上⾯⼀样的结果。
新增于2018.12.14
在定义 Client 资源的时候发现,Client也有⼀个Claims属性,根据注释得知,在此属性上设置的值将会被直接添加到AccessToken,代码如下:
new Client
{
ClientId = "client",
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets =
{
2020高考试题及答案new Secret("cret".Sha256())
},
AllowedScopes =
{
"api1", IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile
opposites},
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时不再需要AddTestUrs,因为将使⽤我们⾃⼰的⽤户信息。