×
Namespaces

Variants
Actions

使用OAuth保护供Windows Phone使用的REST service

From Nokia Developer Wiki
Jump to: navigation, search
WP Metro Icon Web.png
SignpostIcon XAML 40.png
SignpostIcon WP7 70px.png
Article Metadata

代码示例
兼容于
文章
WS_YiLunLuo 在 09 Feb 2012 创建
最后由 hamishwillee 在 27 Jun 2013 编辑

Contents

简介

Windows Phone作为一个设备,它并不是孤立的,而是和整个世界联系在一起的。很多程序也往往不是孤立的,而是要和外部世界打交道的。这其中一个常见的做法是使用web service。

另一方面,很多服务都需要身份验证,而不是随随便便什么人都能使用。目前比较流行的一种为REST service提供身份验证的方式是OAuth,等一下我们会详细说明。

本文将给大家一个示例,看看如何创建一个能够被Windows Phone使用的REST service,并且用OAuth保护它。我们使用WCF Web API编写REST service,使用Windows Azure ACS验证身份,但是REST绝不仅限于WCF平台,你可以在Java,PHP,等多种平台上使用,支持OAuth的服务业很多很多。

注意本文仅仅讨论Windows Phone的本机应用程序,也就是使用.NET编写的那种。基于浏览器的HTML程序不在本文的讨论范围之内,我们可能会写另外一篇文章讨论它。

你可以自这里下载源代码:File:WPAcsOAuth.zip

程序运行时的界面很简单,重点在于后台逻辑。

WPOAuthRestScreenshot.png

想要运行程序,请记得将LoginPage.xaml.cs中的 [your ACS namespace]替换成自己的namespace:

       private static string acsUrl = "https://[your ACS namespace].accesscontrol.windows.net/v2/metadata/IdentityProviders.js?protocol=javascriptnotify&realm=http://localhost:48003/&version=1.0";

并且将StoryService.cs中的[your ACS key]替换成自己的key:

       private static string acsKey = "[your ACS key]";

现在不知道namespace和key是什么东西没有关系,看下去你就明白了。

REST service

REST service简介

现在世界上有两种web service,SOAP和REST。考虑到很多平台(例如iPhone和Andriod)都没有直接提供对SOAP的支持,以及SOAP的复杂性,为了让你的服务能支持更多的平台,通常我们会选择使用REST。

所谓的REST service,简单说来就是使用HTTP拓扑,将服务器上的资源暴露给客户端的服务,有些服务也允许客户端修改服务器上的资源。事实上一个普通的网站本身就是一个REST service,它暴露的资源是网页。当然资源远远不仅限于网页,飞机票,书,电影,等等,全都可以看成资源。请注意REST是面向资源的,而SOAP是面向操作的。

举个简单的例子,GetTicketByDate是操作,请不要在REST service中出现这样的方法。而ticket本身是资源,针对这个资源,客户端可以发送如下形式的HTTP request访问:

  • GET http://[your domain]/tickets
返回全部ticket。
  • GET http://[your domain]/tickets/1
返回ID为1的ticket。
  • GET http://[your domain]/tickets?startdate=1/31/2012
返回自1/31/2012以来全部的ticket。
  • POST http://[your domain]/tickets
创建一个新的ticket。
  • PUT http://[your domain]/tickets/1
修改一个ID为1的ticket。
  • DELETE http://[your domain]/tickets/1
删除ID为1的ticket。

简单说来就是针对同一个资源,你可以发送各种各样的HTTP请求。

使用WCF Web API创建REST service

你可以在各种平台上创建REST service。既然本文针对Windows Phone上的.NET平台,我们现在也假设你在service上同样使用.NET平台。在这边你有三种选择:使用generic handler,使用ASP.NET MVC controller,或者使用WCF。它们各有优缺点吧,时间关系我们不讨论什么情况下用哪个。本文使用WCF Web API创建REST service。为此你需要自这里下载Web API。目前的最新版是preview 6。

然后就可以创建你的service了。你可以从一个空的ASP.NET项目开始,或者把service添加到现成的ASP.NET项目中。如果你是一个用惯了WCF SOAP service的人,请注意使用WCF Web API有较大的不同。

第一件事是添加引用,除了标准的WCF引用之外(System.ServiceModel.dll, System.ServiceModel.Activation.dll, System.ServiceModel.Web.dll),你还需要引用Web API的assembly。本文的示例只使用到了Microsoft.ApplicationServer.Http.dll和System.Net.Http.dll,你要是使用了更多的功能,请添加更多引用。

接下来,在Global.asax中注册route,这是WCF 4中的一个新功能,它允许你使用标准的ASP.NET URL routing来注册service的base URI,从而不需要.svc扩展名。以下代码注册base URI为http://[your domain]/stories/。

        public static void RegisterRoutes(RouteCollection routes)
 
{
 
routes.Add(new ServiceRoute(
 
"stories",
 
new HttpServiceHostFactory(),
 
typeof(StoryService)));
 
}
 
 
protected void Application_Start(object sender, EventArgs e)
 
{
 
RegisterRoutes(RouteTable.Routes);
 
}

接下来你可以创建一个类,用来实现service。REST和SOAP的一个很大的不同点是它不具备诸如service contract和operation contract这样的契约,因为REST是面向资源,不是面向操作的。若是你擅长编写WCF SOAP service,请一定要讲思路逆转过来。

在实现服务的类中,你可以定义一些方法,这些方法的名字并不重要,真正重要的HTTP methodUriTemplate,你可以用WebGet指定HTTP method为GET,想要使用其它method,你必须使用WebInvoke,并且显示指定一个method:

[WebInvoke(Method = "PUT")]

至于UriTemplate,它指定的是相对路径。如果为空,就意味着base URI会直接被映射到该方法,这通常被用于返回全部资源,或者添加新的资源。例如,当客户端发送请求到http://[your domain]/stories/,你可能希望返回全部story,那么就可以保持UriTemplate为空。若是UriTemplate被指定为非空字符串,将会对应于你的CLR方法中的参数。例如:

[WebInvoke(Method = "PUT", UriTemplate = "{id}?commit={commit}")]

public HttpResponseMessage Put(HttpRequestMessage request, string id, bool? commit)

在这边id是URI的一部分,而commit是一个query string,客户端可以发送请求至http://[your domain]/stories/1?commit=true。

说了那么多,为了简单起见,我们的sample只实现最基本的GET请求,其它请求毕竟是类似的。以下代码实现了当客户端发送GET请求至http://[your domain]/stories/时,返回一个xml文件代表故事列表的服务。具体如何实现服务并不重要,请注意WebGet和UriTemplate,还有如何创建一个符合HTTP标准的response。在这个response中,我们将status code设成200 OK,将content-type设置成text/xml,这些是每个response都必须做好的,否则可能会造成不兼容的问题。

public class StoryService
 
{
 
[WebGet(UriTemplate = "")]
 
public HttpResponseMessage Get(HttpRequestMessage request)
 
{
 
XElement stories = new XElement("stories",
 
new XElement("story", new XAttribute("name", "My first story")),
 
new XElement("story", new XAttribute("name", "My second story")),
 
new XElement("story", new XAttribute("name", "My third story"))
 
);
 
HttpResponseMessage response = new HttpResponseMessage();
 
response.StatusCode = HttpStatusCode.OK;
 
response.Content = new StringContent(stories.ToString(), Encoding.UTF8, "text/xml");
 
return response;
 
}

用惯.NET平台和WCF SOAP service的人可能会喜欢使用一个serializer,让你的方法返回一个CLR object。这在Web API中也是可以做到的,不过本文就不再讨论了。你可以自这里找到更多信息。

最后,为了使用service route,你必须启用ASP.NET compatibility mode。这很简单,只要在web.config中添加以下内容即可。

<system.serviceModel>
 
<serviceHostingEnvironment aspNetCompatibilityEnabled="true"/>
 
</system.serviceModel>

很多情况下你不需要对service作任何更多诸如binding和endpoint之类的配置,因为Web API封装好了很多配置。当然想手工配置也是可以的,不过本文就不讨论了。

在Windows Phone上访问REST service

访问REST service很简单,你可以使用WebClient或者HttpWebRequest。现在我们使用WebClient,之后会看到如何使用HttpWebRequest。

private void RetrieveStoriesAsync()
 
{
 
WebClient webClient = new WebClient();
 
webClient.DownloadStringCompleted += new DownloadStringCompletedEventHandler(StoriesRetrieved);
 
webClient.DownloadStringAsync(new Uri(serviceBaseAddress));
 
}
 
 
private void StoriesRetrieved(object sender, DownloadStringCompletedEventArgs e)
 
{
 
if (e.Error != null)
 
{
 
MessageBox.Show("Error downloading stories.");
 
}
 
 
try
 
{
 
this.storiesListBox.Items.Clear();
 
XElement stories = XElement.Parse(e.Result);
 
foreach (var story in stories.Elements())
 
{
 
this.storiesListBox.Items.Add(story.Attribute("name").Value);
 
}
 
}
 
catch (XmlException)
 
{
 
MessageBox.Show("Unexpected result from service.");
 
}
 
}

需要注意的是Windows Phone只支持异步方式访问网络资源。

在获取服务返回的数据后,你可以做任何事,例如使用data binding,等等。本文就简单把数据添加到ListBox中了。

WPRestScreenshot.png

OAuth

接下来,我们看看如何使用OAuth保护REST service。

Oauth和federation 简介

想要验证用户的身份,通常会使用用户名密码,因为这是大众所最能接受的一种方式,其它方式,例如证书,不适用于普通用户,只适用用专业人士。很自然,没有谁会愿意去为每个服务记一套专门的用户名和密码,因此现在主流的身份验证方式是和第三方合作,和那些广为流传的身份验证提供商合作。我们把它们成为security token service(STS)。我们的服务信任第三方的STS,相信它们会帮助我们做好身份验证,因此我们自己就不需要维护一个庞大的系统来验证用户了。这个身份验证外包的过程被称作federation。

请注意在federation中所涉及到的组件:

  • 身份验证提供商,通常被称作STS
  • 我们自己的服务,通常被称作relying party
  • 客户端

Federation也可以用于非REST场合,不过这个今天我们就不做讨论了。

在国外,流行的身份验证提供商包括有Windows Live ID, Google ID, Yahoo ID, Facebook ID,等等。在国内,除了Facebook ID因为GFW的原因无法使用,其它都能正常使用。此外国内还有自己的流行的身份验证提供商,例如QQ帐号,新浪微博帐号,等等。

由于流行的身份验证提供商不止一家,为了让应用程序开发人员能够使用统一的API让自己的程序支持所有提供商,国际标准就出现了。这个标准被称作OAuth,目前被包括上面提到的国内外各大身份验证提供商广为支持。OAuth适用于保护REST service,针对非REST service也有其它诸如WS-Federation之类的标准。

今天我们仅仅讨论一个场景:在富客户端程序,例如Windows Phone的.NET程序,中访问REST service,如何使用OAuth对service进行保护。大致过程如下:

  • 配置STS,让你的REST service和STS建立起相互信任的关系。
  • 在客户端程序中嵌入一个浏览器,让用户在身份验证提供商的登录网站上登录。
  • 在客户端程序处理JavaScript notify,获取身份验证提供商返回给我们程序的信息,这个信息被称作token。
  • 在访问REST service时,将token放在Authorization HTTP header中。
  • 在REST service中验证token,确保它是你信任的STS颁发的,并且确保用户已经被STS验证。

使用Windows Azure ACS保护REST service

本文使用的STS是Windows Azure ACS。ACS是一个比较特别的STS,它本身并不直接做身份验证,而是将身份验证的工作交给诸如Windows Live ID和Google ID这样的第三方来进行。ACS不仅支持OAuth,还支持WS-Federation。当然本文中我们会使用OAuth,因为WS-Federation更适合于SOAP。类似的代码也可以应用于其它支持OAuth的身份验证供应商。

配置ACS

为了使用ACS,首先你需要拥有一个Windows Azure帐号。你可以使用免费的试用版帐号。有关Windows Azure帐号的更多信息,请参考官方网站上的详细信息,这里就不具体描述了。

接下来,需要创建一个ACS namespace,并且配置它。为了节省篇幅,本文主要提供文档上的链接,而不再进行一步一步地仔细说明。那些文档已经有了很详尽的说明,本文只是针对我们的场景中和文档不同的地方进行特殊说明。

请参考这篇文档创建一个ACS namespace。Namespace就相当于你自己程序的帐号,注意是你的程序的帐号,而不是用户使用的帐号。这个帐号让你的程序和ACS建立起了信任关系。创建好之后,请选中该namespace,并且点击ribbon中的Access Control Service,这会打开针对该namespace的管理界面。

AcsPortalScreenshot.png

接下来,你需要添加identity providers,也就是你所信任的身份验证提供商。使用ACS,你能够使用国外常用的身份验证提供商,其中除了Facebook,在国内也能使用。请任意选择你想要使用的identity provider。默认Windows Live ID就已经添加好了。想添加其它的,请参考这些文档以获得详细步骤:GoogleYahooFacebook。ADFS通常用于企业环境,而Windows Phone程序通常是面向消费者的,因此很少会使用。

下一步要做的是配置relying party,简单说来就是你的REST service程序。请参考这篇文档上提供的详细步骤。在这里Realm请输入你的service的domain地址,例如http://localhost:48003/,这意味着你的service的域名。Return URL可以使用这样的地址:http://localhost:48003/FederationCallback,这仅仅在passive redirect的场景中会使用,当用户成功通过某个identity provider登录之后,ACS会向这个地址发送一个POST请求通知你。本文不会讨论passive redirect,所以你可以随意写这个地址。在token format中,请选择SWT。SAML通常和WS-Federation一起使用,而SWT则通常和OAuth一起使用。其它就和文档上说的一模一样了。

在上一步中,你要特别注意Token Signing Keys。

AcsKeyScreenshot.png

点击上面的链接会进入到这篇文档所说的页面。在这里请记住这个Key,今后在我们的service中会用到它。这个密钥是ACS和你的service共享的秘密,也正是这个密钥确保了非ACS的程序无法伪造token,所以请绝对不要将它告诉任何无关人员,要像保护你自己的密码一样保护它。绝对不要将这个密钥放在客户端程序中,只有service和ACS才能知道它。

最后一步是创建rule,在ACS中,你可以创建一系列规则将身份验证提供商提供的信息(被称作claim)转换成你自己的程序所需要的信息。例如,你可以创建一些规则,将某几个Google ID映射到administrator这个role claim中,这样一来,你的程序就会知道当前登录的用户是不是管理员,而不需要自己创建数据库存储这些信息。我们的例子使用最简单的规则,请参考这篇文档上的generating rules章节,这会自动生成一些规则,直接把身份验证提供商提供的claim传达给你的程序。这对身份验证而言已经足够了,若是你需要做其它事情,例如判定角色,权限控制,等等,需要创建更高级的规则。

这样一来,配置就完成了。你可能觉得有很多东西需要配置,有很多文档要看,可实际操作其实是很简单的,请尝试一下就知道了。

在Windows Phone中访问ACS

现在可以在Windows Phone中访问ACS了。和访问其它OAuth服务一样,为了让用户登录,我们不能够直接在程序里面提供一个文本框要求输入用户名密码,这样的做法是黑客行为,是绝对不允许的。用户信任的是身份验证提供商,他们的用户名和密码只有那些提供商才能获得,我们信任那些提供商,所以我们信任它们会帮我们进行身份验证,并且返回正确的用户信息,但是用户的登录过程对我们的程序而言是透明的。

为了给用户提供一个登录界面(创建一个新的页面,例如叫LoginPage.xaml),我们必须让用户连接到身份验证提供商的登录网站上去,在Windows Phone中可以使用WebBrowser控件:

<phone:WebBrowser x:Name="loginBrowser" IsScriptEnabled="True" ScriptNotify="LoginBrowser_ScriptNotify" Visibility="Collapsed"/>

WebBrowser控件的ScriptNotify事件指的是当一个网页调用JavaScript的window.external.notify方法时,会通知host程序,也就是我们自己的程序,从而实现和网页交互的功能。ACS在用户成功登录后正是会调用window.external.notify,以通知我们的程序并返回一个代表用户的token。请注意该事件目前仅在Windows Phone和iPhone/Android之类的设备上有效,在大多数桌面版的浏览器中还不支持。

由于ACS可以让用户使用多个身份验证提供商,我们首先显示一个列表,让用户选择一个提供商进行登录,然后再把WebBrower的地址设置成该供应商的登录网站。为此我们先将WebBrowser隐藏掉(Visibiltiy=”Collapsed”)。

我们用一个ListBox显示提供商列表:

            <ListBox x:Name="idpList" SelectionChanged="IdpList_SelectionChanged">
 
<ListBox.ItemTemplate>
 
<DataTemplate>
 
<TextBlock Text="{Binding Name}" FontSize="32"/>
 
</DataTemplate>
 
</ListBox.ItemTemplate>
 
</ListBox>

供应商列表可以发送一个GET请求至https://[namespace].accesscontrol.windows.net/v2/metadata/IdentityProviders.js?protocol=javascriptnotify&realm=[realm]&version=1.0获得。其中namespace和realm就是你之前在ACS portal上注册时输入的信息。可以看到事实上这是ACS提供的一个REST service。

注意到该请求的query string中有protocol=javascriptnotify,这意味着用户登录完成后ACS将会使用JavaScript notify来通知我们的程序。

以下是一个ACS返回的数据的例子,注意到这是一个JSON格式的数据,针对每一个供应商,都有一条专门的纪录,包括有LoginUrl和LogoutUrl之类的信息。其中LoginUrl全都包括有wtrealm=https%3a%2f%2faccesscontrol.windows.net%2f这样的query string,这意味着当用户成功登录之后,供应商会向ACS发送一个POST请求,而ACS在处理了这个请求之后,会使用JavaScript notify来通知我们的程序。

       [{
           "Name":"Windows Live™ ID",
           "LoginUrl":"https://login.live.com/login.srf?wa=wsignin1.0&wtrealm=https%3a%2f%2faccesscontrol.windows.net%2f&wreply=https%3a%2f%2fwpacsoauth.accesscontrol.windows.net%3a443%2fv2%2fwsfederation&wp=MBI_FED_SSL&wctx=pr%3djavascriptnotify%26rm%3dhttp%253a%252f%252f127.0.0.1%253a81%252f%2b",
           "LogoutUrl":"https://login.live.com/login.srf?wa=wsignout1.0",
           "ImageUrl":"",
           "EmailAddressSuffixes":[]
       },
       {
           "Name":"Google",
           "LoginUrl":"https://www.google.com/accounts/o8/ud?openid.ns=http%3a%2f%2fspecs.openid.net%2fauth%2f2.0&openid.mode=checkid_setup&openid.claimed_id=http%3a%2f%2fspecs.openid.net%2fauth%2f2.0%2fidentifier_select&openid.identity=http%3a%2f%2fspecs.openid.net%2fauth%2f2.0%2fidentifier_select&openid.realm=https%3a%2f%2fwpacsoauth.accesscontrol.windows.net%3a443%2fv2%2fopenid&openid.return_to=https%3a%2f%2fwpacsoauth.accesscontrol.windows.net%3a443%2fv2%2fopenid%3fcontext%3dpr%253djavascriptnotify%2526rm%253dhttp%25253a%25252f%25252f127.0.0.1%25253a81%25252f%252b%26provider%3dGoogle&openid.ns.ax=http%3a%2f%2fopenid.net%2fsrv%2fax%2f1.0&openid.ax.mode=fetch_request&openid.ax.required=email%2cfullname%2cfirstname%2clastname&openid.ax.type.email=http%3a%2f%2faxschema.org%2fcontact%2femail&openid.ax.type.fullname=http%3a%2f%2faxschema.org%2fnamePerson&openid.ax.type.firstname=http%3a%2f%2faxschema.org%2fnamePerson%2ffirst&openid.ax.type.lastname=http%3a%2f%2faxschema.org%2fnamePerson%2flast",
           "LogoutUrl":"",
           "ImageUrl":"",
           "EmailAddressSuffixes":[]
       },
       {
           "Name":"Yahoo!",
           "LoginUrl":"https://open.login.yahooapis.com/openid/op/auth?openid.ns=http%3a%2f%2fspecs.openid.net%2fauth%2f2.0&openid.mode=checkid_setup&openid.claimed_id=http%3a%2f%2fspecs.openid.net%2fauth%2f2.0%2fidentifier_select&openid.identity=http%3a%2f%2fspecs.openid.net%2fauth%2f2.0%2fidentifier_select&openid.realm=https%3a%2f%2fwpacsoauth.accesscontrol.windows.net%3a443%2fv2%2fopenid&openid.return_to=https%3a%2f%2fwpacsoauth.accesscontrol.windows.net%3a443%2fv2%2fopenid%3fcontext%3dpr%253djavascriptnotify%2526rm%253dhttp%25253a%25252f%25252f127.0.0.1%25253a81%25252f%252b%26provider%3dYahoo!&openid.ns.ax=http%3a%2f%2fopenid.net%2fsrv%2fax%2f1.0&openid.ax.mode=fetch_request&openid.ax.required=email%2cfullname%2cfirstname%2clastname&openid.ax.type.email=http%3a%2f%2faxschema.org%2fcontact%2femail&openid.ax.type.fullname=http%3a%2f%2faxschema.org%2fnamePerson&openid.ax.type.firstname=http%3a%2f%2faxschema.org%2fnamePerson%2ffirst&openid.ax.type.lastname=http%3a%2f%2faxschema.org%2fnamePerson%2flast",
           "LogoutUrl":"",
           "ImageUrl":"",
           "EmailAddressSuffixes":[]
       }]

具体如何在程序中向ACS发送请求,只要用标准的HttpWebRequest就行了。解析JSON我们用DataContractJsonSerializer,为此首先需要建一个满足response格式的类:

    public class IdentityProvider
 
{
 
public string Name { get; set; }
 
public string LoginUrl { get; set; }
 
public string LogoutUrl { get; set; }
 
public string ImageUrl { get; set; }
 
public string[] EmailAddressSuffixes { get; set; }
 
}

然后发送请求到ACS的地址,用DataContractJsonSerializer解析结果,设置好ListBox的数据源,就可以了。

            HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create(acsUrl);
 
request.BeginGetResponse((result) =>
 
{
 
HttpWebResponse response = (HttpWebResponse)request.EndGetResponse(result);
 
using (Stream responseStream = response.GetResponseStream())
 
{
 
DataContractJsonSerializer serializer = new DataContractJsonSerializer(typeof(IdentityProvider[]));
 
IdentityProvider[] idProviders = (IdentityProvider[])serializer.ReadObject(responseStream);
 
this.Dispatcher.BeginInvoke(() =>
 
{
 
this.idpList.ItemsSource = idProviders;
 
});
 
}
 
}, null);

现在你运行LoginPage过一会儿会看到身份验证供应商列表。

IdentityProviderListScreenshot.png

以上是ACS特有的功能,因为ACS允许你使用多个身份验证提供商,接下来我们说的则是标准的OAuth的过程了。

当用户自ListBox中选择了一个供应商后,我们显示WebBrowser,并且将它的地址设置成选中的供应商的LoginUrl。

现在再运行程序,你就可以看到该供应商提供的登录界面了。

GoogleSigninPageScreenshot.png

若是用户登录失败,供应商的网站自会处理,我们不需要担心任何问题。而假设登录成功,通常供应商会显示一个页面,提醒用户是否信任ACS,通常这个页面只会提示一次。

GooleConfirmScreenshot.png

如果用户选择信任,浏览器会自动跳转到ACS的页面,而那个ACS的页面会使用JavaScript notify来通知我们的程序,WebBrowser的ScriptNotify事件会被触发,其中的EventArgs的Value属性是一个string,也就是网页想传达给我们的信息,在这个情况下ACS传达给我们的是包含了一个SWT token的JSON字符串,因为我们之前在portal上设置了要求ACS使用SWT。

剩下的事情自然就是解析这个JSON字符串和SWT token了。解析JSON的过程和之前一样,我们不再复述。JSON格式如下:

   {
       "appliesTo":"http://localhost:48003/",
       "context":null,
       "created":1328068645,
       "expires":1328069245,
       "securityToken":"<?xml version="1.0" encoding="utf-16"?><wsse:BinarySecurityToken wsu:Id="uuid:1739731b-16b8-42fb-bd49-e2f5a697eb3e" ValueType="http://schemas.xmlsoap.org/ws/2009/11/swt-token-profile-1.0&quot; EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary&quot; xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd&quot; xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd&quot;>aHR0cCUzYSUyZiUyZnNjaGVtYXMueG1sc29hcC5vcmclMmZ3cyUyZjIwMDUlMmYwNSUyZmlkZW50aXR5JTJmY2xhaW1zJTJmZW1haWxhZGRyZXNzPXMyZmF3ZSU0MGdtYWlsLmNvbSZodHRwJTNhJTJmJTJmc2NoZW1hcy54bWxzb2FwLm9yZyUyZndzJTJmMjAwNSUyZjA1JTJmaWRlbnRpdHklMmZjbGFpbXMlMmZuYW1lPWErYSZodHRwJTNhJTJmJTJmc2NoZW1hcy54bWxzb2FwLm9yZyUyZndzJTJmMjAwNSUyZjA1JTJmaWRlbnRpdHklMmZjbGFpbXMlMmZuYW1laWRlbnRpZmllcj1odHRwcyUzYSUyZiUyZnd3dy5nb29nbGUuY29tJTJmYWNjb3VudHMlMmZvOCUyZmlkJTNmaWQlM2RBSXRPYXdsbkxid1pTaDdyOTE5Q05uOFVyM0VTbmNvelMybTBKQTAmaHR0cCUzYSUyZiUyZnNjaGVtYXMubWljcm9zb2Z0LmNvbSUyZmFjY2Vzc2NvbnRyb2xzZXJ2aWNlJTJmMjAxMCUyZjA3JTJmY2xhaW1zJTJmaWRlbnRpdHlwcm92aWRlcj1Hb29nbGUmQXVkaWVuY2U9aHR0cCUzYSUyZiUyZmxvY2FsaG9zdCUzYTQ4MDAzJTJmJkV4cGlyZXNPbj0xMzI4MDY5MjQ1Jklzc3Vlcj1odHRwcyUzYSUyZiUyZndwYWNzb2F1dGguYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldCUyZiZITUFDU0hBMjU2PWdKY2g0a3dCMm5SWkdPWUpyU1kwamZoT1RNdEs4U3FiMlFSUXJ3cG1nNnclM2Q=</wsse:BinarySecurityToken>","tokenType":"http://schemas.xmlsoap.org/ws/2009/11/swt-token-profile-1.0"
   }

可以看到,其中的securityToken是xml编码后的结果。使用HttpUtility.HtmlDecode解析之后得到一个base64编码的token:

<wsse:BinarySecurityToken wsu:Id="uuid:608d58a7-35cd-49fe-9e82-79f78e87903d" ValueType="http://schemas.xmlsoap.org/ws/2009/11/swt-token-profile-1.0" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">一个base64 token,见上</wsse:BinarySecurityToken>

再次使用Convert.FromBase64String解码,得到了一个明文的SWT token,这也是一般的SWT token的格式。

http%3a%2f%2fschemas.xmlsoap.org%2fws%2f2005%2f05%2fidentity%2fclaims%2femailaddress=s2fawe%40gmail.com&http%3a%2f%2fschemas.xmlsoap.org%2fws%2f2005%2f05%2fidentity%2fclaims%2fname=a+a&http%3a%2f%2fschemas.xmlsoap.org%2fws%2f2005%2f05%2fidentity%2fclaims%2fnameidentifier=https%3a%2f%2fwww.google.com%2faccounts%2fo8%2fid%3fid%3dAItOawlnLbwZSh7r919CNn8Ur3ESncozS2m0JA0&http%3a%2f%2fschemas.microsoft.com%2faccesscontrolservice%2f2010%2f07%2fclaims%2fidentityprovider=Google&Audience=http%3a%2f%2flocalhost%3a48003%2f&ExpiresOn=1328076955&Issuer=https%3a%2f%2fwpacsoauth.accesscontrol.windows.net%2f&HMACSHA256=4fhWsZz9W7HGmqQQvzCedEghrWvAJaqUGE3mNOWk2w8%3d

这是一般的SWT token的格式,当你将token传给service时,就是要传递这串东西。当然,这个格式靠肉眼还是有点难懂,那不妨再用HttpUtility.UrlDecode解析一下看看:

http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress=s2fawe@gmail.com

&http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name=a a

&http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier=https://www.google.com/accounts/o8/id?id=AItOawlnLbwZSh7r919CNn8Ur3ESncozS2m0JA0

&http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider=Google

&Audience=http://localhost:48003/

&ExpiresOn=1328077259

&Issuer=https://wpacsoauth.accesscontrol.windows.net/

&HMACSHA256=j4WziH+mHK2hQcR7aWpNiDc3D2XqcOmSjV0U93OoKFU=

现在,SWT的庐山真面目就显现出来了!可以看到,一个SWT token其实是一堆claim的集合,再加上一些诸如过期时间,hash值,等等。这些claim是ACS根据我们制定的规则创建的。Hash值(HMACSHA256)是ACS使用你的service和ACS共享的一个密钥加密产生的,客户端无法解析它,也无法伪造。若是service端使用那个密钥加密token得到的hash值不同,就可以认为这个token不是ACS生成,而是什么地方伪造的。因此,service和ACS共享的密钥绝对不能让第三方知道。

在这个基础上,你可以获取名为http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name的claim的值(并不一定有这个claim,视ACS上设置的规则而定),以获取用户的名称。我们当时为了简单,注册的账号的名字叫a a,所以你在本文的截图中看到的就是这个名字。

WPAcsRestUsernameScreenshot.png

下面的代码解析SWT token并且尝试获取用户名。

        private string GetSWTToken(string responseString)
 
{
 
using (MemoryStream responseStream = new MemoryStream(Encoding.UTF8.GetBytes(responseString)))
 
{
 
DataContractJsonSerializer serializer = new DataContractJsonSerializer(typeof(SWTResponse));
 
SWTResponse response = (SWTResponse)serializer.ReadObject(responseStream);
 
string encodedToken = response.securityToken;
 
string xmlToken = HttpUtility.HtmlDecode(encodedToken);
 
using (StringReader stringReader = new StringReader(xmlToken))
 
{
 
using (XmlReader xmlReader = XmlReader.Create(stringReader))
 
{
 
xmlReader.MoveToContent();
 
// Read the body.
 
string base64Token = xmlReader.ReadElementContentAsString();
 
byte[] binaryToken = Convert.FromBase64String(base64Token);
 
return Encoding.UTF8.GetString(binaryToken, 0, binaryToken.Length);
 
}
 
}
 
}
 
}
 
private string GetUserNameFromSWT(string swt)
 
{
 
string decodedToken = HttpUtility.UrlDecode(swt);
 
string[] claims = decodedToken.Split('&');
 
foreach (string claim in claims)
 
{
 
string[] claimKeyValue = claim.Split('=');
 
if (claimKeyValue.Length == 2)
 
{
 
if (claimKeyValue[0] == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name")
 
{
 
return claimKeyValue[1];
 
}
 
}
 
}
 
return string.Empty;
 
}

最后,为了访问受OAuth保护的REST service,我们需要在请求中添加一个Authorization header,若是你不清楚Authorization header,请参考这篇文档上的14.8节。简单说来,这个header的格式是:

AuthorizationScheme空格AuthorizationParameter

其中AuthorizationScheme指的是你使用的protocol,在这里我们用OAuth。AuthorizationParameter是token的值。

下面的代码修改了之前访问REST service的方法,若是发现用户未登录,就转到LoginPage。若是用户登录了,就调用service,并且传入Authorization header。

        protected override void OnNavigatedTo(System.Windows.Navigation.NavigationEventArgs e)
 
{
 
if (!string.IsNullOrEmpty(App.userName))
 
{
 
this.userNameTextBlock.Text = "Welcome: " + App.userName;
 
this.RetrieveStoriesAsync();
 
}
 
else
 
{
 
NavigationService.Navigate(new Uri("/LoginPage.xaml", UriKind.Relative));
 
}
 
base.OnNavigatedTo(e);
 
}
 
private void RetrieveStoriesAsync()
 
{
 
WebClient webClient = new WebClient();
 
webClient.Headers["Authorization"] = string.Format(CultureInfo.InvariantCulture, "OAuth {0}", App.swtToken);
 
webClient.DownloadStringCompleted += new DownloadStringCompletedEventHandler(StoriesRetrieved);
 
webClient.DownloadStringAsync(new Uri(serviceBaseAddress));
 
}

不管你用的是ACS还是其它支持OAuth和SWT标准的身份验证供应商,上面的代码逻辑都是通用的。

在REST service中应用OAuth

现在可以在我们的REST service中应用OAuth了。事实上刚才大家也看到了,为了应用OAuth,客户端传了一个Authorization header到service。那么很自然,service要做的事就是验证这个header。

回想起我们的REST service实现方法的参数:

public HttpResponseMessage Get(HttpRequestMessage request)

在这里HttpRequestMessage提供了很多信息,包括所有的header。例如,你可以通过request.Headers.Authorization获得Authorization header,它会返回一个AuthenticationHeaderValue对象,其中的Scheme和Parameter属性正对应了之前说过的Authorization header中的Scheme和Parameter部分。

再次回忆一个SWT token是一个URL encode过的claim集合,包括一个hash值以检验该token是不是真正的ACS颁发的。

http%3a%2f%2fschemas.xmlsoap.org%2fws%2f2005%2f05%2fidentity%2fclaims%2femailaddress=s2fawe%40gmail.com&http%3a%2f%2fschemas.xmlsoap.org%2fws%2f2005%2f05%2fidentity%2fclaims%2fname=a+a&http%3a%2f%2fschemas.xmlsoap.org%2fws%2f2005%2f05%2fidentity%2fclaims%2fnameidentifier=https%3a%2f%2fwww.google.com%2faccounts%2fo8%2fid%3fid%3dAItOawlnLbwZSh7r919CNn8Ur3ESncozS2m0JA0&http%3a%2f%2fschemas.microsoft.com%2faccesscontrolservice%2f2010%2f07%2fclaims%2fidentityprovider=Google&Audience=http%3a%2f%2flocalhost%3a48003%2f&ExpiresOn=1328076955&Issuer=https%3a%2f%2fwpacsoauth.accesscontrol.windows.net%2f&HMACSHA256=4fhWsZz9W7HGmqQQvzCedEghrWvAJaqUGE3mNOWk2w8%3d

我们在验证token的过程中,要做三件事:

第1, 计算hash值,并且和token本身提供的hash值作比较,以确保token是由我们所信任的STS颁发的。在这里要注意,HMACSHA256这一串字符本身并不参与hash值的计算,只有前面的那段字符串才会参与。

第2, 检查token是否过期。

第3, 解析各种claim。

这里最困难的应该是hash值的计算,但是.NET自带的HMACSHA256类已经帮我们实现好了算法,所以问题就简单了。在获取hash值之后,注意还需要进行base64编码,并且来一次URL encode,才能得到STS所计算的hash值。

至于expire时间,是根据当前时间减去1970年1月1号0点0分0秒所获得的总的秒钟数。只要判断这个值是不是比expireTime来的小就可以了。

下面的代码验证token,并且解析claim。如果在验证过程中发生了错误,我们throw一个HttpResponseException,并且传入Unauthorized(401)为status code。这是WCF Web API提供的一种快捷方式,它所做的事是根据我们提供的status code生成一个response,它也提供了重载可以让你自定义response的内容。你不需要catch这个exception。在客户端你需要检查status code,而不是尝试catch fault exception。Fault exception只针对SOAP有效,REST service的错误信息一律通过status code和response body传递。

        private Dictionary<string, string> ValidateRequest(HttpRequestMessage request)
 
{
 
try
 
{
 
var authorizationHeader = request.Headers.Authorization;
 
if (authorizationHeader == null)
 
{
 
throw new HttpResponseException(HttpStatusCode.Unauthorized);
 
}
 
if (authorizationHeader.Scheme != "OAuth")
 
{
 
throw new HttpResponseException(HttpStatusCode.Unauthorized);
 
}
 
Dictionary<string, string> claims = new Dictionary<string, string>();
 
string issuerHash = string.Empty;
 
long expireTime = 0;
 
string token = authorizationHeader.Parameter;
 
string[] keyValuePairs = token.Split('&');
 
foreach (string keyValuePair in keyValuePairs)
 
{
 
string[] pair = keyValuePair.Split('=');
 
if (pair.Length != 2)
 
{
 
throw new HttpResponseException(HttpStatusCode.Unauthorized);
 
}
 
// HMACSHA256 is not a claim, and does not participate in the hash computation.
 
if (pair[0] == "HMACSHA256")
 
{
 
issuerHash = pair[1];
 
}
 
else
 
{
 
claims.Add(pair[0], pair[1]);
 
}
 
// Check expire time, which should use the current time substracts 1970.1.1 00:00:00.
 
if (pair[0] == "ExpiresOn")
 
{
 
expireTime = Convert.ToInt64(pair[1]);
 
long currentTime = Convert.ToInt64((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0)).TotalSeconds);
 
if (expireTime == 0 || currentTime > expireTime)
 
{
 
throw new HttpResponseException(HttpStatusCode.Unauthorized);
 
}
 
}
 
}
 
if (string.IsNullOrEmpty(issuerHash))
 
{
 
throw new HttpResponseException(HttpStatusCode.Unauthorized);
 
}
 
// Find the part before &HMACSHA256, which represents the string to compute hash.
 
string stringToSign = token.Split(new string[] { "&HMACSHA256" }, StringSplitOptions.None)[0];
 
HMACSHA256 hmac = new HMACSHA256(base64AcsKey);
 
// The hash is computed using the following steps:
 
// 1. Compute hmacsha256 based on the token string (without the HMACSHA256 part).
 
// 2. Convert step1's result to base64.
 
// 3. URL encode step2's result.
 
string localHash = HttpUtility.UrlEncode(Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign))));
 
if (localHash != issuerHash)
 
{
 
throw new HttpResponseException(HttpStatusCode.Unauthorized);
 
}
 
return claims;
 
}
 
catch
 
{
 
throw new HttpResponseException(HttpStatusCode.Unauthorized);
 
}
 
}

最后为了测试,我们可以修改一下服务的实现,仅仅返回属于该用户的story。

        [WebGet(UriTemplate = "")]
 
public HttpResponseMessage Get(HttpRequestMessage request)
 
{
 
Dictionary<string, string> claims = this.ValidateRequest(request);
 
// Get the username.
 
string nameKey = "http%3a%2f%2fschemas.xmlsoap.org%2fws%2f2005%2f05%2fidentity%2fclaims%2fname";
 
string username;
 
claims.TryGetValue(nameKey, out username);
 
username = HttpUtility.UrlDecode(username);
 
XElement stories = new XElement("stories",
 
new XElement("story", new XAttribute("name", "My first story"), new XAttribute("user", "a a")),
 
new XElement("story", new XAttribute("name", "My second story"), new XAttribute("user", "b b")),
 
new XElement("story", new XAttribute("name", "My third story"), new XAttribute("user", "a a"))
 
);
 
// Return only stories for the current user.
 
var query = from e in stories.Elements("story") where (e.Attribute("user").Value == username) select e;
 
XElement responseBody = new XElement("stories", query.ToList());
 
HttpResponseMessage response = new HttpResponseMessage();
 
response.StatusCode = HttpStatusCode.OK;
 
response.Content = new StringContent(responseBody.ToString(), Encoding.UTF8, "text/xml");
 
return response;
 
}

注意到上面的数据中My first story和My third story的用户是a a,而My second story则不是。所以现在运行程序,你将只会看到My first story和My third story。

WPOAuthRestScreenshot.png

以上代码逻辑对于任何支持OAuth和SWT标准的身份验证提供商都是通用的,尽管偶尔可能加密的方式有所不同。

更多资源

说到这里,你可能觉得使用OAuth有点麻烦,要自己写很多的代码。可是其实,本文的目的更多地是让大家了解OAuth的机制,在真实使用的过程中,大家完全可以使用别人写好的类库。

在.NET平台上,WIF(Windows Identity Foundation)为包括OAuth在内的federation技术提供了良好的支持。为了使用WIF,首先需要在你的server上安装WIF runtimeWIF SDK

WIF内置就提供了对WS-Federation的支持,此外,有一个扩展包提供了对OAuth的支持。

WIF除了提供对REST的保护之外,还提供了对SOAP和ASP.NET的保护,还有很多其它功能。你可以自这里找到很多完整的关于WIF的教程。

微软还提供了一个Windows Azure Toolkit for Windows Phone,里面就包括了一个Windows Phone控件大致上实现了和本文所描述的同样的功能。在service端,这个toolkit则是和WIF集成的。这个toolkit还提供了很多和Windows Azure compute以及storage集成的功能,有兴趣的人可以去看看。若是你要使用Windows Azure ACS和Windows Phone,使用这个toolkit会很方便的。你可以在这里找到很多相关教程。

当然,使用别人提供的类库并不意味着我们可以完全不去理解OAuth的机制。万一类库不能满足需求,还是必须自己写代码。

总结

本文讲述了如何在Windows Phone上访问一个REST service,并且如何使用OAuth保护这个service。虽然本文的示例使用了微软平台的WCF Web API和ACS,但是这个过程是可以通用的。你可以在各种平台上构建REST service,而OAuth可以用来保护所有种类的REST service,并且已经被各大身份验证提供商所支持。

This page was last modified on 27 June 2013, at 11:25.
216 page views in the last 30 days.

Was this page helpful?

Your feedback about this content is important. Let us know what you think.

 

Thank you!

We appreciate your feedback.

×