ASP.NET MVC 3 使用 DotNetOpenAuth 实现SSO

[原]ASP.NET MVC 3 使用 DotNetOpenAuth 实现SSO

转载注明作者及出处,谢谢

听到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>

对于本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主要结构就是上面这些,文档结构见下图(其中选中的文件是新增的,其他的都是项目模板自带的):

ASP.NET MVC 3 使用 DotNetOpenAuth 实现SSO

二.SsoRP 这个RP和人品没有太大关系,作用为SSO的消费者

文档结构如下:

ASP.NET MVC 3 使用 DotNetOpenAuth 实现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)。

ASP.NET MVC 3 使用 DotNetOpenAuth 实现SSO

登录完成后,根据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会如期而至。

ASP.NET MVC 3 使用 DotNetOpenAuth 实现SSO

SsoRP示例有两个,一个是纯MVC模式的,一个是使用MVC + WebForms模式的。

DotNetOpenAuth的资料现在貌似很少,个人对其的研究现在还处于Step by Step的阶段,只能说跟着官方的示例能做出一个MVC实现,但是对于很多具体的原理还是相当不熟悉,这示例只能是解决有和没有的问题,本文中的谬误还望大家不吝赐教,希望有人能发更多有深度的资料。

示例程序