使用ASP.NET Identity實現基於聲明的授權
在這篇文章中,我將繼續ASP.NET Identity 之旅,這也是ASP.NET Identity 三部曲的最后一篇。在本文中,將為大家介紹ASP.NET Identity 的高級功能,它支持聲明式並且還可以靈活的與ASP.NET MVC 授權結合使用,同時,它還支持使用第三方來實現身份驗證。
關於ASP.NET Identity 的基礎知識,請參考如下文章:
ASP.NET MVC 隨想錄——開始使用ASP.NET Identity,初級篇
ASP.NET MVC 隨想錄——探索ASP.NET Identity 身份驗證和基於角色的授權,中級篇
本文的示例,你可以在此下載和預覽:
走進聲明的世界
在舊的用戶管理系統,例如使用了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;
-
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中顯示修改用戶和角色的關系。
基於聲明的授權
在前一個例子中證明了如何使用聲明來授權,但是這有點不直接因為我基於聲明來產生角色然后再基於新的角色來授權。一個更加直接和靈活的方法是通過創建一個自定義的授權過濾器特性來實現,如下展示:
-
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。
在夜生人靜時,泡一壺茶,拿上一本書,細細品讀,或許會有別樣的精彩正等在我們。