轉載註明作者及出處,謝謝
聽到DotNetOpenAuth是去年某一天的事了,當時在讀《RESTful Web Service》時突然好像靈光一閃,覺得Authorization這個問題似乎應該在構建服務之前就先考慮清楚,否則服務化似乎就無從談起了。為什麼這麼說呢,舉例來說,Google Canlendar是一個服務,你現在使用Google Canlendar又構建了另一個服務,並幸運的擁有了一些使用者,但這些使用者怎麼才能放心的把Google的帳戶資訊交給你,讓你拿去Google驗證呢;另一點,我們公司現在項目比較多,每個新項目建立後,都要往裡複製一份諸如Organization,UserManager之內的公用檔案(主要集中在UI層),增加工作量不說,這些不同拷貝的版本更新就是一個嚴峻的挑戰,更不要說如果一個客戶同時使用了我們兩個產品,就會發現居然同一個人要維護和軟體產品數量相同的使用者...這一切,使得將Organization之類的組件成為服務的要求變得非常強烈,即把Organization業務本身做為一個應用程式存在,發布在IIS後其他項目使用其提供的資料服務即可,這樣就不存在每次都要複製UI,以及多個產品間多套資料的問題了。到這裡,使用SSO似乎已經不可避免了,但是有個問題還沒有考慮到,在使用Web Service時不會總是直接使用頁面去調用吧,大多數時候得提供一個服務用戶端組件,否則有誰會每次調用你一個資料,還在業務層裡添加一堆有關Authorization和Cookie的代碼?所以看起來使用簡單的SSO實現方式,很難高效,體面的解決問題。
於是我就發現居然有DotNetOpenAuth(真不明白為什麼被牆了,想看的翻牆吧,源碼託管地址)這麼個東西了,看介紹似乎就涵蓋了我想要的一切功能!
文檔可能是最讓人又愛又恨的東西了,自已不願意寫文檔,但都希望使用別人的東西時有完備的文檔。
前兩天在學習jqGrid,沒有一個像樣的文檔就灰常痛苦,一天下來,瀏覽歷史裡就只有Google的搜尋記錄了。當時下載DotNetOpenAuth下來也是一樣,除了一個API Documents之外,沒有任何有點價值的資料,雖然他們提供了好幾個範例,但是對於一個比較複雜的技術來說,這些遠遠不夠,最起碼得有個Quick Start或是How to吧,可惜官網上幾乎啥也沒有。也不知到底是啥原因我後來也沒有再去看這個組件。
一直到前兩天,我還是覺得如果說要考慮無論是Web Service,抑或SOA實踐,乃至現在火熱的雲端運算,如果Authorization問題不解決掉,似乎就無從談起。很多的書上使用大量的篇幅講解如何設計,實現一個Service,但卻很少提及SOA實踐或雲端運算的實務講解,結果大家一通倒騰之後,一個個所謂的Web Service拔地而起,但怎麼看也不像是“雲”。解決Authorization問題,還是從DotNetOpenAuth入手比較好,它功能強大,而且oauth和openid是成熟的產品,使用的公司很多,幾乎成了事實標準,找一個和當前工作比較貼近的點,就學習下OpenIdSSOProvider吧。
(這篇文章呢,我想來想去不知該如何去寫,為什麼呢?主要是我認為SOA或是雲端運算是一個非常飄渺的東西,惡補一段時間後,我總會覺得對其概念還算清楚,但是時間一長就會又模糊不清,寫自已都不太清楚的東西,遭人罵是小事,耽誤人是大事。我本是個看貼不回貼的人,但是現在網上有關服務設計實務的東西少之有少,DotNetOpenAuth方面的東西也是鳳毛麟角,我是不想回貼,但是看貼也沒得看,因此權當拋磚引玉,希望能和我有共同想法的人探討一二)
說了這麼多廢話後,進入正題...
DotNetOpenAuth本身提供了一SSOProvider樣本,但是只有WebForm項目的,沒有MVC的SSOProvider樣本,本文提供MVC的SSOProvider實現方法,再順便講講個人對於使用DotNetOpenAuth的一點點小體會。
一.SsoOP SSO的服務提供者
1.建立SsoOP項目,我使用了Razor視圖引擎,添加DotNetOpenAuth.dll引用。見上面的源碼託管地址.
2.設定web.config檔案裡面的配置資訊,詳情請見本文下方樣本程式。
3.建立OpenIdController.cs
public class OpenIdController : Controller { internal static OpenIdProvider openIdProvider = new OpenIdProvider(); public ActionResult Identifier() { if (User.Identity.IsAuthenticated && ProviderEndpoint.PendingAuthenticationRequest != null) { Util.ProcessAuthenticationChallenge(ProviderEndpoint.PendingAuthenticationRequest); if (ProviderEndpoint.PendingAuthenticationRequest.IsAuthenticated.HasValue) { ProviderEndpoint.SendResponse(); } } if (Request.AcceptTypes.Contains("application/xrds+xml")) { return new TransferResult("~/OpenId/Xrds"); } return View(); } [ValidateInput(false)] public ActionResult Provider() { var request = openIdProvider.GetRequest(); if (request != null) { if (request.IsResponseReady) { return openIdProvider.PrepareResponse(request).AsActionResult(); } ProviderEndpoint.PendingRequest = (IHostProcessedRequest)request; var idrequest = request as IAuthenticationRequest; return Util.ProcessAuthenticationChallenge(idrequest); } return View(); } public ActionResult AskUser() { return View(); } public ActionResult Xrds() { return View(); } }
OpenIdController是SsoRP(SSO消費者)使用OP的進入點,其中Provider是提供登入服務的Action,這點需要在後面提到。
4.建立Xrds.cshtml視圖
@{ Layout = null; Response.ContentType = "application/xrds+xml"; var uri = new Uri(Request.Url, Response.ApplyAppPathModifier("~/OpenId/Provider")).ToString();}<?xml version="1.0" encoding="UTF-8"?><xrds:XRDSxmlns:xrds="xri://$xrds"xmlns:openid="http://openid.net/xmlns/1.0"xmlns="xri://$xrd*($v*2.0)"><XRD><Service priority="10"><Type>http://specs.openid.net/auth/2.0/server</Type><Type>http://openid.net/extensions/sreg/1.1</Type><URI>@uri</URI></Service></XRD></xrds:XRDS>
本視圖的用法也將在以後提到。
5.建立AskUser.cshtml視圖
@{ Layout = null; Response.ContentType = "application/xrds+xml"; var uri1 = new Uri(Request.Url, Response.ApplyAppPathModifier("~/OpenId/Provider")).ToString(); var uri2 = new Uri(Request.Url, Response.ApplyAppPathModifier("~/OpenId/Provider")).ToString();}<?xml version="1.0" encoding="UTF-8"?><xrds:XRDSxmlns:xrds="xri://$xrds"xmlns:openid="http://openid.net/xmlns/1.0"xmlns="xri://$xrd*($v*2.0)"><XRD><Service priority="10"><Type>http://specs.openid.net/auth/2.0/signon</Type><Type>http://openid.net/extensions/sreg/1.1</Type><URI>@uri1</URI></Service><Service priority="20"><Type>http://openid.net/signon/1.0</Type><Type>http://openid.net/extensions/sreg/1.1</Type><URI>@uri2</URI></Service></XRD></xrds:XRDS>
對於本Action的作用,現在還很含糊,只能大概的猜測其意圖,其用法也將在以後提到。
6.建立TransferResult類,什麼作用呢,這裡稍作解釋:在ASP.NET WebForm頁面中,有人可能用過Server.Transfer方法,該方法MSDN中的解釋是:對於當前請求,終止當前頁的執行,並使用指向一個新頁的指定 URL 路徑來開始執行此新頁。一般情況下似乎和Redirect方法的作用很像,但是某些特殊場合中,區別是大大的,是什麼呢?Redirect是執行用戶端重新導向,而Transfer是不用用戶端重新導向的,應該就是HTTP的302狀態吧。在使用DotNetOpenAuth的過程中,很多時候也許是基於安全的考慮,OpenId是不允許使用重新導向了的請求,不然就會出錯。在MVC中有一個RedirectToAction方法很好用,卻沒有一個TransferToAction方法,甚至沒有TransferResult類型,所以不得不自已弄一個。
public class TransferResult : RedirectResult { public TransferResult(string url) : base(url) { } public override void ExecuteResult(ControllerContext context) { var httpContext = HttpContext.Current; httpContext.RewritePath(Url, false); IHttpHandler httpHandler = new MvcHttpHandler(); httpHandler.ProcessRequest(HttpContext.Current); } }
7.其他的代碼,限於篇幅,就不一一貼上來了,全放到樣本程式裡面,結構如下:
/Code/ReadOnlyXmlMembershipProvider.cs 作用:使用者驗證
/Code/Util.cs 作用:用於處理登入及許可權請求,這個類裡面的主要方法為:ProcessAuthenticationChallenge,在官方提供的範例中是一個void,用在MVC中,必須使用一個具有ActionResult傳回值的方法了。
/AppData/Users.xml 作用:相當於存使用者資訊的資料庫
8.在項目根目錄下建立default.aspx,該檔案為使用IIS架設程式時的入口
<%@ Page Language="C#" AutoEventWireup="true" %><script runat="server">protected void Page_Load(object sender, EventArgs e) {Response.Redirect("~/Home/Index");}</script>
OK,SsoOP主要結構就是上面這些,文檔結構見(其中選中的檔案是新增的,其他的都是項目模板內建的):
二.SsoRP 這個RP和人品沒有太大關係,作用為SSO的消費者
文檔結構如下:
這個項目主要內容如下:
1.將AccountController類中的內容全部注釋,添加以下代碼:
public class AccountController : Controller { private const string RolesAttribute = "http://samples.dotnetopenauth.net/sso/roles"; private static OpenIdRelyingParty relyingParty = new OpenIdRelyingParty(); public ActionResult LogOn() { if (Array.IndexOf(Request.AcceptTypes, "application/xrds+xml") >= 0) { return View("Xrds"); } UriBuilder returnToBuilder = new UriBuilder(Request.Url); returnToBuilder.Path = "/Account/LogOn"; returnToBuilder.Query = null; returnToBuilder.Fragment = null; Uri returnTo = returnToBuilder.Uri; returnToBuilder.Path = "/Account/LogOn"; Realm realm = returnToBuilder.Uri; var response = relyingParty.GetResponse(); if (response == null) { if (Request.QueryString["ReturnUrl"] != null && User.Identity.IsAuthenticated) { // The user must have been directed here because he has insufficient // permissions to access something. this.ViewBag.Message = "1"; } else { // Because this is a sample of a controlled SSO environment, // we don't ask the user which Provider to use... we just send // them straight off to the one Provider we trust. var request = relyingParty.CreateRequest( ConfigurationManager.AppSettings["SsoProviderOPIdentifier"], realm, returnTo); var fetchRequest = new FetchRequest(); fetchRequest.Attributes.AddOptional(RolesAttribute); request.AddExtension(fetchRequest); request.RedirectToProvider(); } } else { switch (response.Status) { case AuthenticationStatus.Canceled: this.ViewBag.Message = "Login canceled."; break; case AuthenticationStatus.Failed: this.ViewBag.Message = HttpUtility.HtmlEncode(response.Exception.Message); break; case AuthenticationStatus.Authenticated: IList<string> roles = null; var fetchResponse = response.GetExtension<FetchResponse>(); if (fetchResponse != null) { if (fetchResponse.Attributes.Contains(RolesAttribute)) { roles = fetchResponse.Attributes[RolesAttribute].Values; } } if (roles == null) { roles = new List<string>(0); } // Apply the roles to this auth ticket const int TimeoutInMinutes = 100; // TODO: look up the right value from the web.config file var ticket = new FormsAuthenticationTicket( 2, response.ClaimedIdentifier, DateTime.Now, DateTime.Now.AddMinutes(TimeoutInMinutes), false, // non-persistent, since login is automatic and we wanted updated roles string.Join(";", roles.ToArray())); HttpCookie cookie = new HttpCookie(FormsAuthentication.FormsCookieName, FormsAuthentication.Encrypt(ticket)); Response.SetCookie(cookie); Response.Redirect(Request.QueryString["ReturnUrl"] ?? FormsAuthentication.DefaultUrl); break; default: break; } } return RedirectToAction("Index", "Home"); } public IFormsAuthenticationService FormsService { get; set; } protected override void Initialize(RequestContext requestContext) { if (FormsService == null) { FormsService = new FormsAuthenticationService(); } base.Initialize(requestContext); } public ActionResult LogOff() { FormsService.SignOut(); return RedirectToAction("Index", "Home"); }}
結合SsoOP,將個人的理解稍作解釋:
因為在web.config裡面使用下面的配置
<authentication mode="Forms"> <forms name="OpenIdWebRingSsoRelyingParty" loginUrl="~/Account/LogOn" protection="All" path="/" timeout="900" /> </authentication> <authorization> <deny users="?"/> </authorization>
因為使用了Forms模式,在沒有登入的情況下,無論訪問任何資源,都會使請求轉到Account的LogOn Action中,在LogOn中,程式會先向OP的Identifier驗證是否存在ProviderEndpoint,OP通過OpenIdController的Xrds Action(既OP節中的Xrds.cshtml視圖內容)告訴RP這個提供者是存在併合法的,然後RP向提供者請求認證,反過來,OP倒也要確認RP是否存在併合法(使用RP中的Xrds.cshtml),如果沒有問題OP還要驗證請求認證的RP是否在白名單中,這個白名單中必須要和returnToBuilder.Path = "/Account/LogOn";這個值完全一致,比如這裡在LogOn後面沒有"/"號,那麼在白名單中,你就必須使用http://localhost:1220/Account/LogOn,而不能在後面加上“/”號,否則就會不通過。如果一切OK,沒有問題頁面將轉向OP的登入頁面,本例中為Account/LogOn,使用者輸入正確的使用者和密碼(本例User:bob;Password:test)。
登入完成後,根據LogOn中的代碼return RedirectToAction("Identifier", "OpenId");,請求會轉向OpenId/Identifier,程式會先去準備響應資料,這些資料中包含了登入使用者資訊,熟悉openid的人知道,openid總是使用一個url+使用者名稱代表使用者名稱,這個url其實就是另一個發現OP的地方,為什麼是另一個?還有一個在哪裡呢?就在OpenId/Identifier裡面呀,(因為還沒有對DotNetOpenAuth深入研究,因此,對於官方樣本中“服務發現”這個機制還有點模糊,個人感覺應該就是相當於驗證是否相任之類的吧,Identifier應該屬於登入前和登入階段的,當登入完成後使用使用者名稱中地址裡面的驗證了?),接下來使用ProviderEndpoint.SendResponse();向用戶端發送登入結果,並使用return_to裡面的資訊將請求轉到了RP的LogOn中,(在這個過程,RP將使用OP中AskUser“發現”服務提供者。)在LogOn中,根據IAuthenticationResponse的狀態資訊,確定是登入成功還是登入失敗(會攜帶失敗原因資訊)來確定請求轉向,既然咱都有範例程式碼了,應該就不會失敗吧,所以Home/Index會如期而至。
SsoRP樣本有兩個,一個是純MVC模式的,一個是使用MVC + WebForms模式的。
DotNetOpenAuth的資料現在貌似很少,個人對其的研究現在還處於Step by Step的階段,只能說跟著官方的樣本能做出一個MVC實現,但是對於很多具體的原理還是相當不熟悉,這樣本只能是解決有和沒有的問題,本文中的謬誤還望大家不吝賜教,希望有人能發更多有深度的資料。
樣本程式