1. 程式人生 > >ASP.NET MVC 隨想錄—— 使用ASP.NET Identity實現基於宣告的授權,高階篇

ASP.NET MVC 隨想錄—— 使用ASP.NET Identity實現基於宣告的授權,高階篇

在這篇文章中,我將繼續ASP.NET Identity 之旅,這也是ASP.NET Identity 三部曲的最後一篇。在本文中,將為大家介紹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,它的定義如下所示:

  1. public class ClaimsController : Controller
  2. {
  3.     [Authorize]
  4.     public ActionResult Index()
  5.     {
  6.         ClaimsIdentity claimsIdentity = HttpContext.User.Identity as ClaimsIdentity;
  7.         if (claimsIdentity == null)
  8.         {
  9.             return View("Error", new string[] {"未找到宣告"});
  10.         }
  11.         else
  12.         {
  13.             return View(claimsIdentity.Claims);
  14.         }
  15.     }
  16. }

在這個例子中可以看出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進行修改,它呈現使用者所有宣告資訊,相應的檢視程式碼如下所示:

  1. @using System.Security.Claims
  2. @using Users.Infrastructure
  3. @model IEnumerable<Claim>
  4. @{
  5.     ViewBag.Title = "Index";
  6. }
  7. <div class="panel panel-primary">
  8.     <div class="panel-heading">
  9.         聲
  10.     </div>
  11.     <table class="table table-striped">
  12.         <tr>
  13.             <th>Subject</th>
  14.             <th>Issuer</th>
  15.             <th>Type</th>
  16.             <th>Value</th>
  17.         </tr>
  18.         @foreach (Claim claim in Model.OrderBy(x=>x.Type))
  19.         {
  20.             <tr>
  21.                 <td>@claim.Subject.Name</td>
  22.                 <td>@claim.Issuer</td>
  23.                 <td>@Html.ClaimType(claim.Type)</td>
  24.                 <td>@claim.Value</td>
  25.             </tr>
  26.         }
  27.     </table>
  28. </div>

Claim物件的Type屬性返回URI Schema,這對於我們來說並不是特別有用,常見的被用來當作值的Schema定義在System.Security.Claims.ClaimType 類中,所以要使輸出的內容可讀性更強,我添加了一個HTML helper,它用來格式化Claim.Type 的值:

  1. public static MvcHtmlString ClaimType(this HtmlHelper html, string claimType)
  2. {
  3.     FieldInfo[] fields = typeof(ClaimTypes).GetFields();
  4.     foreach (FieldInfo field in fields)
  5.     {
  6.         if (field.GetValue(null).ToString() == claimType)
  7.         {
  8.             return new MvcHtmlString(field.Name);
  9.         }
  10.     }
  11.     return new MvcHtmlString(string.Format("{0}",
  12.     claimType.Split('/', '.').Last()));
  13. }

有了上述的基礎設施程式碼後,我請求ClaimsController 下的Index Action時,顯示使用者關聯的所有宣告,如下所示:

回到頂部

建立並使用宣告

有兩個原因讓我覺得宣告很有趣。第一個原因是,應用程式能從多個來源獲取宣告,而不是僅僅依靠本地資料庫來獲取。在稍後,我會向你展示如何使用外部第三方系統來驗證使用者身份和建立宣告,但此時我新增一個類,來模擬一個內部提供宣告的系統,將它命名為LocationClaimsProvider,如下所示:

  1. public static class LocationClaimsProvider
  2. {
  3.     public static IEnumerable<Claim> GetClaims(ClaimsIdentity user)
  4.     {
  5.         List<Claim> claims=new List<Claim>();
  6.         if (user.Name.ToLower()=="admin")
  7.         {
  8.             claims.Add(CreateClaim(ClaimTypes.PostalCode, "DC 20500"));
  9.             claims.Add(CreateClaim(ClaimTypes.StateOrProvince, "DC"));
  10.         }
  11.         else
  12.         {
  13.             claims.Add(CreateClaim(ClaimTypes.PostalCode, "NY 10036"));
  14.             claims.Add(CreateClaim(ClaimTypes.StateOrProvince, "NY"));
  15.         }
  16.         return claims;
  17.     }
  18.     private static Claim CreateClaim(string type,string value)
  19.     {
  20.         return new Claim(type, value, ClaimValueTypes.String, "RemoteClaims");
  21.     }
  22. }

上述程式碼中,GetClaims 方法接受一個引數為ClaimsIdentity 物件併為使用者建立了PostalCode和StateOrProvince的宣告。在這個類中,假設我模擬一個系統,如一箇中央的人力資源資料庫,那麼這將是關於工作人員本地資訊的權威來源。

宣告是在身份驗證過程被新增到使用者中,故在Account/Login Action對程式碼稍作修改:

  1. [HttpPost]
  2. [AllowAnonymous]
  3. [ValidateAntiForgeryToken]
  4. public async Task<ActionResult> Login(LoginModel model,string returnUrl)
  5. {
  6.     if (ModelState.IsValid)
  7.     {
  8.         AppUser user = await UserManager.FindAsync(model.Name, model.Password);
  9.         if (user==null)
  10.         {
  11.             ModelState.AddModelError("","無效的使用者名稱或密碼");
  12.         }
  13.         else
  14.         {
  15.             var claimsIdentity =
  16.                 await UserManager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie);
  17.             claimsIdentity.AddClaims(LocationClaimsProvider.GetClaims(claimsIdentity));
  18.             AuthManager.SignOut();
  19.             AuthManager.SignIn(new AuthenticationProperties {IsPersistent = false}, claimsIdentity);
  20.             return Redirect(returnUrl);
  21.         }
  22.     }
  23.     ViewBag.returnUrl = returnUrl;
  24.     return View(model);
  25. }

修改完畢,執行應用程式,身份驗證成功過後,瀏覽Claims/Index 地址,你就可以看到已經成功對使用者新增聲明瞭,如下截圖所示:

獲取宣告來自多個來源意味著我們的應用程式不會有重複資料並可以和外部資料整合。Claim 物件的Issuer 屬性 告訴你這個宣告的來源,這能幫助我們精確判斷資料的來源。舉個例子,從中央人力資源資料庫獲取的資訊比從外部供應商郵件列表獲取的資訊會更準確。

宣告是有趣的第二個原因是你能用他們來管理使用者訪問,這比使用標準的角色控制來的更為靈活。在前一篇文章中,我建立了一個專門負責角色的管理RoleContoller,在RoleController裡實現使用者和角色的繫結,一旦使用者被賦予了角色,則該成員將一直隸屬於這個角色直到他被移除掉。這會有一個潛在的問題,在大公司工作時間很長的員工,當他們換部門時換工作時,如果舊的角色沒被刪除,那麼可能會出現資料洩露的風險。

考慮使用宣告吧,如果把傳統的角色控制視為靜態的話,那麼宣告是動態的,我們可以在程式執行時動態建立宣告。宣告可以直接基於已知的使用者資訊來授權使用者訪問,這樣確保當宣告資料更改時授權也更改。

最簡單的是使用Role 宣告來對Action 受限訪問,這我們已經很熟悉了,因為ASP.NET Identity 已經很好的整合到了ASP.NET 平臺中了,當使用ASP.NET Identity 時,HttpContext.User 返回的是ClaimsPrincipal 物件,它實現了IsInRole 方法並使用HasClaim來判斷指定的角色宣告是否存在,從而達到授權。

接著剛才的話題,我們想讓授權是動態的,是由使用者資訊(宣告)驅動的,所以我建立了一個ClaimsRoles類,用來模擬生成宣告,如下所示:

  1. public class ClaimsRoles
  2. {
  3.     public static IEnumerable<Claim> CreateRolesFromClaims(ClaimsIdentity user)
  4.     {
  5.         List<Claim> claims = new List<Claim>();
  6.         if (user.HasClaim(x => x.Type == ClaimTypes.StateOrProvince
  7.         && x.Issuer == "RemoteClaims" && x.Value == "北京")
  8.         && user.HasClaim(x => x.Type == ClaimTypes.Role
  9.         && x.Value == "Employee"))
  10.         {
  11.             claims.Add(new Claim(ClaimTypes.Role, "BjStaff"));
  12.         }
  13.         return claims;
  14.     }
  15. }

初略看一下CreateRolesFromClaims方法中的程式碼,使用Lambda表示式檢查使用者是否有來自Issuer為RemoteClaims ,值為北京的StateOrProvince宣告和值為Employee 的Role宣告,如果使用者都包含兩者,新增一個值為BjStaff 的 Role 宣告。最後在Login Action 時呼叫此方法,如下所示:

  1. [HttpPost]
  2. [AllowAnonymous]
  3. [ValidateAntiForgeryToken]
  4. public async Task<ActionResult> Login(LoginModel model,string returnUrl)
  5. {
  6.     if (ModelState.IsValid)
  7.     {
  8.         AppUser user = await UserManager.FindAsync(model.Name, model.Password);
  9.         if (user==null)
  10.         {
  11.             ModelState.AddModelError("","無效的使用者名稱或密碼");
  12.         }
  13.         else
  14.         {
  15.             var claimsIdentity = await UserManager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie);
  16.             claimsIdentity.AddClaims(LocationClaimsProvider.GetClaims(claimsIdentity));
  17.             claimsIdentity.AddClaims(ClaimsRoles.CreateRolesFromClaims(claimsIdentity));
  18.             AuthManager.SignOut();
  19.             AuthManager.SignIn(new AuthenticationProperties {IsPersistent = false}, claimsIdentity);
  20.             return Redirect(returnUrl);
  21.         }
  22.     }
  23.     ViewBag.returnUrl = returnUrl;
  24.     return View(model);
  25. }

現在就可以基於角色為BjStaff對OtherAction受限訪問,如下所示:

  1. [Authorize(Roles = "BjStaff")]
  2. public string OtherAction()
  3. {
  4.     return "這是一個受保護的Action";
  5. }

當用戶資訊發生改變時,如若生成的宣告不為BjStaff,那麼他也就沒許可權訪問OtherAction了,這完全是由使用者資訊所驅動,而非像傳統的在RoleController中顯示修改使用者和角色的關係。

回到頂部

基於宣告的授權

在前一個例子中證明了如何使用宣告來授權,但是這有點不直接因為我基於宣告來產生角色然後再基於新的角色來授權。一個更加直接和靈活的方法是通過建立一個自定義的授權過濾器特性來實現,如下展示:

  1. public class ClaimsAccessAttribute:AuthorizeAttribute
  2. {
  3.     public string Issuer { get; set; }
  4.     public string ClaimType { get; set; }
  5.     public string Value { get; set; }
  6.     protected override bool AuthorizeCore(HttpContextBase context)
  7.     {
  8.         return context.User.Identity.IsAuthenticated
  9.         && context.User.Identity is ClaimsIdentity
  10.         && ((ClaimsIdentity)context.User.Identity).HasClaim(x =>
  11.         x.Issuer == Issuer && x.Type == ClaimType && x.Value == Value
  12.         );
  13.     }
  14. }

ClaimsAccessAttribute 特性繼承自AuthorizeAttribute,並Override了 AuthorizeCore 方法,裡面的業務邏輯是當用戶驗證成功並且IIdentity的實現是ClaimsIdentity 物件,同時使用者包含通過屬性傳入的宣告,最後將此Attribute 放在AnOtherAction 前,如下所示:

  1.  [ClaimsAccess(Issuer = "RemoteClaims", ClaimType = ClaimTypes.PostalCode, Value = "200000")]
  2. public string AnotherAction()
  3. {
  4.     return "這也是一個受保護的Action";
  5. }
回到頂部

使用第三方來身份驗證

像ASP.NET Identity 這類基於宣告的系統的一個好處是任何宣告能從外部系統獲取,這意味著其他應用程式能幫我們來身份驗證。ASP.NET Identity 基於這個原則增加對第三方如Google、Microsoft、FaceBook身份驗證的支援。

使用第三方身份驗證有許多好處:許多使用者已經有一個第三方賬戶了,並且你也不想在這個應用程式管理你的憑據。使用者也不想在每一個網站上註冊賬戶並都記住密碼。使用一個統一的賬戶會比較靈活。

1.啟用Google 賬戶身份驗證

ASP.NET Identity 釋出了對第三方身份驗證的支援,通過Nuget來安裝:

Install-Package Microsoft.Owin.Security.Google

當Package 安裝完成後,在OWIN Startup啟動項中,新增對身份驗證服務的支援:

  1. app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
  2. //http://www.asp.net/mvc/overview/security/create-an-aspnet-mvc-5-app-with-facebook-and-google-oauth2-and-openid-sign-on
  3. app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions()
  4. {
  5.     ClientId = "165066370005-6nhsp87llelff3tou91hhktg6eqgr0ke.apps.googleusercontent.com",
  6.     ClientSecret = "euWbCSUZujjQGKMqOyz0msbq",
  7. });

在View中,新增一個通過Google 登陸的按鈕:

  1. @using (Html.BeginForm("GoogleLogin", "Account"))
  2. {
  3.     <input type="hidden" name="returnUrl" value="@ViewBag.returnUrl" />
  4.     <button class="btn btn-primary" type="submit">Google 賬戶登入 </button>
  5. }

當點選按鈕時,Post到Account/GoogleLogin :

  1. [HttpPost]
  2. [AllowAnonymous]
  3. public ActionResult GoogleLogin(string returnUrl)
  4. {
  5.     var properties = new AuthenticationProperties
  6.     {
  7.         RedirectUri = Url.Action("GoogleLoginCallback",
  8.         new { returnUrl = returnUrl })
  9.     };
  10.     HttpContext.GetOwinContext().Authentication.Challenge(properties, "Google");
  11.     return new HttpUnauthorizedResult();
  12. }

GoogleLogin 方法建立了AuthenticationProperties 型別的物件,並制定RedirectUri為當前Controller下的GoogleLoginCallBack Action,接下來就是見證奇蹟的時候,返回401 Unauthorize 然後OWIN 中介軟體重定向到Google 登陸頁面,而不是預設的Account/Login。這意味著,當用戶點選以Google登陸按鈕後,瀏覽器重定向到Google 身份驗證服務然後一旦身份驗證通過,重定向到GoogleLoginCallBack:

  1. /// <summary>
  2. /// Google登陸成功後(即授權成功)回掉此Action
  3. /// </summary>
  4. /// <param name="returnUrl"></param>
  5. /// <returns></returns>
  6. [AllowAnonymous]
  7. public async Task<ActionResult> GoogleLoginCallback(string returnUrl)
  8. {
  9.     ExternalLoginInfo loginInfo = await AuthManager.GetExternalLoginInfoAsync();
  10.     AppUser user = await UserManager.FindAsync(loginInfo.Login);
  11.     if (user == null)
  12.     {
  13.         user = new AppUser
  14.         {
  15.             Email = loginInfo.Email,
  16.             UserName = loginInfo.DefaultUserName,
  17.             City = Cities.Shanghai,
  18.             Country = Countries.China
  19.         };
  20.         IdentityResult result = await UserManager.CreateAsync(user);
  21.         if (!result.Succeeded)
  22.         {
  23.             return View("Error", result.Errors);
  24.         }
  25.         result = await UserManager.AddLoginAsync(user.Id, loginInfo.Login);
  26.         if (!result.Succeeded)
  27.         {
  28.             return View("Error", result.Errors);
  29.         }
  30.     }
  31.     ClaimsIdentity ident = await UserManager.CreateIdentityAsync(user,
  32.         DefaultAuthenticationTypes.ApplicationCookie);
  33.     ident.AddClaims(loginInfo.ExternalIdentity.Claims);
  34.     AuthManager.SignIn(new AuthenticationProperties
  35.     {
  36.         IsPersistent = false
  37.     }, ident);
  38.     return Redirect(returnUrl ?? "/");
  39. }

對上述程式碼中,通過AuthManager.GetExternalLoginInfoAsync 方法獲取外部登陸詳細資訊,ExternalLoginInfo 類定義瞭如下屬性:

DefaultUserName

返回使用者名稱

Email

返回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。
在夜生人靜時,泡一壺茶,拿上一本書,細細品讀,或許會有別樣的精彩正等在我們。