走进声明的世界
在旧的用户管理系统,例如使用了ASP.NET Membership的应用程序,我们的应用程序被认为是获取用户所有信息的权威来源,所以本质上可以将应用程序视为封闭的系统,它包含了所有的用户信息。在上一篇文章中,我使用ASP.NET Identity 验证用户存储在数据库的凭据,并根据与这些凭据相关联的角色进行授权访问,所以本质上身份验证和授权所需要的用户信息来源于我们的应用程序。
ASP.NET Identity 还支持使用声明来和用户打交道,它效果很好,而且应用程序并不是用户信息的唯一来源,有可能来自外部,这比传统角色授权来的更为灵活和方便。
接下来我将为大家介绍ASP.NET Identity 是如何支持基于声明的授权(claims-based authorization)。
1.理解什么是声明
声明(Claims)其实就是用户相关的一条一条信息的描述,这些信息包括用户的身份(如Name、Email、Country等)和角色成员,而且,它描述了这些信息的类型、值以及发布声明的认证方等。我们可以使用声明来实现基于声明的授权。声明可以从外部系统获得,当然也可以从本地用户数据库获取。
对于ASP.NET MVC应用程序,通过自定义AuthorizeAttribute,声明能够被灵活的用来对指定的Action 方法授权访问,不像传统的使用角色授权那么单一,基于声明的授权更加丰富和灵活,它允许使用用户信息来驱动授权访问。
既然声明(Claim)是一条关于用户信息的描述,最简单的方式来阐述什么是声明就是通过具体的例子来展示,这比抽象概念的讲解来的更有用。所以,我在示例项目中添加了一个名为Claims 的 Controller,它的定义如下所示:
public class ClaimsController : Controller { [Authorize] public ActionResult Index() { ClaimsIdentity claimsIdentity = HttpContext.User.Identity as ClaimsIdentity; //HttpContext.User 返回的是ClaimsPrincipal 对象 if (claimsIdentity == null) { return View("Error", new string[] {"未找到声明"}); } else { return View(claimsIdentity.Claims); } } }
在这个例子中可以看出ASP.NET Identity 已经很好的集成到ASP.NET 平台中,而HttpContext.User.Identity 属性返回一个 IIdentity 接口的实现,而当与ASP.NET Identity 结合使用时,返回的是ClaimsIdentity 对象。
ClaimsIdentity 类被定义在System.Security.Claims 名称空间下,它包含如下重要的成员:
Claims |
返回用户包含的声明对象集合 |
AddClaim(claim) |
为用户添加一个声明 |
AddClaims(claims) |
为用户添加一系列声明 |
HasClaim(predicate) |
判断是否包含声明,如果是,返回True |
RemoveClaim(claim) |
为用户移除声明 |
当然ClaimsIdentity 类还有更多的成员,但上述表描述的是在Web应用程序中使用频率很高的成员。在上述代码中,将HttpContext.User.Identity 转换为ClaimsIdentity 对象,并通过该对象的Claims 属性获取到用户相关的所有声明。
一个声明对象代表了用户的一条单独的信息数据,声明对象包含如下属性:
Issuer |
返回提供声明的认证方名称 |
Subject |
返回声明指向的ClaimIdentity 对象 |
Type |
返回声明代表的信息类型 |
Value |
返回声明代表的用户信息的值 |
有了对声明的基本概念,对上述代码的View进行修改,它呈现用户所有声明信息,相应的视图代码如下所示:
@using System.Security.Claims @using Users.Infrastructure @model IEnumerable<Claim> @{ ViewBag.Title = "Index"; } <div class="panel panel-primary"> <div class="panel-heading"> 声明 </div> <table class="table table-striped"> <tr> <th>Subject</th> <th>Issuer</th> <th>Type</th> <th>Value</th> </tr> @foreach (Claim claim in Model.OrderBy(x=>x.Type)) { <tr> <td>@claim.Subject.Name</td> <td>@claim.Issuer</td> <td>@Html.ClaimType(claim.Type)</td> <td>@claim.Value</td> </tr> } </table> </div>
Claim对象的Type属性返回URI Schema,这对于我们来说并不是特别有用,常见的被用来当作值的Schema定义在System.Security.Claims.ClaimType 类中,所以要使输出的内容可读性更强,我添加了一个HTML helper,它用来格式化Claim.Type 的值:
public static MvcHtmlString ClaimType(this HtmlHelper html, string claimType) { FieldInfo[] fields = typeof(ClaimTypes).GetFields(); foreach (FieldInfo field in fields) { if (field.GetValue(null).ToString() == claimType) { return new MvcHtmlString(field.Name); } } return new MvcHtmlString(string.Format("{0}", claimType.Split('/', '.').Last())); }
有了上述的基础设施代码后,我请求ClaimsController 下的Index Action时,显示用户关联的所有声明,如下所示:
创建并使用声明
有两个原因让我觉得声明很有趣。第一个原因是,应用程序能从多个来源获取声明,而不是仅仅依靠本地数据库来获取。在稍后,我会向你展示如何使用外部第三方系统来验证用户身份和创建声明,但此时我添加一个类,来模拟一个内部提供声明的系统,将它命名为LocationClaimsProvider,如下所示:
public static class LocationClaimsProvider { public static IEnumerable<Claim> GetClaims(ClaimsIdentity user) { List<Claim> claims=new List<Claim>(); if (user.Name.ToLower()=="admin") { claims.Add(CreateClaim(ClaimTypes.PostalCode, "DC 20500")); claims.Add(CreateClaim(ClaimTypes.StateOrProvince, "DC")); } else { claims.Add(CreateClaim(ClaimTypes.PostalCode, "NY 10036")); claims.Add(CreateClaim(ClaimTypes.StateOrProvince, "NY")); } return claims; } private static Claim CreateClaim(string type,string value) { return new Claim(type, value, ClaimValueTypes.String, "RemoteClaims"); } }
上述代码中,GetClaims 方法接受一个参数为ClaimsIdentity 对象并为用户创建了PostalCode和StateOrProvince的声明。在这个类中,假设我模拟一个系统,如一个中央的人力资源数据库,那么这将是关于工作人员本地信息的权威来源。
声明是在身份验证过程被添加到用户中,故在Account/Login Action对代码稍作修改:
[HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public async Task<ActionResult> Login(LoginModel model,string returnUrl) { if (ModelState.IsValid) { AppUser user = await UserManager.FindAsync(model.Name, model.Password); if (user==null) { ModelState.AddModelError("","无效的用户名或密码"); } else { var claimsIdentity = await UserManager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie); claimsIdentity.AddClaims(LocationClaimsProvider.GetClaims(claimsIdentity)); AuthManager.SignOut(); AuthManager.SignIn(new AuthenticationProperties {IsPersistent = false}, claimsIdentity); return Redirect(returnUrl); } } ViewBag.returnUrl = returnUrl; return View(model); }
修改完毕,运行应用程序,身份验证成功过后,浏览Claims/Index 地址,你就可以看到已经成功对用户添加声明了,如下截图所示:
获取声明来自多个来源意味着我们的应用程序不会有重复数据并可以和外部数据集成。Claim 对象的Issuer 属性 告诉你这个声明的来源,这能帮助我们精确判断数据的来源。举个例子,从中央人力资源数据库获取的信息比从外部供应商邮件列表获取的信息会更准确。
声明最有趣的第二个原因是你能用他们来管理用户访问,这比使用标准的角色控制来的更为灵活。在前一篇文章中,我创建了一个专门负责角色管理的控制器RoleContoller,在RoleController里实现用户和角色的绑定,一旦用户被赋予了角色,则该成员将一直隶属于这个角色直到他被移除掉。这会有一个潜在的问题,在大公司工作时间很长的员工,当他们换部门、换工作时,如果旧的角色没被删除,那么可能会出现资料泄露的风险。
考虑使用声明吧,如果把传统的角色控制视为静态的话,那么声明是动态的,我们可以在程序运行时动态创建声明。声明可以直接基于已知的用户信息来授权用户访问,这样确保当声明数据更改时授权也更改。
最简单的是使用Role 声明来对Action 受限访问,这我们已经很熟悉了,因为ASP.NET Identity 已经很好的集成到了ASP.NET 平台中了,当使用ASP.NET Identity 时,HttpContext.User 返回的是ClaimsPrincipal 对象,它实现了IsInRole 方法并使用HasClaim来判断指定的角色声明是否存在,从而达到授权。
接着刚才的话题,我们想让授权是动态的,是由用户信息(声明)驱动的,所以我创建了一个ClaimsRoles类,用来模拟生成声明,如下所示:
public class ClaimsRoles { public static IEnumerable<Claim> CreateRolesFromClaims(ClaimsIdentity user) { List<Claim> claims = new List<Claim>(); if (user.HasClaim(x => x.Type == ClaimTypes.StateOrProvince && x.Issuer == "RemoteClaims" && x.Value == "北京") && user.HasClaim(x => x.Type == ClaimTypes.Role && x.Value == "Employee")) { claims.Add(new Claim(ClaimTypes.Role, "BjStaff")); } return claims; } }
初略看一下CreateRolesFromClaims方法中的代码,使用Lambda表达式检查用户是否有来自Issuer为RemoteClaims ,值为北京的StateOrProvince声明和值为Employee 的Role声明,如果用户都包含两者,新增一个值为BjStaff 的 Role 声明。最后在Login Action 时调用此方法,如下所示:
[HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public async Task<ActionResult> Login(LoginModel model,string returnUrl) { if (ModelState.IsValid) { AppUser user = await UserManager.FindAsync(model.Name, model.Password); if (user==null) { ModelState.AddModelError("","无效的用户名或密码"); } else { var claimsIdentity = await UserManager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie); claimsIdentity.AddClaims(LocationClaimsProvider.GetClaims(claimsIdentity)); claimsIdentity.AddClaims(ClaimsRoles.CreateRolesFromClaims(claimsIdentity)); AuthManager.SignOut(); AuthManager.SignIn(new AuthenticationProperties {IsPersistent = false}, claimsIdentity); return Redirect(returnUrl); } } ViewBag.returnUrl = returnUrl; return View(model); }
现在就可以基于角色为BjStaff对OtherAction受限访问,如下所示:
[Authorize(Roles = "BjStaff")] public string OtherAction() { return "这是一个受保护的Action"; }
当用户信息发生改变时,如若生成的声明不为BjStaff,那么他也就没权限访问OtherAction了,这完全是由用户信息所驱动,而非像传统的在RoleController中显示修改用户和角色的关系。
- 优点:这种动态生成角色,可以考虑多个因素:在哪个省份、属于哪个角色等等,然后生成一个角色,用这个角色限制对控制器和操作的访问权限;
- 缺点:这个角色名称需要预先定义而且代码中已经写好。能否取请求的控制器名称及操作名称,再根据当前用户名称和所在的角色来控制对控制器及操作的访问权限;
- 时刻:在登录Login中为当前用户添加声明、角色。
基于声明的授权
在前一个例子中证明了如何使用声明来授权,但是这有点不直接,因为 基于声明 --> 产生角色 --> 再基于新的角色来授权。一个更加直接和灵活的方法是通过创建一个自定义的授权过滤器特性来实现,如下展示:
public class ClaimsAccessAttribute:AuthorizeAttribute { public string Issuer { get; set; } public string ClaimType { get; set; } public string Value { get; set; } protected override bool AuthorizeCore(HttpContextBase context) { return context.User.Identity.IsAuthenticated && context.User.Identity is ClaimsIdentity && ((ClaimsIdentity)context.User.Identity).HasClaim(x => x.Issuer == Issuer && x.Type == ClaimType && x.Value == Value ); } }
ClaimsAccessAttribute 特性继承自AuthorizeAttribute,并Override了 AuthorizeCore 方法,里面的业务逻辑是当用户验证成功并且IIdentity的实现是ClaimsIdentity 对象,同时用户包含通过属性传入的声明,最后将此Attribute 放在AnOtherAction 前,如下所示:
[ClaimsAccess(Issuer = "RemoteClaims", ClaimType = ClaimTypes.PostalCode, Value = "200000")] public string AnotherAction() { return "这也是一个受保护的Action"; }
注:这种方法是在操作前给出了验证参数【写死了】,然后与用户已经具有的声明比较【用户声明必须在登录时添加好】,不够灵活。
使用第三方来身份验证
像ASP.NET Identity 这类基于声明的系统的一个好处是任何声明能从外部系统获取,这意味着其他应用程序能帮我们来身份验证。ASP.NET Identity 基于这个原则增加对第三方如Google、Microsoft、FaceBook身份验证的支持。
使用第三方身份验证有许多好处:许多用户已经有一个第三方账户了,并且你也不想在这个应用程序管理你的凭据。用户也不想在每一个网站上注册账户并都记住密码。使用一个统一的账户会比较灵活。
1.启用Google 账户身份验证
ASP.NET Identity 发布了对第三方身份验证的支持,通过Nuget来安装:
Install-Package Microsoft.Owin.Security.Google
当Package 安装完成后,在OWIN Startup启动项中,添加对身份验证服务的支持:
-
app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
-
//http://www.asp.net/mvc/overview/security/create-an-aspnet-mvc-5-app-with-facebook-and-google-oauth2-and-openid-sign-on
-
app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions()
-
{
-
ClientId = "165066370005-6nhsp87llelff3tou91hhktg6eqgr0ke.apps.googleusercontent.com",
-
ClientSecret = "euWbCSUZujjQGKMqOyz0msbq",
-
});
在View中,添加一个通过Google 登陆的按钮:
-
@using (Html.BeginForm("GoogleLogin", "Account"))
-
{
-
<input type="hidden" name="returnUrl" value="@ViewBag.returnUrl" />
-
<button class="btn btn-primary" type="submit">Google 账户登录 </button>
-
}
当点击按钮时,Post到Account/GoogleLogin :
-
[HttpPost]
-
[AllowAnonymous]
-
public ActionResult GoogleLogin(string returnUrl)
-
{
-
var properties = new AuthenticationProperties
-
{
-
RedirectUri = Url.Action("GoogleLoginCallback",
-
new { returnUrl = returnUrl })
-
};
-
HttpContext.GetOwinContext().Authentication.Challenge(properties, "Google");
-
return new HttpUnauthorizedResult();
-
}
GoogleLogin 方法创建了AuthenticationProperties 类型的对象,并制定RedirectUri为当前Controller下的GoogleLoginCallBack Action,接下来就是见证奇迹的时候,返回401 Unauthorize 然后OWIN 中间件重定向到Google 登陆页面,而不是默认的Account/Login。这意味着,当用户点击以Google登陆按钮后,浏览器重定向到Google 身份验证服务然后一旦身份验证通过,重定向到GoogleLoginCallBack:
-
/// <summary>
-
/// Google登陆成功后(即授权成功)回掉此Action
-
/// </summary>
-
/// <param name="returnUrl"></param>
-
/// <returns></returns>
-
[AllowAnonymous]
-
public async Task<ActionResult> GoogleLoginCallback(string returnUrl)
-
{
-
ExternalLoginInfo loginInfo = await AuthManager.GetExternalLoginInfoAsync();
-
AppUser user = await UserManager.FindAsync(loginInfo.Login);
-
if (user == null)
-
{
-
user = new AppUser
-
{
-
Email = loginInfo.Email,
-
UserName = loginInfo.DefaultUserName,
-
City = Cities.Shanghai,
-
Country = Countries.China
-
};
-
-
IdentityResult result = await UserManager.CreateAsync(user);
-
if (!result.Succeeded)
-
{
-
return View("Error", result.Errors);
-
}
-
result = await UserManager.AddLoginAsync(user.Id, loginInfo.Login);
-
if (!result.Succeeded)
-
{
-
return View("Error", result.Errors);
-
}
-
}
-
ClaimsIdentity ident = await UserManager.CreateIdentityAsync(user,
-
DefaultAuthenticationTypes.ApplicationCookie);
-
ident.AddClaims(loginInfo.ExternalIdentity.Claims);
-
AuthManager.SignIn(new AuthenticationProperties
-
{
-
IsPersistent = false
-
}, ident);
-
return Redirect(returnUrl ?? "/");
-
}
对上述代码中,通过AuthManager.GetExternalLoginInfoAsync 方法获取外部登陆详细信息,ExternalLoginInfo 类定义了如下属性:
DefaultUserName |
返回用户名 |
|
返回Email 地址 |
ExternalIdentity |
返回代表用户的ClaimIdentity |
Login |
返回一个UserLoginInfo用来描述外部登陆 |
接着使用定义在UserManager对象中的FindAsync方法,传入ExternalLoginInfo.Login 属性,来获取AppUser对象,如果返回的对象不存在,这意味这这是该用户第一次登录到我们的应用程序中,所以我创建了一个AppUser对象并填充了属性然后将其保存到数据库中。
我同样也保存了用户登陆的详细信息以便下一次能找到。
最后,创建ClaimsIdentity 对象并创建Cookie,让应用程序知道用户已经验证通过了。
为了测试Google 身份验证,我们启动应用程序,当验证通过后,访问Claims/Index,得到如下声明:
可以看到一些声明的认证发布者是Google,而且这些信息来自于第三方。
小节
在这篇文章中,我为大家介绍了ASP.NET Identity 支持的一些高级功能,并解释了Claim是如何运行以及怎样创建灵活的授权访问。在本文最后演示了如和通过Google来身份验证。
在技术领域,我们往往会对一些晦涩难翻译的术语感到惶恐,甚至会排斥它,比如yield、Identity、Claim。
在夜生人静时,泡一壶茶,拿上一本书,细细品读,或许会有别样的精彩正等在我们。