这里主要总结下本人最近半个月关于搭建OAuth2.0服务器工做的经验。至于为什么须要OAuth2.0、为什么是Owin、什么是Owin等问题,再也不赘述。我假定读者是使用Asp.Net,并须要搭建OAuth2.0服务器,对于涉及的Asp.Net Identity(Claims Based Authentication)、Owin、OAuth2.0等知识点已有基本了解。若不了解,请先参考如下文章:html
在对前言中所列的各知识点有初步了解以后,咱们从何处下手呢?
这里推荐一个demo: OWIN OAuth 2.0 Authorization Server git
或者: https://github.com/beginor/owin-samples
除了demo外,还推荐准备好 katanaproject的源代码 github
接下来,咱们主要看这个demo web
从OAuth2.0的rfc文档中,咱们知道OAuth有多种受权模式,这里只关注受权码方式。
首先来看Authorization Server项目,里面有三大块: 数据库
- Clients
- Authorization Server
- Resource Server
以 RFC6749 图示:
Clients分别对应各类受权方式的Client,这里咱们只看对应受权码方式的AuthorizationCodeGrant项目;
Authorization Server即提供OAuth服务的认证受权服务器;
Resource Server即Client拿到AccessToken后携带AccessToken访问的资源服务器(这里仅简单提供一个/api/Me显示用户的Name)。
另外须要注意Constants项目,里面设置了一些关键数据,包含接口地址以及Client的Id和Secret等。 api
AuthorizationCodeGrant项目使用了DotNetOpenAuth.OAuth2封装的一个WebServerClient类做为和Authorization Server通讯的Client。
(这里因为封装了底层的一些细节,导致不使用这个包和Authorization Server交互时可能会遇到几个坑,这个稍后再讲)
这里主要看几个关键点: 浏览器
1.运行项目后,出现页面,点击【Authorize】按钮,第一次重定向用户至 Authorization Server 安全
if (!string.IsNullOrEmpty(Request.Form.Get("submit.Authorize"))) { var userAuthorization = _webServerClient.PrepareRequestUserAuthorization(new[] { "bio", "notes" }); userAuthorization.Send(HttpContext); Response.End(); }
这里 new[] { “bio”, “notes” } 为须要申请的scopes,或者说是Resource Server的接口标识,或者说是接口权限。而后Send(HttpContext)即重定向。服务器
2.这里暂不论重定向用户至Authorization Server后的状况,假设用户在Authorization Server上完成了受权操做,那么Authorization Server会重定向用户至Client,在这里,具体的回调地址即以前点击【Authorize】按钮的页面,而url上带有一个一次性的code参数,用于Client再次从服务器端发起请求到Authorization Server以code交换AccessToken。关键代码以下:cookie
if (string.IsNullOrEmpty(accessToken)) { var authorizationState = _webServerClient.ProcessUserAuthorization(Request); if (authorizationState != null) { ViewBag.AccessToken = authorizationState.AccessToken; ViewBag.RefreshToken = authorizationState.RefreshToken; ViewBag.Action = Request.Path; } }
咱们发现这段代码在以前点击Authorize的时候也会触发,可是那时并无code参数(缺乏code时,可能_webServerClient.ProcessUserAuthorization(Request)并不会发起请求),因此拿不到AccessToken。
3.拿到AccessToken后,剩下的就是调用api,CallApi,试一下,发现返回的就是刚才用户登录Authorization Server所使用的用户名(Resource Server的具体细节稍后再讲)。
4.至此,Client端的代码分析完毕(RefreshToken请自行尝试,自行领会)。没有复杂的内容,按RFC6749的设计,Client所需的就只有这些步骤。对于Client部分,惟一须要再次郑重提醒的是,必定不能把AccessToken泄露出去,好比不加密直接放在浏览器cookie中。
咱们先把Authorization Server放一放,接着看下Resource Server。
Resource Server很是简单,App_Start中Startup.Auth配置中只有一句代码:
app.UseOAuthBearerAuthentication(new Microsoft.Owin.Security.OAuth.OAuthBearerAuthenticationOptions());
而后,惟一的控制器MeController也很是简单:
[Authorize] public class MeController : ApiController { public string Get() { return this.User.Identity.Name; } }
有效代码就这些,就实现了非用户受权下没法访问,受权了就能获取用户登录用户名。(其实webconfig里还有一项关键配置,稍后再说)
那么,Startup.Auth中的代码是什么意思呢?为何Client访问api,而User.Identity.Name倒是受权用户的登录名而不是Client的登录名呢?
咱们先看第一个问题,找 UseOAuthBearerAuthentication() 这个方法。具体怎么找就不废话了,我直接说明它的源代码位置在 Katana Project源码中的Security目录下的Microsoft.Owin.Security.OAuth项目。OAuthBearerAuthenticationExtensions.cs文件中就这么一个针对IAppBuilder的扩展方法。而这个扩展方法其实就是设置了一个OAuthBearerAuthenticationMiddleware,以针对AccessToken进行解析。解析的结果就相似于Client以受权用户的身份(即第二个问题,User.Identity.Name是受权用户的登录名)访问了api接口,获取了属于该用户的信息数据。
关于Resource Server,目前只须要知道这么多。
(关于接口验证scopes、获取用户主键、AccessToken中添加自定义标记等,在看过Authorization Server后再进行说明)
Authorization Server是本文的核心,也是最复杂的一部分。
首先来看Authorization Server项目的Startup.Auth.cs文件,关于OAuth2.0服务端的设置就在这里。
// Enable Application Sign In Cookie app.UseCookieAuthentication(new CookieAuthenticationOptions { AuthenticationType = "Application", //这里有个坑,先提醒下 AuthenticationMode = AuthenticationMode.Passive, LoginPath = new PathString(Paths.LoginPath), LogoutPath = new PathString(Paths.LogoutPath), });
既然到这里了,先提醒下这个设置:AuthenticationType是用户登录Authorization Server后的登录凭证的标记名,简单理解为cookie的键名就行。为何要先提醒下呢,由于这和OAuth/Authorize中检查用户当前是否已登录有关系,有时候,这个值的默认设置多是”ApplicationCookie”。
好,正式看OAuthServer部分的设置:
// Setup Authorization Server app.UseOAuthAuthorizationServer(new OAuthAuthorizationServerOptions { AuthorizeEndpointPath = new PathString(Paths.AuthorizePath), TokenEndpointPath = new PathString(Paths.TokenPath), ApplicationCanDisplayErrors = true, #if DEBUG AllowInsecureHttp = true, //重要!!这里的设置包含整个流程通讯环境是否启用ssl #endif // Authorization server provider which controls the lifecycle of Authorization Server Provider = new OAuthAuthorizationServerProvider { OnValidateClientRedirectUri = ValidateClientRedirectUri, OnValidateClientAuthentication = ValidateClientAuthentication, OnGrantResourceOwnerCredentials = GrantResourceOwnerCredentials, OnGrantClientCredentials = GrantClientCredetails }, // Authorization code provider which creates and receives authorization code AuthorizationCodeProvider = new AuthenticationTokenProvider { OnCreate = CreateAuthenticationCode, OnReceive = ReceiveAuthenticationCode, }, // Refresh token provider which creates and receives referesh token RefreshTokenProvider = new AuthenticationTokenProvider { OnCreate = CreateRefreshToken, OnReceive = ReceiveRefreshToken, } });
咱们一段段来看:
... AuthorizeEndpointPath = new PathString(Paths.AuthorizePath), TokenEndpointPath = new PathString(Paths.TokenPath), ...
设置了这两个EndpointPath,则无需重写OAuthAuthorizationServerProvider的MatchEndpoint方法(假如你继承了它,写了个本身的ServerProvider,不然也能够经过设置OnMatchEndpoint达到和重写相同的效果)。
反过来讲,若是你的EndpointPath比较复杂,好比前面可能由于国际化而携带culture信息,则能够经过override MatchEndpoint方法实现定制。
但请记住,重写了MatchEndpoint(或设置了OnMatchEndpoint)后,我推荐注释掉这两行赋值语句。至于为何,请看Katana Project源码中的Security目录下的Microsoft.Owin.Security.OAuth项目OAuthAuthorizationServerHandler.cs第38行至第46行代码。
对了,若是项目使用了某些全局过滤器,请自行判断是否要避开这两个路径(AuthorizeEndpointPath是对应OAuth控制器中的Authorize方法,而TokenEndpointPath则是彻底由这里配置的OAuthAuthorizationServer中间件接管的)。
ApplicationCanDisplayErrors = true, #if DEBUG AllowInsecureHttp = true, //重要!!这里的设置包含整个流程通讯环境是否启用ssl #endif
这里第一行很少说,字面意思理解下。
重要!! AllowInsecureHttp设置整个通讯环境是否启用ssl, 不只是OAuth服务端,也包含Client端(当设置为false时,若登记的Client端重定向url未采用https,则不重定向,踩到这个坑的话,问题很难定位,亲身体会) 。
// Authorization server provider which controls the lifecycle of Authorization Server Provider = new OAuthAuthorizationServerProvider { OnValidateClientRedirectUri = ValidateClientRedirectUri, OnValidateClientAuthentication = ValidateClientAuthentication, OnGrantResourceOwnerCredentials = GrantResourceOwnerCredentials, OnGrantClientCredentials = GrantClientCredetails }
这里是核心Provider,凡是On开头的,其实都是委托方法,中间件定义了OAuth2的一套流程,可是它把几个关键的事件以委托的方式暴露了出来。
具体的这些委托的做用,咱们接着看对应的方法的代码:
//验证重定向url的 private Task ValidateClientRedirectUri(OAuthValidateClientRedirectUriContext context) { if (context.ClientId == Clients.Client1.Id) { context.Validated(Clients.Client1.RedirectUrl); } else if (context.ClientId == Clients.Client2.Id) { context.Validated(Clients.Client2.RedirectUrl); } return Task.FromResult(0); }
这里context.ClientId是OAuth2处理流程上下文中获取的ClientId,而Clients.Client1.Id是前面说的Constants项目中预设的测试数据。若是咱们有Client的注册机制,那么Clients.Client1.Id对应的Clients.Client1.RedirectUrl就多是从数据库中读取的。而数据库中读取的RedirectUrl则能够直接做为字符串参数传给context.Validated(RedirectUrl)。这样,这部分逻辑就算结束了。
//验证Client身份 private Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context) { string clientId; string clientSecret; if (context.TryGetBasicCredentials(out clientId, out clientSecret) || context.TryGetFormCredentials(out clientId, out clientSecret)) { if (clientId == Clients.Client1.Id && clientSecret == Clients.Client1.Secret) { context.Validated(); } else if (clientId == Clients.Client2.Id && clientSecret == Clients.Client2.Secret) { context.Validated(); } } return Task.FromResult(0); }
和上面验证重定向URL相似,这里是验证Client身份的。可是特别要注意两个TryGet方法,这两个TryGet方法对应了OAuth2Server如何接收Client身份认证信息的方式(这个demo用了封装好的客户端,不会遇到这个问题,以前说的在不使用DotNetOpenAuth.OAuth2封装的一个WebServerClient类的状况下可能遇到的坑就是这个)。
那么何时须要Client提交ClientId和ClientSecret呢?是在前面说到的Client拿着一次性的code参数去OAuth服务器端交换AccessToken的时候。
Basic身份认证,参考 RFC2617
Basic简单说明下就是添加以下的一个Http Header:
Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== //这只是个例子
其中Basic后面部分是 ClientId:ClientSecret 形式的字符串进行Base64编码后的字符串,Authorization是Http Header 的键名,Basic至最后是该Header的值。
Form这种只要注意两个键名是 client_id 和 client_secret 。
private readonly ConcurrentDictionary<string, string> _authenticationCodes = new ConcurrentDictionary<string, string>(StringComparer.Ordinal); private void CreateAuthenticationCode(AuthenticationTokenCreateContext context) { context.SetToken(Guid.NewGuid().ToString("n") + Guid.NewGuid().ToString("n")); _authenticationCodes[context.Token] = context.SerializeTicket(); } private void ReceiveAuthenticationCode(AuthenticationTokenReceiveContext context) { string value; if (_authenticationCodes.TryRemove(context.Token, out value)) { context.DeserializeTicket(value); } }
这里是对应以前说的用来交换AccessToken的code参数的生成和验证的,用ConcurrentDictionary是为了线程安全;_authenticationCodes.TryRemove就是以前一直重点强调的code是 一次性的 ,验证一次后即删除了。
private void CreateRefreshToken(AuthenticationTokenCreateContext context) { context.SetToken(context.SerializeTicket()); } private void ReceiveRefreshToken(AuthenticationTokenReceiveContext context) { context.DeserializeTicket(context.Token); }
这里处理RefreshToken的生成和接收,只是简单的调用Token的加密设置和解密的方法。
至此,Startup.Auth部分的基本结束,咱们接下来看OAuth控制器部分。
OAuthController中只有一个Action,即Authorize。
Authorize方法并无区分HttpGet或者HttpPost,主要缘由多是方法签名引发的(Action同名,除非参数不一样,不然即便设置了HttpGet和HttpPost,编译器也会认为你定义了两个相同的Action,咱们如果硬要拆开,可能会稍微麻烦点)。
if (Response.StatusCode != 200) { return View("AuthorizeError"); }
这段说实话,到如今我还没搞懂为啥要判断下200,多是考虑到owin中间件会提早处理点什么?去掉了也没见有什么异常,或者是我没注意。。。这段无关紧要。。
var authentication = HttpContext.GetOwinContext().Authentication; var ticket = authentication.AuthenticateAsync("Application").Result; var identity = ticket != null ? ticket.Identity : null; if (identity == null) { authentication.Challenge("Application"); return new HttpUnauthorizedResult(); }
这里就是判断受权用户是否已经登录,这是很简单的逻辑,登录部分能够和AspNet.Identity那套一块儿使用,而关键就是authentication.AuthenticateAsync(“Application”)中的“Application”,还记得么,就是以前说的那个cookie名:
... AuthenticationType = "Application", //这里有个坑,先提醒下 ...
这个里要匹配,不然用户登录后,到OAuth控制器这里可能依然会认为是未登录的。
若是用户登录,则这里的identity就会有值。
var scopes = (Request.QueryString.Get("scope") ?? "").Split(' ');
这句只是获取Client申请的scopes,或者说是权限(用空格分隔感受有点奇怪,不知道是否是OAuth2.0里的标准)。
if (Request.HttpMethod == "POST") { if (!string.IsNullOrEmpty(Request.Form.Get("submit.Grant"))) { identity = new ClaimsIdentity(identity.Claims, "Bearer", identity.NameClaimType, identity.RoleClaimType); foreach (var scope in scopes) { identity.AddClaim(new Claim("urn:oauth:scope", scope)); } authentication.SignIn(identity); } if (!string.IsNullOrEmpty(Request.Form.Get("submit.Login"))) { authentication.SignOut("Application"); authentication.Challenge("Application"); return new HttpUnauthorizedResult(); } }
这里,submit.Grant分支就是处理受权的逻辑,其实就是很直观的向identity中添加Claims。那么Claims都去哪了?有什么用呢?
这须要再回过头去看ResourceServer,如下是重点内容:
其实Client访问ResourceServer的api接口的时候,除了AccessToken,不须要其余任何凭据。那么ResourceServer是怎么识别出用户登录名的呢?
关键就是claims-based identity 这套东西。其实全部的claims都加密存进了AccessToken中,而ResourceServer中的OAuthBearer中间件就是解密了AccessToken,
获取了这些claims。这也是为何以前强调AccessToken绝对不能泄露,对于ResourceServer来讲,访问者拥有AccessToken,那么就是受信任的,
颁发AccessToken的机构也是受信任的,因此对于AccessToken中加密的内容也是绝对相信的,因此,ResourceServer这边甚至不须要再去数据库验证访问者Client的身份。
这里提到,颁发AccessToken的机构也是受信任的,这是什么意思呢?咱们看到AccessToken是加密过的,那么如何解密?关键在于AuthorizationServer项目和ResourceServer项目的web.config中配置了一致的 machineKey 。
(题外话,有个在线machineKey生成器: machineKey generator ,这里也提一下,若是不喜欢配置machineKey,能够研究下如何重写AccessToken和RefreshToken的加密解密过程,这里很少说了,提示:OAuthAuthorizationServerOptions中有好几个以Format后缀的属性)
上面说的machineKey便是系统默认的AccessToken和RefreshToken的加密解密的密钥。
submit.Login分支就很少说了,意思就是用户换个帐号登录。
首先,你须要一个自定义的Authorize属性,用于在ResourceServer中验证Scopes,这里要注意两点:
第一点,须要重写的方法不是AuthorizeCore(具体方法名忘了,不知道有没有写错),而是OnAuthorize(同上,有空VS里验证下再来改),且须要调用 base.OnAuthorize 。
第二点,以下:
var claimsIdentity = User.Identity as ClaimsIdentity; claimsIdentity.Claims.Where (c => c.Type == "urn:oauth:scope").ToList();
而后,还有个ResourceServer经常使用的东西,就是用户信息的主键,通常能够从User.Identity.GetUserId()获取,不过这个方法是个扩展方法,须要using Microsoft.AspNet.Identity。至于为何这里能够用呢?就是Claims里包含了用户信息的主键,不信能够调试下看看(注意观察添加claims那段代码,将登录后原有的claims也累加进去了,这里就包含了用户登录名Name和用户主键UserId)。
此次写的真很多,基本本身踩过的坑应该都写了吧,有空再回顾看下有没有遗漏的。今天就先到这里,over。
后续实践发现,因为使用了owin的中间件,ResourceServer依赖Microsoft.Owin.Host.SystemWeb,发布部署的时候不要遗漏该dll。
【转载】