转载注明作者及出处,谢谢
听到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(真不明白为什么被墙了,想看的FQ吧,源码托管地址)这么个东西了,看介绍似乎就涵盖了我想要的一切功能!
文档可能是最让人又爱又恨的东西了,自已不愿意写文档,但都希望使用别人的东西时有完备的文档。
前两天在学习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:XRDS xmlns: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:XRDS xmlns: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>
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实现,但是对于很多具体的原理还是相当不熟悉,这示例只能是解决有和没有的问题,本文中的谬误还望大家不吝赐教,希望有人能发更多有深度的资料。