最近有朋友向我咨詢單點登錄的相關問題,並多次提到了OAuth這個名詞.本人不才,由於工作關系尚未有過相關經驗.於是上網搜索相關資料並初步研究了在.net下單點登錄的實際應用.略有微小心得,現記錄如下
1.什么是OpenId
OpenId是一個分布式的身份管理系統,也叫做分散的單點登錄平台。通過在多系統間減化登錄過程來提高用戶體驗.
參考:
2.OpenId與OAuth的區別
OAuth和OpenID的區別在於應用場景的區別,OAuth用於授權的,是一套授權(Authorization)協議;OpenID是用來認證的,是一套認證(Authentication)協議。很多人現在錯誤的把OAuth當做OpenID使用,但是其實也不會照成什么影響。
參考:
Difference Between oAuth and OpenID
3.OpenId的一般認證過程
A.用戶希望訪問其在example.com的賬戶
B.example.com (在OpenID的黑話里面被稱為“Relying Party”) 提示用戶輸入他/她/它的OpenID
C.用戶給出了他的OpenID,比如說"http://user.myopenid.com"
D.example.com 跳轉到了用戶的OpenID提供商“mypopenid.com”
E.用戶在"myopenid.com"(OpenID provider)提示的界面上輸入用戶名密碼登錄
F.“myopenid.com" (OpenID provider) 問用戶是否要登錄到example.com
G.用戶同意后,"myopenid.com" (OpenID provider) 跳轉回example.com
H.example.com 允許用戶訪問其帳號
可以看到,所謂的OpenId,其實就是以Url的方式表達某個認證中心網站的某個用戶.如上面的http://user.myopenid.com,首先它是一個網址,其次myopenid.com是認證中心網站地址,user是實際的用戶名.
4.OpenId在.Net下的應用
DotNetOpenAuth是本技術在.Net下廣泛應用的類庫,書寫本文時最新的版本為4.2.2.13055.下載后在其Sample文件夾下可以看到其一整套的示例程序.可以看到其不僅支持OpenId,還支持OAuth和InfoCard技術.
本文通過分析認證過程來研究其在Asp.Net Mvc場景下的應用.主要涉及兩個示例項目:OpenIdProviderMvc為服務提供者(即認證中心,以下簡稱P,地址為Http://192.168.0.217:85),OpenIdRelyingPartyMvc為服務使用者(以下簡稱A,地址為Http://192.168.0.217:87).
A.獲取OpenId
使用OpenId的第一步是要獲取自己的OpenId.如同一般的認證系統使用前需注冊一樣.但在實際使用中,也可只需輸入認證網站的地址.如同上面的例子,輸入http://192.168.0.217:85與http://192.168.0.217:85/user/bob(bob為用戶名)都可進入認證過程.后面將會結合例子做具體的分析.
B.服務發現
即然A將認證過程交給P完成,則用戶在登錄A時,A系統會提示用戶輸入自己的OpenId.A系統提交代碼如下:
1 private static OpenIdRelyingParty openid = new OpenIdRelyingParty(); 2 3 public ActionResult Authenticate(string returnUrl) 4 { 5 var response = openid.GetResponse(); 6 if (response == null) 7 { 8 // Stage 2: user submitting Identifier 9 Identifier id; 10 if (Identifier.TryParse(Request.Form["openid_identifier"], out id)) 11 { 12 try 13 { 14 return openid.CreateRequest(Request.Form["openid_identifier"]).RedirectingResponse.AsActionResult(); 15 } 16 catch (ProtocolException ex) 17 { 18 ViewData["Message"] = ex.Message; 19 return View("Login"); 20 } 21 } 22 else 23 { 24 ViewData["Message"] = "Invalid identifier"; 25 return View("Login"); 26 } 27 } 28 else 29 { 30 // Stage 3: OpenID Provider sending assertion response 31 switch (response.Status) 32 { 33 case AuthenticationStatus.Authenticated: 34 Session["FriendlyIdentifier"] = response.FriendlyIdentifierForDisplay; 35 FormsAuthentication.SetAuthCookie(response.ClaimedIdentifier, false); 36 if (!string.IsNullOrEmpty(returnUrl)) 37 { 38 return Redirect(returnUrl); 39 } 40 else 41 { 42 return RedirectToAction("Index", "Home"); 43 } 44 case AuthenticationStatus.Canceled: 45 ViewData["Message"] = "Canceled at provider"; 46 return View("Login"); 47 case AuthenticationStatus.Failed: 48 ViewData["Message"] = response.Exception.Message; 49 return View("Login"); 50 } 51 } 52 return new EmptyResult(); 53 }
代碼第5行是獲取P系統的響應.如果為空,如這里首次提交,便進入Stage 2.向用戶輸入的地址發起一個Http請求.如果不為空,則分析其狀態碼.33行是認證成功,則在本地設置Cookie,並跳回上一次使用的頁面.
這個OpenId不是隨便輸的.如果輸入一個錯誤的地址,A系統則會提示No OpenID endpoint found.上面說過,這里可以輸入兩類地址,http://192.168.0.217:85與http://192.168.0.217:85/user/bob.下面就將目光轉到服務端,來看看這兩個地址后面究竟對應什么處理代碼
public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.MapRoute( "User identities", "user/{id}/{action}", new { controller = "User", action = "Identity", id = string.Empty, anon = false }); routes.MapRoute( "PPID identifiers", "anon", new { controller = "User", action = "Identity", id = string.Empty, anon = true }); routes.MapRoute( "Default", // Route name "{controller}/{action}/{id}", // URL with parameters new { controller = "Home", action = "Index", id = string.Empty }); // Parameter defaults }
可以看到,兩個地址對應了兩個不同的Controller.首先看看Default
public ActionResult Index() { if (Request.AcceptTypes.Contains("application/xrds+xml")) { ViewData["OPIdentifier"] = true; return View("Xrds"); } ViewData["Message"] = "Welcome to ASP.NET MVC!"; return View(); } public ActionResult Xrds() { ViewData["OPIdentifier"] = true; return View(); }
其實就是返回一個名為Xrds的View,再看看User identities
public ActionResult Identity(string id, bool anon) { if (!anon) { var redirect = this.RedirectIfNotNormalizedRequestUri(id); if (redirect != null) { return redirect; } } if (Request.AcceptTypes != null && Request.AcceptTypes.Contains("application/xrds+xml")) { return View("Xrds"); } if (!anon) { this.ViewData["username"] = id; } return View(); } public ActionResult Xrds(string id) { return View(); }
這樣就明白了,雖然使用了提供了兩個認證地址,對應兩個處理Controller,但其實做了同一件事,就是返回一個Xrds.下面就來看看它
1 <%@ Page Language="C#" AutoEventWireup="true" ContentType="application/xrds+xml" %> 2 <%@ OutputCache Duration="86400" VaryByParam="none" Location="Any" %><?xml version="1.0" encoding="UTF-8"?> 3 <%-- 4 This XRDS view is used for both the OP identifier and the user identity pages. 5 Only a couple of conditional checks are required to share the view, but sharing the view 6 makes it very easy to ensure that all the Type URIs that this server supports are included 7 for all XRDS discovery. 8 --%> 9 <xrds:XRDS 10 xmlns:xrds="xri://$xrds" 11 xmlns:openid="http://openid.net/xmlns/1.0" 12 xmlns="xri://$xrd*($v*2.0)"> 13 <XRD> 14 <Service priority="10"> 15 <% if (ViewData["OPIdentifier"] != null) { %> 16 <Type>http://specs.openid.net/auth/2.0/server</Type> 17 <% } else { %> 18 <Type>http://specs.openid.net/auth/2.0/signon</Type> 19 <% } %> 20 <Type>http://openid.net/extensions/sreg/1.1</Type> 21 <Type>http://axschema.org/contact/email</Type> 22 23 <%-- 24 Add these types when and if the Provider supports the respective aspects of the UI extension. 25 <Type>http://specs.openid.net/extensions/ui/1.0/mode/popup</Type> 26 <Type>http://specs.openid.net/extensions/ui/1.0/lang-pref</Type> 27 <Type>http://specs.openid.net/extensions/ui/1.0/icon</Type>--%> 28 <URI><%=new Uri(Request.Url, Response.ApplyAppPathModifier("~/OpenId/Provider"))%></URI> 29 </Service> 30 <% if (ViewData["OPIdentifier"] == null) { %> 31 <Service priority="20"> 32 <Type>http://openid.net/signon/1.0</Type> 33 <Type>http://openid.net/extensions/sreg/1.1</Type> 34 <Type>http://axschema.org/contact/email</Type> 35 <URI><%=new Uri(Request.Url, Response.ApplyAppPathModifier("~/OpenId/Provider"))%></URI> 36 </Service> 37 <% } %> 38 </XRD> 39 </xrds:XRDS>
里面含有少許邏輯,簡單講,就是如果輸入的地址不帶用戶,則使用第16行,然后不使用30到37行,反之則使用18行,也使用30到37行.
OpenId使用Yadis協議.如同Wsdl是WebServices的描述文檔一樣,Xrds是此協議的描述文檔.Tpye元素描述技術版本,如http://specs.openid.net/auth/2.0/server表示使用OpenId2.0版本,Uri元素描述實際的認證地址:http://192.168.0.217:85/OpenId/Provider
參考:
在OpenId2.0之前的版本(1.0,1.1),用戶在A系統只能通過http://192.168.0.217:85/user/bob進行登錄,.在2.0之后的版本中可以直接使用http://192.168.0.217:85,即認證中心地址進行登錄.這種能力官方名稱為:OP-driven identifier selection.只需輸入認證中心地址,然后去認證中心進行用戶與密碼的輸入.
這個改進給我最大的感覺是在A中輸入方便了一點,但在參考文中卻還提到了能解決標識難以記憶的問題,標准文檔中"選擇"的概念在這里也沒有體現出來.估計我還是經驗不足(其實是完全沒有經驗).其實在使用中,使用這兩個地址登錄還是有微小區別:使用http://192.168.0.217:85,則跳轉到85后你可以使用任意用戶登錄,而使用http://192.168.0.217:85/user/bob,則跳轉后只能使用bob登錄.
參考:
Users vs. Identity Providers in OpenID
Directed Identity vs Identifier Select
小結一下,當用戶在A系統輸入http://192.168.0.217:85或http://192.168.0.217:85/user/bob並提交后,A系統訪問P系統並獲取了Xrds,然后跟據里面的內容將瀏覽器跳轉到http://192.168.0.217:85/OpenId/Provider
A系統也是有Xrds的,通過在首頁加上一個X-XRDS-Location頭來發布自己Xrds路徑
public ActionResult Index() { Response.AppendHeader( "X-XRDS-Location", new Uri(Request.Url, Response.ApplyAppPathModifier("~/Home/xrds")).AbsoluteUri); return View("Index"); } public ActionResult Xrds() { return View("Xrds"); }
A系統Xrds文檔如下
<%@ Page Language="C#" Inherits="System.Web.Mvc.ViewPage" ContentType="application/xrds+xml" %><?xml version="1.0" encoding="UTF-8"?> <%-- This page is a required for relying party discovery per OpenID 2.0. It allows Providers to call back to the relying party site to confirm the identity that it is claiming in the realm and return_to URLs. This page should be pointed to by the 'realm' home page, which in this sample is default.aspx. --%> <xrds:XRDS xmlns:xrds="xri://$xrds" xmlns:openid="http://openid.net/xmlns/1.0" xmlns="xri://$xrd*($v*2.0)"> <XRD> <Service priority="1"> <Type>http://specs.openid.net/auth/2.0/return_to</Type> <%-- Every page with an OpenID login should be listed here. --%> <%-- We use the Authenticate action instead of Login because Authenticate is the action that receives OpenId assertions. --%> <URI><%=new Uri(Request.Url, Response.ApplyAppPathModifier("~/user/authenticate"))%></URI> </Service> </XRD> </xrds:XRDS>
意思很明白,就是告訴P系統當驗證成功后返回A系統的http://192.168.0.217:87/User/Authenticate
另外,Xrds是通過修改文檔頭來完成自我標識,如同將Json數據的文檔標識修改為application/json一樣,當A系統向P系統訪問時會將標識修改為application/xrds+xml.
參考:
DotNetOpenAuth and X-XRDS-Location header
C.用戶驗證
接上所述,當瀏覽器跳轉到http://192.168.0.217:85/OpenId/Provider后,執行的是OpenId類的Provider方法,代碼如下,有刪節
IRequest request = OpenIdProvider.GetRequest(); if (request != null) { // Some requests are automatically handled by DotNetOpenAuth. If this is one, go ahead and let it go. if (request.IsResponseReady) { return OpenIdProvider.PrepareResponse(request).AsActionResult(); } return this.ProcessAuthRequest(); }
如果當前是未驗證狀態,則執行ProcessAuthRequest方法,如下,有刪節
// Try responding immediately if possible. ActionResult response; if (this.AutoRespondIfPossible(out response)) { return response; } // We can't respond immediately with a positive result. But if we still have to respond immediately... if (ProviderEndpoint.PendingRequest.Immediate) { // We can't stop to prompt the user -- we must just return a negative response. return this.SendAssertion(); } return this.RedirectToAction("AskUser");
驗證模式分為兩種,交互式驗證表示有顯式的登錄頁面,有諸如用戶手動輸入用戶名密碼等交互行為,反之就是即時驗證,所需信息都被包含在請求中,系統處理后立即返回結果.上面的代碼中兩個If就是嘗試處理即時請求.否則跳轉到AskUser方法.
注意,這里使用了RedirectToAction進行跳轉,也就是說,當執行到這里時,瀏覽器會再次跳轉.此方法簽名如下
[Authorize] public ActionResult AskUser()
可以看到,此方法需用戶登錄后才能執行.於是瀏覽器會再次跳轉.這是第三次跳轉了.下面為Account類的LogOn方法
if (!this.ValidateLogOn(userName, password)) { return View(); } this.FormsAuth.SignIn(userName, rememberMe); if (!string.IsNullOrEmpty(returnUrl)) { return Redirect(returnUrl); } else { return RedirectToAction("Index", "Home"); }
ValidateLogOn就是驗證用戶名密碼處,本例使用的Membership,實際應用中會進行替換.
FormsAuth.SignIn是將用戶登錄狀態持久到Cookie中,代碼如下
public void SignIn(string userName, bool createPersistentCookie) { FormsAuthentication.SetAuthCookie(userName, createPersistentCookie); }
可以看到,其就是FormsAuthentication類的簡單封裝.
成功后,瀏覽器將頁面再次跳轉到AskUser處.這是第四次跳轉.代碼如下,有刪節
if (!ProviderEndpoint.PendingAuthenticationRequest.IsDirectedIdentity && !this.UserControlsIdentifier(ProviderEndpoint.PendingAuthenticationRequest)) { return this.Redirect(this.Url.Action("LogOn", "Account", new { returnUrl = this.Request.Url })); } this.ViewData["Realm"] = ProviderEndpoint.PendingRequest.Realm; return this.View();
這里就是上文所提到的兩種登錄地址使用區別的實現.不帶用戶名的地址,IsDirectedIdentity屬性為True,則將跳過此If執行.
本View將會提示用戶是否登錄A系統.如果點擊確定,則接下來會相繼執行AskUserResponse方法與SendAssertion方法.其基本就是構建OpenId的各個返回值,最終瀏覽器會再次跳轉至A系統提交處,即Authenticate方法處進行再次驗證.此方法說明上文已表,恕不贅述.
這里有個細節問題.在P系統中經過多次跳轉后,在沒有附帶相關信息的前提下,如何能跳轉回A系統正確的地址呢?答案是在A系統首次跳轉P系統時就將地址附帶了過來,存儲中用戶的信息區中.
D.其它需要注意的問題
a.關於Ssl安全
OpenId支持Ssl傳輸協議.在web.config中將其配置項打開即可,貌似還有一個與之相關的類:AnonymousIdentifierProvider,在Global.asax中也對其進行了配置
protected void Application_BeginRequest(object sender, EventArgs e) { InitializeBehaviors(); } private static void InitializeBehaviors() { if (DotNetOpenAuth.OpenId.Provider.Behaviors.PpidGeneration.PpidIdentifierProvider == null) { lock (behaviorInitializationSyncObject) { if (DotNetOpenAuth.OpenId.Provider.Behaviors.PpidGeneration.PpidIdentifierProvider == null) { DotNetOpenAuth.OpenId.Provider.Behaviors.PpidGeneration.PpidIdentifierProvider = new Code.AnonymousIdentifierProvider(); DotNetOpenAuth.OpenId.Provider.Behaviors.GsaIcamProfile.PpidIdentifierProvider = new Code.AnonymousIdentifierProvider(); } } } }
我只搗鼓了幾分鍾,沒有將其Ssl功能測試成功,放棄了.
b.相關信息存儲模式
ProviderEndpoint對象是本框架的關鍵對象之一,是P系統與A系統的關聯對象.按道理每個用戶登錄都有其自身的數據,應該都擁有一個此對象的實例.但此對象大部分屬性與方法卻是靜態的.讀了注釋才明白,雖然是靜態的,但其取出的數據仍與具體用戶相關.每個用戶自己的資料都保存在Session中.
E.個人總結
其實跟據上面的學習,我個人感覺OpenId也是比較好理解的,其都是基於Cookie的認證模式.對於上面的中心P與網站A,
a.如果A已登錄,則訪問A時正常運行.
b.如果A未登錄P已登錄,登錄A時跳轉至P並詢問是否允許在A上登錄,如果是,則在A上登錄.
c.如果A未登錄P也未登錄,登錄A時跳轉到P並要求填入相關信息.然后詢問是否允許在A上登錄,如果是,則在A上登錄.
其實使用Cookie是只實現OpenId眾多方式中的一種,但是一來網站上主要使用基於Cookie的驗證,二來這種方式使用也最廣泛,就沒有再去研究其它小眾了.
在現實使用中一般都會面對現有系統的改造,認證與授權的聯合使用等問題.這個就只能修改相應實現,具體問題具體分析了.或者以后再繼續研究OAuth
PS:以上純屬一家之言,如果不對的地方還請多多包涵,多多指教
參考的文章: