使用微服务架构思想,设计部署API代理网关和OAuth2.0授权认证框架

1,授权认证与微服务架构

1.1,由不同团队合作引发的授权认证问题

去年的时候,公司开发一款新产品,但人手不够,将B/S系统的Web开发外包,外包团队使用Vue.js框架,调用我们的WebAPI,但是这些WebAPI并不在一台服务器上,甚至可能是第三方提供的WebAPI。同时处于系统安全的架构设计,后端WebAPI是不能直接暴露在外面的;另一方面,我们这个新产品还有一个C/S系统,C端登录的时候,要求统一到B/S端登录,可以从C端无障碍的访问任意B/S端的页面,也可以调用B/S系统的一些API,所以又增加了一个API网关代理。

整个系统的架构示意图如下:

注:上图还有一个iMSF,这是一个实时消息服务框架,这里用来做文件服务,参见《消息服务框架使用案例之--大文件上传(断点续传)功能》。在Web端会读取这些上传的文件。

1.2,微服务--分布式“最彻底”的分

1.2.1,为什么需要分布式

大部分情况下,如果你的系统不是很复杂,API和授权认证服务,文件服务都可以放到一台服务器:Web Port 服务器上,但要把它们分开部署到不同的站点,或者不同的服务器,主要是出于以下考虑:

1,职责单一:每一个服务都只做一类工作,比如某某业务WebAPI,授权服务,用户身份认证服务,文件服务等;职责单一使得开发、部署和维护变得容易,比如很容易知道当前是授权服务的问题,而不是业务API问题。

2,系统安全:采用内外网隔离的方案,一些功能需要直接暴露在公网,这需要付出额外的成本,比如带宽租用和安全设施;另外一些功能部署在内网,这样能够提供更大的安全保证。

3,易于维护:每一个服务职责都比较单一,所以每一个服务都足够小,那么开发维护就更容易,比如要更新一个功能,只需要更新一个服务而不用所有服务器都暂停;另一方面也更加容易监控服务器的负载,如果发现某一个服务器负载太大可以增加服务器来分散负载。

4,第三方接入:现在系统越来越复杂,内部的系统很可能需要跟第三方的系统对接,一起协同工作;或者整个系统一部分是 .NET开发的,一部分又是Java平台开发的,两个平台部署的环境有很大差异,没法部署在一起;或者虽然同是ASP.NET MVC,但是一个是MVC3,一个是MVC5,所以需要分别独立部署。

以上就是各个服务需要分开部署的原因,而这样做的结果就是我们常说的分布式计算了,这是自然需求的结果,不是为了分而才分。

1.2.2,依赖于中间层而不直接依赖于服务

客户端直接访问后端服务,对后端的服务会形成比较强的依赖。有架构经验的朋友都知道,解决依赖的常见手段就是添加一个中间层,客户端依赖于这个中间层而不是直接依赖于服务层。这样做有几个很大的好处:

  • 当服务负载过大的时候可以在中间层做负载均衡;
  • 或者后端某个服务出现问题可以切换主备服务;
  • 或者替换后端某个服务的版本做灰度发布。

另一方面,当后端服务部署为多个独立的进程/服务器后,客户端直接访问这些服务,将是一个更加较复杂的问题,负载均衡,主备切换,灰度发布等运维功能更难操作,除此之外,还有下面两个比较重要的问题:

  • 客户端直接访问后端多个服务,将暴露过多的后端服务器地址,从而增加安全隐患;
  • 后端服务太多,需要在客户端维护这些服务访问关系,增加开发调试的复杂性;
  • B/S页面的AJax跨域问题,WebAPI地址跟主站地址不一样,要解决跨域问题比较复杂并且也会增加安全隐患。

所以,为了解决客户端对后端服务层的依赖,并且解决后端服务太多以后引起的问题,我们需要在客户端和后端服务层之间添加一个中间层,这个中间层就是我们的服务代理层,也就是我们后面说的服务网关代理(WebAPI Gateway Proxy),它作为我们所有Web访问的入口站点,这就是上图所示的 Web Port。有了网关代理,后台所有的WebAPI都可以通过这个统一的入口提供对外服务的功能,而对于后端不同服务地址的路由,由网关代理的路由功能来实现,所以这个代理功能很像Nginx这样的反向代理,只不过,这里仅仅代理WebAPI,而不是其它Web资源。

现在,网关已经成为很多分布式系统的标配,比如TX的这个架构:

注:上图来源于网络,侵删!

另外,这个读写分离代理,如果使用SOD框架,可以在AdoHelper对象直接设置读写不同的连接字符串简单达到效果。

1.2.3,微服务架构

经过上面的设计,我们发现这个架构有几个特点:

  1. 每个服务足够小,职责单一;
  2. 每个服务运行在自己的进程或者独立的服务器中,独立发布部署和开发维护;
  3. 服务对外提供访问或者服务之间进行通信,都是使用轻量级的HTTP API;
  4. 每个服务有自己独立的存储,彼此之间进行数据交互都通过接口进行;
  5. 有一个API代理网关统一提供服务的对外访问。

这些特点是非常符合现在流行的微服务思想的,比如在《什么是微服务》这篇文章中,像下面说的这样:

微服务最早由Martin Fowler与James Lewis于2014年共同提出,微服务架构风格是一种使用一套小服务来开发单个应用的方式途径,每个服务运行在自己的进程中,
并使用轻量级机制通信,通常是HTTP API,这些服务基于业务能力构建,并能够通过自动化部署机制来独立部署,这些服务使用不同的编程语言实现,以及不同数据存储技术,
并保持最低限度的集中式管理。

所以我们这个架构是基本符合微服务思想的,它的诞生背景也是要解决其它传统单体软件项目现在遇到的问题一样的,是在比较复杂的实际需求环境下自然而然的一种需求,不过好在它没有过多的“技术债务”,所以设计实施起来比较容易。下面我们来详细看看这个架构是如何落地的。

2,“授权\认证\资源”独立服务的OAuth2.0架构

2.1,为什么需要OAuth2.0 ?

OAuth 2.0已经是一个“用户验证和授权”的工业级标准。OAuth(开放授权)是一个开放标准,1.0版本于2006年创立,它允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。 OAuth 2.0关注客户端开发者的简易性,同时为Web应用,桌面应用和手机,和起居室设备提供专门的认证流程。2012年10月,OAuth 2.0协议正式发布为RFC 6749。以上内容详见OAuth 2.0官网

现在百度开放平台,腾讯开放平台等大部分的开放平台都是使用的OAuth 2.0协议作为支撑,国内越来越多的企业都开始支持OAuth2.0协议。现在,我们的产品设计目标是要能够和第三方系统对接,那么在对接过程中的授权问题就是无法回避的问题。在我们原来的产品中,有用户授权验证的模块,但并没有拆分出独立的服务,用它与第三方系统对接会导致比较大的耦合性;另一方面,与第三方系统对接合作不一定每次都是以我们为主导,也有可能要用第三方的授权认证系统。这就出现了选择哪一方的授权认证方案的问题。之前我曾经经历过一个项目,因为其中的授权认证问题导致系统迟迟不能集成。所以,选择一个开放标准的授权认证方案,才是最佳的解决方案,而OAuth 2.0正是这样的方案。

2.2,OAuth的名词解释和规范

(1)Third-party application:第三方应用程序,本文中又称”客户端”(client),即上一节例子中的“Web Port”或者C/S客户端应用程序。
(2)HTTP service:HTTP服务提供商,即上一节例子中提供软件产品的我们公司或者第三方公司。
(3)Resource Owner:资源所有者,本文中又称“用户”(user)。
(4)User Agent:用户代理,本文中就是指浏览器或者C/S客户端应用程序。
(5)Authorization server:授权服务器,即服务提供商专门用来处理认证的服务器。
(6)Resource server:资源服务器,即服务提供商存放用户生成的资源的服务器,即上一节例子中的内部API服务器、第三方外部API服务器和文件服务器等。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。

以上名词是OAuth规范内必须理解的一些名词,然后我们才能方便的讨论OAuth2.0是如何授权的。有关OAuth的思路、运行流程和详细的四种授权模式,请参考阮一峰老师的《理解OAuth 2.0》。

2.3,OAuth2.0的授权模式

为了表述方便,先简单说说这4种授权模式:

  1. 授权码模式(authorization code)--是功能最完整、流程最严密的授权模式。它的特点就是通过客户端的后台服务器,与"服务提供商"的认证服务器进行互动。
  2. 简化模式(implicit)--不通过第三方应用程序的服务器,直接在浏览器中向认证服务器申请令牌,跳过了"授权码"这个步骤,因此得名。所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。
  3. 密码模式(resource owner password credentials)--用户向客户端提供自己的用户名和密码。客户端使用这些信息,向"服务商提供商"索要授权。在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。
  4. 客户端模式(client credentials)--指客户端以自己的名义,而不是以用户的名义,向"服务提供商"进行认证。严格地说,客户端模式并不属于OAuth框架所要解决的问题。在这种模式中,用户直接向客户端注册,客户端以自己的名义要求"服务提供商"提供服务,其实不存在授权问题。

在我们的需求中,用户不仅仅通过B/S系统的浏览器进行操作,还会通过C/S程序的客户端进行操作,B/S,C/S系统主要都是我们提供和集成的,客户购买了我们这个产品要使用它就意味着客户信任我们的产品。授权码模式虽然是最完整的授权模式,但是授权码模式授权完成后需要浏览器的跳转,显然浏览器无法直接跳转到我们的C/S客户端,虽然从技术上可以模拟,但实现起来成本还是比较高;简化模式也有这个问题。所以我们最终决定采用OAuth2.0的密码模式。

2.4,OAuth2.0密码模式授权流程

 简单来说,密码模式的步骤如下:

  1.  用户向客户端提供用户名和密码。
  2. 客户端将用户名和密码发给认证服务器,向后者请求令牌。
  3. 认证服务器确认无误后,向客户端提供访问令牌。

 上面这个步骤只是说明了令牌的获取过程,也就是我们常说用户登陆成功的过程。当用户登陆成功之后,客户端得到了一个访问令牌,然后再使用这个令牌去访问资源服务器,具体说来还有如下后续过程:

  • 4,客户端携带此访问令牌,访问资源服务器;
  • 5,资源服务器去授权服务器验证客户端的访问令牌是否有效;
  • 6,如果访问令牌有效,授权服务器给资源服务器发送用户标识信息;
  • 7,资源服务器根据用户标识信息,处理业务请求,最后发送响应结果给客户端。

下面是流程图:

注意:这个流程适用于资源服务器、授权服务器相分离的情况,否则,流程中的第5,6步不是必须的,甚至第4,7步都是显而易见的事情而不必说明。现在大部分有关OAuth2.0的介绍文章都没有4,5,6,7步骤的说明,可能为了表述方便,默认都是将授权服务器跟资源服务器合在一起部署的。

2.5,授权、认证与资源服务的分离

什么情况下授权服务器跟资源服务器必须分开呢?

如果一个系统有多个资源服务器并且这些资源服务器的框架版本不兼容,运行环境有差异,代码平台不同(比如一个是.NET,一个是Java),或者一个是内部系统,一个是外部的第三方系统,必须分开部署。在这些情况下,授权服务器跟任意一个资源服务器部署在一起都不利于另一些资源服务器的使用,导致系统集成成本增加。这个时候,授权服务器必须跟资源服务器分开部署,我们在具体实现OAuth2.0系统的时候,需要做更多的事情。

什么情况下授权服务器跟认证服务器必须分开呢?

 授权(authorization)和认证(authentication)有相似之处,但也是两个不同的概念:

  • 授权(authorization):授权,批准;批准(或授权)的证书;
  • 认证(authentication):认证;身份验证;证明,鉴定;密押。

仅仅从这两个词的名词定义可能不太容易分辨,我们用实际的例子来说明他们的区别:

有一个管理系统,包括成熟的人员管理,角色管理,权限管理,系统登录的时候,用户输入的用户名和密码到系统的人员信息表中查询,通过后取得该用户的角色权限。

在这个场景中,用户登录系统实际上分为了3个步骤:

  1. 用户在登录界面,输入用户名和密码,提交登录请求;
  2. 【认证】系统校验用户输入的用户名和密码是否在人员信息表中;
  3. 【授权】给当前用户授予相应的角色权限。

现在,该管理系统需要和第三方系统对接,根据前面的分析,这种情况下最好将授权功能独立出来,采用OAuth这种开放授权方案,而认证问题,原有管理系统坚持用户信息是敏感信息,不能随意泄露给第三方,要求在原来管理系统完成认证。这样一来,授权和认证,只好分别作为两个服务,独立部署实现了。

本文的重点就是讲述如何在授权服务器和资源服务器相分离,甚至授权和认证服务器相分离的情况下,如何设计实现OAuth2.0的问题。

3,PWMIS OAuth2.0 方案

PWMIS OAuth2.0 方案就是一个符合上面要求的授权与认证相分离,授权与资源服务相分离的架构设计方案,该方案已经成功支撑了我们产品的应用。下面分别来说说该方案是如何设计和落地的。

3.1,使用Owin中间件搭建OAuth2.0认证授权服务器

这里主要总结下本人在这个产品中搭建OAuth2.0服务器工作的经验。至于为何需要OAuth2.0、为何是Owin、什么是Owin等问题,不再赘述。我假定读者是使用Asp.Net,并需要搭建OAuth2.0服务器,对于涉及的Asp.Net Identity(Claims Based Authentication)、Owin、OAuth2.0等知识点已有基本了解。若不了解,请先参考以下文章:

我们的工作,可以从研究《OWIN OAuth 2.0 Authorization Server》这个DEMO开始,不过为了更好的结合本文的主题,实现授权与认证相分离的微服务架构,推荐大家直接从我的DEMO开始:https://github.com/bluedoctor/PWMIS.OAuth2.0 

PS:大家觉得好,先点个赞支持下,谢谢!

克隆我这个DEMO到本地,下面开始我们OAuth2.0如何落地的正式讲解。

3.2,PWMIS.OAuth2.0解决方案介绍

首先看到解决方案视图,先逐个做下简单说明:

编号

角色

程序集名称

说明

1

授权服务器

PWMIS.OAuth2.AuthorizationCenter

授权中心

ASP.NET Web API+OWIN

2

资源服务器

Demo.OAuth2.WebApi

提供API资源

ASP.NET Web API+OWIN

   

Demo.OAuth2.WebApi2

 提供API资源

 ASP.NET Web API 

3

客户端

Demo.OAuth2.ConsoleTest

控制台测试程序,测试令牌申请等功能

   

 Demo.OAuth2.WinFormTest

 测试登录到B/S和打开B/S页面等功能

4

 API代理网关

Demo.OAuth2.Port

用户的Web入口,本测试程序入口

ASP.NET MVC 5.0

5

认证服务器

Demo.OAuth2.IdentityServer

简单登录账号认证

ASP.NET Web API

   

Demo.OAuth2.Mvc

 简单登录账号认证,支持登录会话

 ASP.NET Web MVC 

6

 其它

PWMIS.OAuth2.Tools

提供OAuth2.0 协议访问的一些有用的工具类

 

3.2.1,运行解决方案

将解决方案的项目,除了PWMIS.OAuth2.Tools,全部设置为启动项目,启动之后,在 http://localhost:62424/ 站点,输入下面的地址:

http://localhost:62424/Home

然后就可以看到下面的界面:

点击登录页面,为了方便演示,不真正验证用户名和密码,所以随意输入,提交后结果如下图:

点击确定,进入了业务操作页面,如下图:

如果能够看到这个页面,我们的OAuth2.0演示程序就成功了。

还可以运行解决方案里面的WinForm测试程序,先登录,然后运行性能测试,如下图:

更多信息,请参考下文的【3.8集成C/S客户端访问】

下面我们来看看各个程序集项目的构建过程。

3.3,项目 PWMIS.OAuth2.AuthorizationCenter

首先添加一个MVC5项目PWMIS.OAuth2.AuthorizationCenter,然后添加如下包引用:

Microsoft.AspNet.Mvc
Microsoft.Owin.Host.SystemWeb
Microsoft.Owin.Security.OAuth
Microsoft.Owin.Security.Cookies

然后在项目根目录下添加一个OWin的启动类 Startup:

using Microsoft.Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.OAuth;
using Owin;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Web.Http;

namespace PWMIS.OAuth2.AuthorizationCenter
{
    public partial class Startup
    {
        public void ConfigureAuth(IAppBuilder app)
        {
            var OAuthOptions = new OAuthAuthorizationServerOptions
            {
                AllowInsecureHttp = true,
                AuthenticationMode = AuthenticationMode.Active,
                TokenEndpointPath = new PathString("/api/token"), //获取 access_token 授权服务请求地址
                AuthorizeEndpointPath = new PathString("/authorize"), //获取 authorization_code 授权服务请求地址
                AccessTokenExpireTimeSpan = TimeSpan.FromSeconds(60), //access_token 过期时间,默认10秒太短

                Provider = new OpenAuthorizationServerProvider(), //access_token 相关授权服务
                AuthorizationCodeProvider = new OpenAuthorizationCodeProvider(), //authorization_code 授权服务
                RefreshTokenProvider = new OpenRefreshTokenProvider() //refresh_token 授权服务
            };
            app.UseOAuthBearerTokens(OAuthOptions); //表示 token_type 使用 bearer 方式
        }

        public void Configuration(IAppBuilder app)
        {
            // For more information on how to configure your application, visit http://go.microsoft.com/fwlink/?LinkID=316888
            ConfigureAuth(app);

            var configuration = new HttpConfiguration();
            WebApiConfig.Register(configuration);
            app.UseWebApi(configuration);

         }

      }
}

上面的代码中,定义了access_token 授权服务请求地址和access_token 过期时间,这里设置60秒后过期。由于本篇着重讲述OAuth2.0的密码授权模式,我们直接看到类 OpenAuthorizationServerProvider的定义:

 public class OpenAuthorizationServerProvider : OAuthAuthorizationServerProvider
    {
        /// <summary>
        /// 验证 client 信息
        /// </summary>
        public override async Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
        {
            string clientId;
            string clientSecret;
            if (!context.TryGetBasicCredentials(out clientId, out clientSecret))
            {
                context.TryGetFormCredentials(out clientId, out clientSecret);
            }
            if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret))
            {
                context.SetError("PWMIS.OAuth2 invalid_client", "client or clientSecret is null or empty");
                return;
            }

            var identityRepository = IdentityRepositoryFactory.CreateInstance();
            try
            {
                if (!await identityRepository.ValidateClient(clientId, clientSecret))
                {
                    context.SetError("PWMIS.OAuth2 invalid_client", "client or clientSecret is not valid");
                    return;
                }
            }
            catch (Exception ex)
            {
                context.SetError("PWMIS.OAuth2 identity_repository_error", ex.Message );
                Log("PWMIS.OAuth2 identity_repository_error:" + ex.Message);
                return;
            }
          
            context.Validated();
        }

        /// <summary>
        /// 生成 access_token(resource owner password credentials 授权方式)
        /// </summary>
        public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
        {
            string validationCode = "";
            string sessionId = "";
            if (string.IsNullOrEmpty(context.UserName))
            {
                context.SetError("PWMIS.OAuth2 invalid_username", "username is not valid");
                return;
            }
            if (string.IsNullOrEmpty(context.Password))
            {
                context.SetError("PWMIS.OAuth2 invalid_password", "password is not valid");
                return;
            }
            if (context.Scope.Count > 0)
            {
                //处理用户会话标识和验证码
                var temp= context.Scope.FirstOrDefault(p => p.Contains("ValidationCode:"));
                if (temp != null)
                {
                    validationCode = temp.Split(':')[1];
                }

                var temp1 = context.Scope.FirstOrDefault(p => p.Contains("SessionID:"));
                if (temp1 != null)
                {
                    sessionId = temp1.Split(':')[1];
                }
            }

            IdentityService service = new IdentityService();
            try
            {
                LoginResultModel user = await service.UserLogin(context.UserName, context.Password,sessionId, validationCode);
                if (user == null)
                {
                    context.SetError("PWMIS.OAuth2 invalid_identity", "username or password is not valid");
                    return;
                }
                else  if (string.IsNullOrEmpty(user.UserName))
                {
                    context.SetError("PWMIS.OAuth2 invalid_identity", user.ErrorMessage);
                    return;
                }
            }
            catch (Exception ex)
            {
                context.SetError("PWMIS.OAuth2 identity_service_error", ex.Message );
                Log("PWMIS.OAuth2 identity_service_error:" + ex.Message);
                return;
            }
           

            var OAuthIdentity = new ClaimsIdentity(context.Options.AuthenticationType);
            OAuthIdentity.AddClaim(new Claim(ClaimTypes.Name, context.UserName));
            context.Validated(OAuthIdentity);
        }

        /// <summary>
        /// 验证 access_token 的请求
        /// </summary>
        public override async Task ValidateTokenRequest(OAuthValidateTokenRequestContext context)
        {
            if (context.TokenRequest.IsAuthorizationCodeGrantType || 
                context.TokenRequest.IsRefreshTokenGrantType || 
                context.TokenRequest.IsResourceOwnerPasswordCredentialsGrantType ||
                context.TokenRequest.IsClientCredentialsGrantType)
            {
                context.Validated();
            }
            else
            {
                context.Rejected();
            }
        }
          
    }
}
OpenAuthorizationServerProvider

 token过期时间不宜太长,比如一天,这样不安全,但也不能太短,比如10秒,这样当API访问量比较大的时候会增大刷新token的负担,所以这里设置成60秒。

3.3.1,验证客户端信息

在本类的第一个方法 ValidateClientAuthentication 验证客户端的信息,这里的客户端可能是C/S程序的客户端,也可能是访问授权服务器的网关代理服务器,OAuth2.0会验证需要生成访问令牌的客户端,只有合法的客户端才可以提供后续的生成令牌服务。

客户端信息有2个部分,一个是clientId,一个是clientSecret,前者是客户端的唯一标识,后者是授权服务器颁发给客户端的秘钥,这个秘钥可以设定有效期或者设定授权范围。为简便起见,我们的演示程序仅仅到数据库去检查下传递的这两个参数是否有对应的数据记录,使用下面一行代码:

 var identityRepository = IdentityRepositoryFactory.CreateInstance();

这里会用到一个验证客户端的接口,包括验证用户名和密码的方法一起定义了:

 /// <summary>
    /// 身份认证持久化接口
    /// </summary>
    public interface IIdentityRepository
    {
        /// <summary>
        /// 客户ID是否存在
        /// </summary>
        /// <param name="clientId"></param>
        /// <returns></returns>
        Task<bool> ExistsClientId(string clientId);
        /// <summary>
        /// 校验客户标识
        /// </summary>
        /// <param name="clientId">客户ID</param>
        /// <param name="clientSecret">客户秘钥</param>
        /// <returns></returns>
        Task<bool> ValidateClient(string clientId, string clientSecret);
        /// <summary>
        /// 校验用户名密码
        /// </summary>
        /// <param name="userName"></param>
        /// <param name="password"></param>
        /// <returns></returns>
        Task<bool> ValidatedUserPassword(string userName, string password);
    }

这样我们就可以通过反射或者简单 IOC框架将客户端验证的具体实现类注入到程序中,本例实现了一个简单的客户端和用户认证类,采用的是SOD框架访问数据库:

namespace PWMIS.OAuth2.AuthorizationCenter.Repository
{
    public class SimpleIdentityRepository : IIdentityRepository
    {
        private static System.Collections.Concurrent.ConcurrentDictionary<string, string> dictClient = new System.Collections.Concurrent.ConcurrentDictionary<string, string>();
        public async Task<bool> ExistsClientId(string clientId)
        {
            return await Task.Run<bool>(() =>
            {
                AuthClientInfoEntity entity = new AuthClientInfoEntity();
                entity.ClientId = clientId;

                OQL q = OQL.From(entity)
                    .Select(entity.ClientId)
                    .Where(entity.ClientId)
                    .END;
                AuthDbContext context = new AuthDbContext();
                AuthClientInfoEntity dbEntity = context.QueryObject<AuthClientInfoEntity>(q);
                return dbEntity != null;
            });
        }

        public async Task<bool> ValidateClient(string clientId, string clientSecret)
        {
            string dict_clientSecret;
            if (dictClient.TryGetValue(clientId, out dict_clientSecret) && dict_clientSecret== clientSecret)
            {
                return true;
            }
            else
            {
                return await Task.Run<bool>(() => {
                    AuthClientInfoEntity entity = new AuthClientInfoEntity();
                    entity.ClientId = clientId;
                    entity.ClientSecret = clientSecret;
                    OQL q = OQL.From(entity)
                        .Select(entity.ClientId)
                        .Where(entity.ClientId, entity.ClientSecret)
                        .END;
                    AuthDbContext context = new AuthDbContext();
                    AuthClientInfoEntity dbEntity = context.QueryObject<AuthClientInfoEntity>(q);
                    if (dbEntity != null)
                    {
                        dictClient.TryAdd(clientId, clientSecret);
                        return true;
                    }
                    else
                        return false;
                });
            }
            
        }

        public async Task<bool> ValidatedUserPassword(string userName, string password)
        {
            return await Task.Run<bool>(() =>
            {
                UserInfoEntity user = new UserInfoEntity();
                user.UserName = userName;
                user.Password = password;
                OQL q = OQL.From(user)
                   .Select()
                   .Where(user.UserName, user.Password)
                   .END;
                AuthDbContext context = new AuthDbContext();
                AuthClientInfoEntity dbEntity = context.QueryObject<AuthClientInfoEntity>(q);
                return dbEntity != null;
            });
        }
    }
}

AuthDbContext 类非常简单,它会自动生成验证客户端所需要的表:

namespace PWMIS.OAuth2.AuthorizationCenter.Repository
{
    public class AuthDbContext:DbContext
    {
        public AuthDbContext()
            : base("OAuth2")
        {
                    
        }


        protected override bool CheckAllTableExists()
        {
            base.CheckTableExists<AuthClientInfoEntity>();
            base.CheckTableExists<UserInfoEntity>();
            return true;
        }
    }
}

3.3.2,认证用户,生成访问令牌

生成访问令牌需要重写OWIN OAuthAuthorizationServerProvider类的 GrantResourceOwnerCredentials方法(方法的详细内容看前面【OpenAuthorizationServerProvider的定义】),方法里面使用到了IdentityService 对象,它有一个UserLogin 方法,用来实现或者调用用户认证服务: 

namespace PWMIS.OAuth2.AuthorizationCenter.Service
{
    public class IdentityService
    {
        public async Task<LoginResultModel> UserLogin(string userName, string password,string sessionId, string validationCode)
        { 
            //通过配置,决定是使用本地数据库验证登录,还是使用登录接口服务登录
            string identityLoginMode = System.Configuration.ConfigurationManager.AppSettings["IdentityLoginMode"];
            if (!string.IsNullOrEmpty(identityLoginMode) && identityLoginMode.ToLower() == "database")
            {
                var identityRepository = IdentityRepositoryFactory.CreateInstance();
                bool flag= await identityRepository.ValidatedUserPassword(userName, password);
                LoginResultModel result = new LoginResultModel();
                if (flag)
                {
                    result.ID = "123";
                    result.UserName = userName;
                    result.Roles = "";//暂时略
                }
                return result;
            }
            else
            {
                System.Diagnostics.Stopwatch sp = new System.Diagnostics.Stopwatch();
                var parameters = new Dictionary<string, string>();
                //parameters.Add("ID", "");
                parameters.Add("UserName", userName);
                parameters.Add("Password", password);
                parameters.Add("ID", sessionId);
                parameters.Add("ValidationCode", validationCode);
                //parameters.Add("Roles", "");

                string loginUrl = System.Configuration.ConfigurationManager.AppSettings["IdentityWebAPI"];
                string sessionCookieName = System.Configuration.ConfigurationManager.AppSettings["SessionCookieName"];
                if (string.IsNullOrEmpty(sessionCookieName))
                    sessionCookieName = "ASP.NET_SessionId";

                //添加会话标识
                CookieContainer cc = new CookieContainer();
                HttpClientHandler handler = new HttpClientHandler();
                handler.CookieContainer = cc;
                handler.UseCookies = true;
                Cookie cookie = new Cookie(sessionCookieName, sessionId);
                cookie.Domain = (new Uri(loginUrl)).Host;
                cc.Add(cookie);

                HttpClient httpClient = new HttpClient(handler);
                LoginResultModel result = null;
                sp.Start();

                var response = await httpClient.PostAsync(loginUrl, new FormUrlEncodedContent(parameters));
                if (response.StatusCode != HttpStatusCode.OK)
                {
                    result = new LoginResultModel();
                    result.UserName = userName;
                    try
                    {
                        result.ErrorMessage = response.Content.ReadAsAsync<HttpError>().Result.ExceptionMessage;
                    }
                    catch 
                    {
                        result.ErrorMessage = "登录错误(错误信息无法解析),服务器状态码:"+response.StatusCode;
                    }
                }
                else
                {
                    result = await response.Content.ReadAsAsync<LoginResultModel>();
                }

                sp.Stop();
                if (!string.IsNullOrEmpty(result.ErrorMessage) || sp.ElapsedMilliseconds > 100)
                    WriteLog(result, sp.ElapsedMilliseconds);

                return result;
            }
        }

        public static void WriteLog(LoginResultModel result,long logTime)
        {
            string filePath = System.IO.Path.Combine(HttpRuntime.AppDomainAppPath, "UserLog.txt");
            try
            {
                string text = string.Format("{0} User :{1} Web Login used time(ms):{2}, ErrorMsg:{3}\r\n", DateTime.Now.ToString(), 
                    result.UserName, logTime, result.ErrorMessage);

                System.IO.File.AppendAllText(filePath, text);
            }
            catch
            {

            }
        }
    }
}
IdentityService

UserLogin方法提供了2种方式来认证用户身份,一种是直接访问用户数据库,一种是调用第三方的用户认证接口,这也是当前演示程序默认配置的方式。当用户认证比较复杂的时候,推荐使用这种方式,比如认证的时候需要检查验证码。

需要在授权服务器的应用程序配置文件中配置使用何种用户身份验证方式以及验证地址:

 <appSettings>
    <add key="webpages:Version" value="3.0.0.0"/>
    <add key="webpages:Enabled" value="false"/>
    <add key="ClientValidationEnabled" value="true"/>
    <add key="UnobtrusiveJavaScriptEnabled" value="true"/>
    <!--IdentityLoginMode 认证登录模式,值为DataBase/WebAPI ,默认为WebAPI;配置为WebAPI将使用 IdentityWebAPI 配置的地址访问WebAPI来认证用户-->
    <add key="IdentityLoginMode" value=""/>
    <!--IdentityWebAPI 认证服务器身份认证接口-->
    <!--<add key="IdentityWebAPI" value="http://localhost:61001/api/Login"/>-->
    <add key="IdentityWebAPI" value="http://localhost:50697/Login"/>
    
    <!--DataBase 认证模式的持久化提供程序类和程序集信息
        此提供程序继承自 PWMIS.OAuth2.Tools程序集的IIdentityRepository 接口。
    -->
    <add key="IdentityRepository" value="PWMIS.OAuth2.AuthorizationCenter.Repository.SimpleIdentityRepository,PWMIS.OAuth2.AuthorizationCenter"/>
    <add key="SessionCookieName" value="ASP.NET_SessionId"/>
    <add key="LogFile" value="~\AuthError.txt"/>
  </appSettings>

 

如果认证用户名和密码通过,在GrantResourceOwnerCredentials方法最后,调用OWin的用户标识方式表示授权验证通过:

    var OAuthIdentity = new ClaimsIdentity(context.Options.AuthenticationType);
    OAuthIdentity.AddClaim(new Claim(ClaimTypes.Name, context.UserName));
    context.Validated(OAuthIdentity);

 

3.4,项目 PWMIS.OAuth2.Tools

项目 PWMIS.OAuth2.Tools 封装了OAuth2.0调用相关的一些API函数,前面我们介绍了基于OWIN实现的OAuth2.0服务端,下面我们来看看如何调用它生成一个访问令牌。

3.4.1,OAuthClient类--获取和刷新令牌

看到 OAuthClient.cs 文件的 OAuthClient类的GetToken 方法:

 

        /// <summary>
        /// 获取访问令牌
        /// </summary>
        /// <param name="grantType">授权模式</param>
        /// <param name="refreshToken">刷新的令牌</param>
        /// <param name="userName">用户名</param>
        /// <param name="password">用户密码</param>
        /// <param name="authorizationCode">授权码</param>
        /// <param name="scope">可选业务参数</param>
        /// <returns></returns>
         public  async Task<TokenResponse> GetToken(string grantType, string refreshToken = null, string userName = null, string password = null, string authorizationCode = null,string scope=null)
        {
            var clientId = System.Configuration.ConfigurationManager.AppSettings["ClientID"];
            var clientSecret = System.Configuration.ConfigurationManager.AppSettings["ClientSecret"];
            this.ExceptionMessage = "";
            var parameters = new Dictionary<string, string>();
            parameters.Add("grant_type", grantType);

            if (!string.IsNullOrEmpty(userName) && !string.IsNullOrEmpty(password))
            {
                parameters.Add("username", userName);
                parameters.Add("password", password);
                parameters.Add("scope", scope);
           
            }
            if (!string.IsNullOrEmpty(authorizationCode))
            {
                var redirect_uri = System.Configuration.ConfigurationManager.AppSettings["RedirectUri"];
                parameters.Add("code", authorizationCode);
                parameters.Add("redirect_uri", redirect_uri); //和获取 authorization_code 的 redirect_uri 必须一致,不然会报错
            }
            if (!string.IsNullOrEmpty(refreshToken))
            {
                parameters.Add("refresh_token", refreshToken);
            }

            httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
                "Basic",
                Convert.ToBase64String(Encoding.ASCII.GetBytes(clientId + ":" + clientSecret)));
            string errCode = "00";
            try
            {
                //PostAsync 在ASP.NET下面,必须加).ConfigureAwait(false);否则容易导致死锁
                //详细内容,请参考 http://blog.csdn.net/ma_jiang/article/details/53887967
                var cancelTokenSource = new CancellationTokenSource(50000);
                var response = await httpClient.PostAsync("/api/token", new FormUrlEncodedContent(parameters), cancelTokenSource.Token).ConfigureAwait(false);
                var responseValue = await response.Content.ReadAsStringAsync();
                if (response.StatusCode != HttpStatusCode.OK)
                {
                    try
                    {
                        var error = await response.Content.ReadAsAsync<HttpError>();
                        if (error.ExceptionMessage == null)
                        {
                            string errMsg = "";
                            foreach (var item in error)
                            {
                                errMsg += item.Key + ":\"" + (item.Value == null ? "" : item.Value.ToString()) + "\",";
                            }
                            this.ExceptionMessage = "HttpError:{" + errMsg.TrimEnd(',')+"}";
                        }
                        else
                        {
                            this.ExceptionMessage = error.ExceptionMessage;
                        }
                        errCode = "1000";
                    }
                    catch (AggregateException agex)
                    {
                        string errMsg = "";
                        foreach (var ex in agex.InnerExceptions)
                        {
                            errMsg += ex.Message;
                        }

                        errCode = "1001";
                        this.ExceptionMessage = errMsg;
                    }
                    catch (Exception ex)
                    {
                        this.ExceptionMessage = response.Content.ReadAsStringAsync().Result;
                        errCode = "1002";
                        WriteErrorLog(errCode, ex.Message);
                    }

                    WriteErrorLog(errCode, "StatusCode:" + response.StatusCode + "\r\n" + this.ExceptionMessage);
                    this.ExceptionMessage = "{ErrorCode:" + errCode + ",ErrorObject:{" + this.ExceptionMessage + "}}";
                    return null;
                }
                return await response.Content.ReadAsAsync<TokenResponse>();
            }
            catch (AggregateException agex)
            {
                string errMsg = "";
                foreach (var ex in agex.InnerExceptions)
                {
                    errMsg += ex.Message+",";
                }

                errCode = "1003";
                this.ExceptionMessage = errMsg;
                WriteErrorLog(errCode, errMsg);
                this.ExceptionMessage = "{ErrorCode:" + errCode + ",ErrorMessage:'" + this.ExceptionMessage + "'}";
                return null;
            }
            catch (Exception ex)
            {
                this.ExceptionMessage = ex.Message;
                errCode = "1004";
                WriteErrorLog(errCode, this.ExceptionMessage);
                this.ExceptionMessage = "{ErrorCode:" + errCode + ",ErrorMessage:'" + this.ExceptionMessage + "'}";
                return null;
            }
        }


方法首先要获取客户端的clientId 和clientSecret 信息,这个信息需要指定到本次请求的Authorization 头信息里面;
然后在请求正文里面,指定授权类型,这里应该是"password",再在正文里面添加用户名和密码参数。接着,调用HttpClient对象,访问授权服务器的 /api/token ,该地址正是前面介绍的授权服务器项目里面指定的。
最后,对请求返回的响应结果做复杂的异常处理,得到正确的返回值或者异常结果。

在本例中,获取的令牌有效期只有1分钟,超过时间就需要刷新令牌:

         /// <summary>
         /// 使用指定的令牌,直接刷新访问令牌
         /// </summary>
         /// <param name="token"></param>
         /// <returns></returns>
         public TokenResponse RefreshToken(TokenResponse token)
         {
             this.CurrentToken = token;
             return  GetToken("refresh_token", token.RefreshToken).Result;
         }

3.4.2,TokenManager类--令牌的管理

由于令牌过期后需要刷新令牌获取新的访问令牌,否则应用使用过期的令牌访问就会出错,因此我们应该在令牌超期之前就检查令牌是否马上到期,在到期之前的前一秒我们就立即刷新令牌,用新的令牌来访问资源服务器;但是刷新令牌可能导致之前一个线程使用的令牌失效,造成访问未授权的问题,毕竟授权服务跟资源服务器分离之后,这个可能性是比较高的,因此我们需要对令牌的使用进行管理,降低发生问题的风险。

首先看到 PWMIS.OAuth2.Tools.TokenManager 文件的 CreateToken 生成令牌的方法:

        /// <summary>
        /// 使用密码模式,给当前用户创建一个访问令牌
        /// </summary>
        /// <param name="password">用户登录密码</param>
        /// <param name="validationCode">验证码</param>
        /// <returns></returns>
        public async Task<TokenResponse> CreateToken(string password,string validationCode=null)
        {
            OAuthClient oc = new OAuthClient();
            oc.SessionID = this.SessionID;
            var tokenRsp= await oc.GetTokenOfPasswardGrantType(this.UserName, password, validationCode);
            if (tokenRsp != null)
            {
                UserTokenInfo uti = new UserTokenInfo(this.UserName, tokenRsp);
                dictUserToken[this.UserName] = uti;
            }
            else
            {
                this.TokenExctionMessage = oc.ExceptionMessage;
            }
            return tokenRsp;
        }

生成的令牌存储在一个字段中,通过登录用户名来获取对应的令牌。

然后看TakeToken 方法,它首先尝试获取一个当前用户的令牌,如果令牌快过期,就尝试刷新令牌:

        /// <summary>
        /// 取一个访问令牌
        /// </summary>
        /// <returns>如果没有或者获取令牌失败,返回空</returns>
        public TokenResponse TakeToken()
        {
            if (dictUserToken.ContainsKey(this.UserName))
            {
                UserTokenInfo uti = dictUserToken[this.UserName];
                this.OldToken = uti.Token;

                //如果令牌超期,刷新令牌
                if (DateTime.Now.Subtract(uti.FirstUseTime).TotalSeconds >= uti.Token.expires_in || NeedRefresh)
                {
                    lock (uti.SyncObject)
                    {
                        //防止线程重入,再次判断
                        if (DateTime.Now.Subtract(uti.FirstUseTime).TotalSeconds >= uti.Token.expires_in || NeedRefresh)
                        {
                            //等待之前的用户使用完令牌再刷新
                            while (uti.UseCount > 0)
                            {
                                if (DateTime.Now.Subtract(uti.LastUseTime).TotalSeconds > 5)
                                {
                                    //如果发出请求超过5秒使用计数还大于0,可以认为资源服务器响应缓慢,最终请求此资源可能会拒绝访问
                                    this.TokenExctionMessage = "Resouce Server maybe Request TimeOut.";
                                    OAuthClient.WriteErrorLog("00", "**警告** "+DateTime.Now.ToString()+":用户"+this.UserName+" 最近一次使用当前令牌("
                                        +uti.Token.AccessToken +")已经超时(10秒),使用次数:"+uti.UseCount+",线程ID:"+System.Threading.Thread.CurrentThread.ManagedThreadId+"。\r\n**下面将刷新令牌,但可能导致之前还未处理完的资源服务器访问被拒绝访问。");
                                    break;
                                }
                                System.Threading.Thread.Sleep(100);
                            }
                            //刷新令牌
                            try
                            {
                                OAuthClient oc = new OAuthClient();
                                var newToken = oc.RefreshToken(uti.Token);
                                if (newToken == null)
                                    throw new Exception("Refresh Token Error:" + oc.ExceptionMessage);
                                else if( string.IsNullOrEmpty( newToken.AccessToken))
                                    throw new Exception("Refresh Token Error:Empty AccessToken. Other Message:" + oc.ExceptionMessage);

                                uti.ResetToken(newToken);
                                this.TokenExctionMessage = oc.ExceptionMessage;
                            }
                            catch (Exception ex)
                            {
                                this.TokenExctionMessage = ex.Message;
                                return null;
                            }
                            NeedRefresh = false;
                        }
                    }//end lock
                }
               
                this.CurrentUserTokenInfo = uti;
                uti.BeginUse();
                //this.CurrentTokenLock.Set();
                return uti.Token;
            }
            else
            {
                //throw new Exception(this.UserName+" 还没有访问令牌。");
                this.TokenExctionMessage = "UserNoToken";
                return null;
            }
        }

有了令牌管理功能,客户端生成和获取一个访问令牌就方便了,下面看看客户端如何来使用它。

3.5,项目 Demo.OAuth2.Port

项目 Demo.OAuth2.Port 在本解决方案里面有3个作用:

  1. 提供静态资源的访问,比如调用WebAPI的Vue.js 功能代码;
  2. 提供后端API路由功能,作为前端所有API访问的网关代理;
  3. 存储用户的登录票据,关联用户的访问令牌。

这里我们着重讲解第3点功能,网关代理功能另外详细介绍。

在方案中,用户的访问令牌缓存在Port站点的进程中,每当用户登录成功后,就生成一个用户访问令牌跟当前用户票据关联。

看到项目的控制器 LogonController 的用户登录Action:

        [HttpPost]
        [AsyncTimeout(60000)]
        public async Task<ActionResult> Index(LogonModel model)
        {
            LogonResultModel result = new LogonResultModel();
          
            //首先,调用授权服务器,以密码模式获取访问令牌
            //授权服务器会携带用户名和密码到认证服务器去验证用户身份
            //验证服务器验证通过,授权服务器生成访问令牌给当前站点程序
            //当前站点标记此用户登录成功,并将访问令牌存储在当前站点的用户会话中
            //当前用户下次访问别的站点的WebAPI的时候,携带此访问令牌。
          
            TokenManager tm = new TokenManager(model.UserName, Session.SessionID);
            var tokenResponse = await tm.CreateToken(model.Password,model.ValidationCode);
            if (tokenResponse != null && !string.IsNullOrEmpty(tokenResponse.AccessToken))
            {
                result.UserId = 123;
                result.UserName = model.UserName;
                result.LogonMessage = "OK";
                /* OWin的方式
                ClaimsIdentity identity = new ClaimsIdentity("Basic");
                identity.AddClaim(new Claim(ClaimTypes.Name, model.UserName));
                ClaimsPrincipal principal = new ClaimsPrincipal(identity);
                HttpContext.User = principal;
                */
                FormsAuthentication.SetAuthCookie(model.UserName, false);
            }
            else
            {
                result.LogonMessage = tm.TokenExctionMessage;
            }
            return Json(result);
        }

Port站点作为授权服务器的客户端,需要配置客户端信息,看到Web.config文件的配置:

 <appSettings>
    <add key="webpages:Version" value="3.0.0.0" />
    <add key="webpages:Enabled" value="false" />
    <add key="ClientValidationEnabled" value="true" />
    <add key="UnobtrusiveJavaScriptEnabled" value="true" />
    <!--向授权服务器登记的客户端ID和秘钥-->
    <add key="ClientID" value="PWMIS.OAuth2.Port"/>
    <add key="ClientSecret" value="1234567890"/>
    <!--授权服务器地址-->
    <add key="Host_AuthorizationCenter" value="http://localhost:60186"/>
    <!--资源服务器地址-->
    <add key="Host_Webapi" value="http://localhost:62477"/>
  </appSettings>

另外,再提供一个获取当前用户令牌的方法,当然前提是必须先登录成功:

        [HttpGet]
        [Authorize]
        public ActionResult GetUserToken()
        {
            using (TokenManager tm = new TokenManager(User.Identity.Name, Session.SessionID))
            {
                var token = tm.TakeToken();
                return Content(token.AccessToken);
            }
        }

 3.6,项目 Demo.OAuth2.WebApi

项目 Demo.OAuth2.WebApi是本解决方案中的资源服务器。由于资源服务器跟授权服务器并不是在同一台服务器,所以资源服务器必须检查每次客户端请求的访问令牌是否合法,检查的方法就是将客户端的令牌提取出来发送到授权服务器去验证,得到这个令牌对应的用户信息,包括登录用户名和角色信息等。

如果是ASP.NET MVC5,我们可以拦截API请求的 DelegatingHandler 处理器,我们定义一个 AuthenticationHandler 类继承它来处理:

namespace PWMIS.OAuth2.Tools
{
    /// <summary>
    /// WebAPI 认证处理程序
    /// </summary>
    /// <remarks>
    /// 需要在 WebApiApplication.Application_Start() 方法中,增加下面一行代码:
    ///   GlobalConfiguration.Configuration.MessageHandlers.Add(new AuthenticationHandler());
    /// </remarks>
    public class AuthenticationHandler : DelegatingHandler 
    {
        /*
         * 【认证处理程序】处理过程:
         * 1,客户端使用之前从【授权服务器】申请的访问令牌,访问【资源服务器】;
         * 2,【资源服务器】加载【认证处理程序】
         * 3,【认证处理程序】将来自客户端的访问令牌,拿到【授权服务器】进行验证;
         * 4,【授权服务器】验证客户端的访问令牌有效,【认证处理程序】写入身份验证票据;
         * 5,【资源服务器】的受限资源(API)验证通过访问,返回结果给客户端。
         */

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
        {
            if (request.Headers.Authorization != null && request.Headers.Authorization.Parameter != null)
            {
                string token = request.Headers.Authorization.Parameter;

                string Host_AuthCenter = System.Configuration.ConfigurationManager.AppSettings["OAuth2Server"];// "http://localhost:60186";
                HttpClient _httpClient = new HttpClient(); ;
                _httpClient.BaseAddress = new Uri(Host_AuthCenter);
                _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

                var response = await _httpClient.GetAsync("/api/AccessToken");//.Result;
                if (response.StatusCode == HttpStatusCode.OK)
                {
                    string[] result = await response.Content.ReadAsAsync<string[]>();//.Result;
                    ClaimsIdentity identity = new ClaimsIdentity(result[2]);
                    identity.AddClaim(new Claim(ClaimTypes.Name, result[0]));
                    ClaimsPrincipal principal = new ClaimsPrincipal(identity);
                    HttpContext.Current.User = principal;
                    //添加角色示例,更多信息,请参考 https://msdn.microsoft.com/zh-cn/library/5k850zwb(v=vs.80).aspx
                    //string[] userRoles = ((RolePrincipal)User).GetRoles();
                    //Roles.AddUserToRole("JoeWorden", "manager");

                }
            }
            
          
            return await base.SendAsync(request, cancellationToken);
        }
    }
}

最后,在WebApiApplication 的Application_Start 方法调用此对象:

namespace Demo.OAuth2.WebApi
{
    public class WebApiApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            GlobalConfiguration.Configure(WebApiConfig.Register);
            GlobalConfiguration.Configuration.MessageHandlers.Add(new AuthenticationHandler());
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
        }
    }
}

 这样,我们跟OAuth2.0相关的客户端,授权服务器与资源服务器的实现过程就介绍完了。认证服务器的实现比较简单,但它涉及到登录验证码问题的时候就比较复杂了,之后单独介绍。

3.7,接入第三方OAuth2.0资源服务器

前面的例子中,我们使用ASP.NET WebAPI作为OAuth2.0的资源服务器,它可以很方便的调用我们的AuthenticationHandler 拦截器来处理API调用,发现有访问令牌信息就将它发送到授权服务器验证。如果是单纯的ASP.NET WebForms, ASP.NET MVC3 ,甚至是Java等其它平台的资源服务器呢?

没有关系,我们发现OAuth本身就是一个开放的授权协议,任何能够处理HTTP请求的服务器都能够集成OAuth,只要相应的请求响应符合规范即可。对于访问令牌,它存在HTTP请求头的Authorization 里面,解析使用它即可。

下面我们以某个比较老的管理系统来举例,它基于 ASP.NET MVC3定制开发,扩展了一些底层的东西,所以没法升级到兼容支持ASP.NET WebAPI MVC5。

public void XXXLogon(HttpRequestBase request)
        {
            if (request.Headers.AllKeys.Contains("Authorization"))
            {
                var headValue = request.Headers["Authorization"];
                string[] headValueArr = headValue.Split(' ');
                string authType = headValueArr[0];
                string token = headValueArr[1];
                //验证token
                string host_AuthCenter = System.Configuration.ConfigurationManager.AppSettings["OAuth2Server"];// "http://localhost:60186";
                if (string.IsNullOrEmpty(host_AuthCenter))
                    throw new Exception("请在AppSettings 配置OAuth2Server ,值类似于http://localhost:80 的授权服务器地址");
                HttpClient _httpClient = new HttpClient(); ;
                _httpClient.BaseAddress = new Uri(host_AuthCenter);
                _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
                
                var response = _httpClient.GetAsync("/api/AccessToken").Result;
                if (response.StatusCode == HttpStatusCode.OK)
                {
                    //需要 Newtonsoft.Json, Version=4.5.0.0
                    string[] result = response.Content.ReadAsAsync<string[]>().Result;
                    string userName = result[0];
                    //以下代码在.NET 4.0下面无法使用,需要.NET 4.5
                    //ClaimsIdentity identity = new ClaimsIdentity(result[2]);
                    //identity.AddClaim(new Claim(ClaimTypes.Name, result[0]));
                    //ClaimsPrincipal principal = new ClaimsPrincipal(identity);
                    //HttpContext.Current.User = principal;

                    string sessionKey = Guid.NewGuid().ToString();
                    UserDTO userDto = (UserDTO)HttpContext.Current.Cache[userName];
                    SaveProfile(userDto, sessionKey, request);
                }
                //验证结束
                return;
            }
          }
DefaultRequestHeaders.Authorization

上面代码有2个需要注意的地方,一个是提取出HTTP请求头中的Authorization,然后需要构造一个新的请求(请求授权服务器),添加AuthenticationHeaderValue,它的类型是“Bearer”,值是当前访问令牌;另一个是需要在站点配置文件中配置 “OAuth2Server”,值为授权服务器的地址。

3.8,集成C/S客户端访问

OAuth提供了多种授权方案,密码模式和客户端模式比较适合C/S客户端授权。不过,为了跟B/S端统一,都使用密码模式,可以让客户端程序直接访问授权服务器。但这并不是最佳的方案,可以让B/S的Web Port作为访问代理,C/S客户端模拟浏览器发起访问,这样就跟B/S端访问完全统一了。具体访问架构如前面的架构图所示。

集成C/S客户端访问,包括登录功能和访问授权资源功能,我们在实际实现的时候,都以Web Port为访问代理。为了简便起见,这里的客户端应用程序使用一个WinForm程序来模拟。请看到解决方案的项目 Demo.OAuth2.WinFormTest。

如下图所示的登录效果:

接着使用浏览器打开一个API地址: http://localhost:62424/api/values

接着模拟登录并且打开授权访问的资源地址,这个效果跟在程序里面使用授权后的访问令牌去访问需要授权访问的资源,效果是一样的,入下图:

下面我们来简单介绍下以上的统一登录、打开浏览器访问授权访问的资源和应用程序直接访问授权资源是如何实现的,这些方法都封装在OAuthClient 类中。

namespace Demo.OAuth2.WinFormTest
{
    public partial class Form1 : Form
    {
        private OAuthClient oAuthCenterClient;
        private HttpClient client;
        public Form1()
        {
            InitializeComponent();
        }

        private async void btnLogin_Click(object sender, EventArgs e)
        {
            string userName = this.txtUseName.Text.Trim();
            string password = this.txtPassword.Text;
           
            try
            {
                await oAuthCenterClient.WebLogin(userName, password, result =>
                {
                    if (result.LogonMessage == "OK")
                    {
                        MessageBox.Show("登录成功!");
                        this.txtUrl.Text = this.oAuthCenterClient.ResourceServerClient.BaseAddress.ToString() + "api/values";
                        btnGo.Enabled = true;
                        //有关 cookie,可以参考:
                        // string[] strCookies = (string[])response.Headers.GetValues("Set-Cookie");
                        // http://www.cnblogs.com/leeairw/p/3754913.html
                        // http://www.cnblogs.com/sjns/p/5331723.html
                    }
                    else
                    {
                        MessageBox.Show(result.LogonMessage);
                        btnGo.Enabled = false;
                    }
                });

            }
            catch (Exception ex)
            {
                MessageBox.Show(ex.Message);
            }
        }

     

        private async void btnGo_Click(object sender, EventArgs e)
        {
            //使用全局的HttpClient,将使用登录时候获取的Cookie,服务器会认为这是同一个用户的请求
            HttpClient client = this.client;
            if (!this.ckbUserLogin.Checked)
            {
                string url = System.Configuration.ConfigurationManager.AppSettings["Host_Webapi"];
                client = new HttpClient();
                client.BaseAddress = new Uri(url);
            }
            await RequestResouceServer(client);
        }

        private async Task RequestResouceServer(HttpClient client)
        {
            var tokenResponse = oAuthCenterClient.GetToken("client_credentials").Result;
            if (tokenResponse == null)
            {
                MessageBox.Show("获取客户端令牌失败");
                return;
            }
            oAuthCenterClient.SetAuthorizationRequest(client, tokenResponse);

            var response = await client.GetAsync(this.txtUrl.Text);
            if (response.StatusCode != HttpStatusCode.OK)
            {
                try
                {
                    string errMsg = string.Format("HTTP响应码:{0},错误信息:{1}", response.StatusCode, (await response.Content.ReadAsAsync<HttpError>()).ExceptionMessage);
                    MessageBox.Show(errMsg);
                }
                catch
                {
                    MessageBox.Show(response.StatusCode.ToString());
                }

            }
            else
            {
                this.txtPage.Text = await response.Content.ReadAsStringAsync();
            }
        }

        private void Form1_Load(object sender, EventArgs e)
        {
            this.btnGo.Enabled = false;
            this.oAuthCenterClient = new OAuthClient();
            this.client = oAuthCenterClient.ResourceServerClient;
            this.txtUrl.Text = this.client.BaseAddress.ToString();


        }

        private async void txtOpenIE_Click(object sender, EventArgs e)
        {
            await oAuthCenterClient.OpenUrlByBrowser(this.txtUseName.Text, this.txtUrl.Text);
        }

       
    }
}
Demo.OAuth2.WinFormTest

客户端程序访问资源服务器,授权服务器,可以通过网关代理进行的,可以分别配置。为了演示授权服务器的效果,这里客户端直接访问了授权服务器,所以需要配置它的客户端ID和秘钥,请看它的应用程序配置信息:

<appSettings>
    <add key="ClientID" value="PWMIS OAuth2 Client1"/>
    <add key="ClientSecret" value="1234567890"/>
    <!--授权服务器地址-->
    <add key="Host_AuthorizationCenter" value="http://localhost:60186"/>
    <!--资源服务器地址-->
    <add key="Host_Webapi" value="http://localhost:62424"/>
  </appSettings>

 4,PWMIS API Gateway

前面的架构分析说明,要让多个资源服务独立部署,并且简化客户端对资源服务的访问,一个统一的访问入口必不可少,它就是API网关,实际上它是客户端访问后端API的一个代理,在代理模式上属于反向代理,我们这个方案中的PWMIS API Gateway 正是这样一个反向代理。网关程序与网站其它部分部署在一起,作为统一的Web访问入口--Web Port。在本示例解决方案中,网关代理就在 Demo.OAuth2.Port 项目上。

4.1,代理配置

首先我们来看看代理的配置文件 ProxyServer.config:

# ======PWMIS API Gateway Proxy,Ver 1.1 ==================
# ======PWMIS API网关代理配置,版本 1.1 ==================
#
# 注释说明:每行第一个非空白字符串是#,表示这行是一个注释
# 版本说明:
# Ver 1.0:
# * 实现API网关代理与OAuth2.0 的集成
# * OAuth2.0 授权与认证服务实现相分离的架构
# Ver 1.1:
# * 为每一个目标主机使用相同的HttpClient对象,并且保持长连接,优化网络访问效率
# * 网关访问资源服务器,支持连接会话保持功能,使得资源服务器可以使用自身的会话状态
# * 资源服务器 由 /api/ ,/api2/ 增加到 /api3/
# Ver 1.2:
# * 在路由项目上支持会话连接,整体上默认不启用会话连接,优化网络访问效率
#
# 全局配置:
# EnableCache: 是否支持缓存,值为 false/true,但当前版本不支持
# EnableRequestLog: 是否开启请求日志,值为 false,true
# LogFilePath: 请求日志文件保存的目录
# ServerName: 代理服务器名字
# UnauthorizedRedir:目标API地址访问未授权,是否跳转,值为 false,true。
#                   如果跳转,将跳转到OAuthRedirUrl 指定的页面,如果不跳转,会直接抛出 HTTP Statue Unauthorized
# OAuthRedirUrl:未授权要跳转的地址,通常为网关的登录页
# RouteMaps:路由项目配置清单
#
# 路由项目配置:
  # Prefix:要匹配的API Url 前缀。注意,如果配置文件配置了多个路由项目,会按照配路由项目的顺序依次匹配,直到不能配置为止,
  #         所以理论上可以对一个Url进行多次匹配和替换,请注意路由项目的编排顺序
  # Host:  匹配后,要访问的目标主机地址,比如 "localhost:62477"
  # Match: 匹配该路由项目后,要对Url 内容进行替换的要匹配的字符串
  # Map:   匹配该路由项目后,要对Url Match的内容进行替换的目标字符串
#

{
"EnableCache":false,
"EnableRequestLog":true,
"LogFilePath":"C:\\WebApiProxyLog",
"ServerName":"PWMIS ASP.NET Proxy,Ver 1.2",
"UnauthorizedRedir":false,
"OAuthRedirUrl":"http://localhost:62424/Logon",

"RouteMaps":
  [
    {
      "Prefix":"/api/",
      "Host":"localhost:62477",
      "Match":"",
      "Map":null
    },
    # 授权服务器配置
    {
      "Prefix":"/api/token",
      "Host":"localhost:60186",
      "Match":"",
      "Map":null
    },
    {
      "Prefix":"/api/AccessToken",
      "Host":"localhost:60186",
      "Match":"",
      "Map":null
    },
    # 登录验证码配置
    {
      "Prefix":"/api/Login/CreateValidate",
      "Host":"localhost:50697",
      "Match":"/api/",
      "Map":"/",
      "SessionRequired":true
    },
    {
      "Prefix":"/api2/common/GetValidationCode",
      "Host":"localhost:8088",
      "Match":"/api2/",
      "Map":"/",
      "SessionRequired":true
    },
    # 其它资源服务器配置
    {
      "Prefix":"/api2/",
      "Host":"localhost:8088",
      "Match":"/api2/",
      "Map":"/"
    }
  ]
}

配置文件分为全局配置和路由项目配置,全局配置包含代理访问的日志信息配置,以及资源未授权访问的跳转配置,路由信息配置包括要匹配的URL前缀,路由的目标主机地址,要替换的内容和是否支持会话请求。

需要注意的是,路由项目的匹配不是匹配到该项目后就结束,而是会尝试匹配所有路由项目,进行多次匹配和替换,直到不能匹配为止,所以代理配置文件对于路由项目的顺序很重要,也不宜编写太多的路由配置项目。

目前,支持的路由项目的API前缀地址,有 /api,/api2,api3/ 三大种,更多的匹配前缀需要修改代理服务的源码。

 4.2,API 代理请求拦截器

首先定义一个拦截器 ProxyRequestHandler,它继承自 WebAPI的DelegatingHandler,可以在底层拦截对API调用的消息,在重载的SendAsync 方法内实现访问请求的处理:

public class ProxyRequestHandler : DelegatingHandler
{
        /// <summary>  
        /// 拦截请求  
        /// </summary>  
        /// <param name="request">请求</param>  
        /// <param name="cancellationToken">用于发送取消操作信号</param>  
        /// <returns></returns>  
        protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            //实现暂略
        }
}

首先,我们需要从request请求对象中拿出当前请求的URL地址,处理代理规则,进行路由项目匹配:

            bool matched = false;
            bool sessionRequired = false;
            string url = request.RequestUri.PathAndQuery;
            Uri baseAddress = null;
            //处理代理规则
            foreach (var route in this.Config.RouteMaps)
            {
                if (url.StartsWith(route.Prefix))
                {
                    baseAddress = new Uri("http://" + route.Host + "/");
                    if (!string.IsNullOrEmpty(route.Match))
                    {
                        if (route.Map == null) route.Map = "";
                        url = url.Replace(route.Match, route.Map);
                    }
                    matched = true;
                    if (route.SessionRequired)
                        sessionRequired = true;
                    //break;
                    //只要不替换前缀,还可以继续匹配并且替换剩余部分
                }
            }

如果未匹配到,说明是一个本地地址请求,直接返回本地请求的响应结果:

          if (!matched)
            {
                return await base.SendAsync(request, cancellationToken);
            }

如果匹配到,那么进入GetNewResponseMessage 方法进一步处理请求:

        /// <summary>
        /// 请求目标服务器,获取响应结果
        /// </summary>
        /// <param name="request"></param>
        /// <param name="url"></param>
        /// <param name="baseAddress"></param>
        /// <param name="sessionRequired">是否需要会话支持</param>
        /// <returns></returns>
        private async Task<HttpResponseMessage> GetNewResponseMessage(HttpRequestMessage request, string url, Uri baseAddress, bool sessionRequired)
        {
            HttpClient client = GetHttpClient(baseAddress, request, sessionRequired);

            var identity = HttpContext.Current.User.Identity;
            if (identity == null || identity.IsAuthenticated == false)
            {
                return await ProxyReuqest(request, url, client);
            }
            //如果当前请求上下文的用户标识对象存在并且已经认证过,那么获取它关联的访问令牌,添加到请求头部
            using (TokenManager tm = new TokenManager(identity.Name, null))
            {
                TokenResponse token = tm.TakeToken();
                client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
                return await ProxyReuqest(request, url, client);
            }
         }

这里的代码只是一个简化后的示意代码,实际处理的时候可能存在请求令牌失败,刷新令牌失败,或者获取到了令牌但等到访问资源服务器的时候令牌又被别的线程刷新导致资源访问未授权失败的情况,这些复杂的情况处理起来比较麻烦,目前遇到访问未授权的时候,采取重试2次的策略。具体请看真是源码。

最后,就是我们真正的代理请求访问的方法 ProxyReuqest 了:

 private async Task<HttpResponseMessage> ProxyReuqest(HttpRequestMessage request, string url, HttpClient client)
        {
            HttpResponseMessage result = null;
            if (request.Method == HttpMethod.Get)
            {
                result = await client.GetAsync(url);
            }
            else if (request.Method == HttpMethod.Post)
            {
                result = await client.PostAsync(url, request.Content);
            }
            else if (request.Method == HttpMethod.Put)
            {
                result = await client.PutAsync(url, request.Content);
            }
            else if (request.Method == HttpMethod.Delete)
            {
                result = await client.DeleteAsync(url);
            }
            else
            {
                result = SendError("PWMIS ASP.NET Proxy 不支持这种 Method:" + request.Method.ToString(), HttpStatusCode.BadRequest);
            }
          
            result.Headers.Add("Proxy-Server", this.Config.ServerName);
            return result;
         }

4.3,注册代理拦截器和API路由

前面定义了拦截器 ProxyRequestHandler,现在需要把它注册到API的请求管道里面去,看到项目的 WebApiConfig 文件:

namespace Demo.OAuth2.Port.App_Start
{
    public class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            // Web API 配置和服务  
            config.MessageHandlers.Add(new ProxyRequestHandler());
            // Web API 路由  
            config.MapHttpAttributeRoutes();

            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional }
                );
             config.Routes.MapHttpRoute(
                name: "MyApi",
                routeTemplate: "api2/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional }
              );
            config.Routes.MapHttpRoute(
              name: "MyApi3",
              routeTemplate: "api3/{controller}/{id}",
              defaults: new { id = RouteParameter.Optional }
            );
           
        }  
    }
}

 

4.4 HttpClient对象的优化

 HttpClient对象封装了很多HTTP请求有用的方法,特别是哪些异步方法,感觉它跟ASP.NET MVC WebAPI就是标配。但是也常常听见有朋友在讨论HttpClient的性能问题,主要原因就是它的连接问题,如果每个请求一个HttpClient实例在高并发下会产生很多TCP连接,进而降低请求响应的效率,解决办法就是复用HttpClient对象,并且设置长连接。有关这个问题的测试和解决方案,可以参考这篇文章《WebApi系列~HttpClient的性能隐患》。

在本解决方案的代理服务器中,默认情况下访问每一个代理的目标主机,会使用同一个HttpClient对象。比如有站点A,B,会创建 httpClientA,httpClientB 两个对象。这样,相当于代理服务器跟每一个被代理的目标主机(资源服务器)都建立了一个长连接,从而提高网络访问效率。

 private HttpClient GetHttpClient(Uri baseAddress, HttpRequestMessage request, bool sessionRequired)
        {
            if (sessionRequired)
            {
                //注意:应该每个浏览器客户端一个HttpClient 实例,这样才可以保证各自的会话不冲突
                var client = getSessionHttpClient(request, baseAddress.Host);
                setHttpClientHeader(client, baseAddress, request);
                return client;
            }
            else
            {
                string key = baseAddress.ToString();
                if (dictHttpClient.ContainsKey(key))
                {
                    return dictHttpClient[key];
                }
                else
                {
                    lock (sync_obj)
                    {
                        if (dictHttpClient.ContainsKey(key))
                        {
                            return dictHttpClient[key];
                        }
                        else
                        {
                            var client = getNoneSessionHttpClient(request, baseAddress.Host);
                            setHttpClientHeader(client, baseAddress, request);
                            dictHttpClient.Add(key, client);
                            return client;
                        }
                    }
                }
            }

        }

上面的代码,根据URL请求的基础地址(被代理访问的目标主机地址)为字典的键,获取或者添加一个HttpClient对象,创建新HttpClient对象使用下面这个方法:

        private HttpClient getNoneSessionHttpClient(HttpRequestMessage request, string host)
        {
            HttpClient client = new HttpClient();
            client.DefaultRequestHeaders.Connection.Add("keep-alive");
            return client;
        }

这个方法主要作用是为新创建的HttpClient对象添加长连接请求标头。

另外,还需要解决DNS缓存问题,在ServicePointManager 类进行设定,每一分钟刷新一次。

   //定期清除DNS缓存
   var sp = ServicePointManager.FindServicePoint(baseAddress);
   sp.ConnectionLeaseTimeout = 60 * 1000; // 1 分钟

最后,修改默认的并发连接数为512,如下:

 static ProxyRequestHandler()
 {
        ServicePointManager.DefaultConnectionLimit = 512;
 }

有关这问题,可以进一步参考下面的文章:

C#中HttpClient使用注意:预热与长连接

多线程环境下调用 HttpWebRequest 并发连接限制

 

4.5,代理的会话支持

 我们的入口网站(Web Port)一般都是支持会话的,有时候,需要在资源服务器或者认证服务器保持用户的会话状态,提供有状态的服务。前面我们说明实现代理访问使用了HttpClient对象,默认情况下同一个HttpClient对象与服务器交互是可以保持会话状态的,在代理请求的时候,将原始请求的Cookie值附加到代理请求的HttpCliet的CookieContainer对象即可。然而为了优化HttpClient的访问效率,我们对同一个被代理访问的资源服务器使用了同一个HttpClient对象,而不是对同一个浏览器的请求使用同一个HttpClient对象。实际上,并不需要这样做,只要确保当前HttpClient对象的Cookie能够发送到被代理的资源服务器即可,针对每个请求线程创建一个HttpClient对象实例是最安全的做法。

回到前面的 GetHttpClient 方法,看到下面代码:

            if (sessionRequired)
            {
                //注意:应该每个浏览器客户端一个HttpClient 实例,这样才可以保证各自的会话不冲突
                var client = getSessionHttpClient(request, baseAddress.Host);
                setHttpClientHeader(client, baseAddress, request);
                return client;
            }

在 getSessionHttpClient 方法中,将原始请求的Cookie值一一复制到新的请求上去。CookieContainer 里面的Cookie跟HttpRequestMessage 请求头里面的Cookie根本就不是一回事,需要一个个的转换:

        private HttpClient getSessionHttpClient(HttpRequestMessage request, string host)
        {
            CookieContainer cc = new CookieContainer();
            HttpClientHandler handler = new HttpClientHandler();
            handler.CookieContainer = cc;
            handler.UseCookies = true;

            HttpClient client = new HttpClient(handler);

             //复制Cookies
            var headerCookies = request.Headers.GetCookies();
            foreach (var chv in headerCookies)
            {
                foreach (var item in chv.Cookies)
                {
                    Cookie cookie = new Cookie(item.Name, item.Value);
                    cookie.Domain = host;
                    cc.Add(cookie);
                }
            }

            return client;
        }

我们知道对于ASP.NET来说,服务器支持会话是因为服务器给客户端发送了一个 名字为 ASP.NET_SessionId 的Cookie,只要这个Cookie发送过去了,被代理的服务器就不会再为“客户端”生成这个会话ID,并且会使用这个会话ID,在当前服务器(资源服务器)维护自己的会话状态。

注意:虽然Web Port跟被代理的服务器使用了一样的SessionID,但它们的会话状态并不相同,只不过看起来访问两个服务器的客户端(浏览器)是同一个而已。

这样,我们就间接的实现了资源服务器“会话状态”的代理。

默认情况下,我们并不会对所有请求使用有会话状态的代理,而是使用优化了连接请求的代理,如果需要启用代理会话状态的功能需要设置SessionRequired 为true,具体请参考下面的【5.2,代理获取验证码的API】

5,实战--为OAuth2.0添加验证码功能

默认情况下,OAuth2.0的密码授权模式并没有支持验证码功能。但很多网站都有验证码功能,如果验证码生成和校验不是在网关服务器,而是在认证服务器呢?毕竟,认证用户的用户名、密码和当前验证码能够增强认证服务器的“认证能力”。在我们的这个架构中,认证服务器属于后端服务,是不能跟网关服务器放在一起对外访问的,所以也需要进行代理访问。因此,登录的验证码功能是OAuth2.0授权功能和API网关代理相结合的一个比较好的实战案例。

5.1,在登录页添加验证码显示

 登录页不是登录验证的API,所以它在用户入口网站上(Web Port),当前的入口站点程序是项目Demo.OAuth2.Port,我们看到控制器LogonController 的视图文件:

\PWMIS.OAuth2\Demo.OAuth2.Port\Views\Logon\Index.cshtml

@{
    //Layout = null;
    ViewBag.Title = "登录";
}
<script type="text/javascript">

    function Logon() {
        var uid = $("#uid").val();
        var pwd = $("#pwd").val();
        var vcode = $("#vcode").val();

        $.ajax({
            type: "post",
            url: "Logon",
            data: {
                UserName: uid,
                Password: pwd,
                ValidationCode: vcode
            },
            dataType: "json",
            success: function (r) {
                if (r.UserName == "" || r.UserName == null) {
                    alert("登录错误:" + r.LogonMessage);
                } else {

                    alert(r.UserName + "登录成功!");
                    window.location.href = "/Home/Biz";
                }
            }
        });
    }

    $(document).ready(function () {
        //验证码,下面地址实际上会反向代理到认证服务器去,参考代理配置文件
        //下面两个URL地址都可以获取验证码,具体参考代理配置文件
        var vcodeUrl = "/api/Login/CreateValidate";
        //var vcodeUrl = "/api2/Login/CreateValidate";
       
        $.get(vcodeUrl, function (data, status) {
            if (status == "success") {
                $("#spVcode").html(data);
            }
            else {
                alert(status);
            }
        });
     
    });


</script>
<h3>  Login to your account</h3>   
<div class="col-sm-9 col-md-9">
    <div class="form-group">

        <label for="uid">用户名:</label>
        <input type="text" name="LoginId" id="uid" class="form-control" />
    </div>
    <div class="form-group">

        <label for="pwd">密码:</label>
        <input type="password" name="Loginpwd" id="pwd" class="form-control" />
    </div>
    <div class="form-group">

        <label for="vcode">验证码:</label><span id="spVcode">loading</span>
        <!-- <img src="/api2/common/GetValidationCode" />-->
        <input type="text" name="ValidationCode" id="vcode" class="form-control" />
    </div>
    <div class="form-group">
        <input type="submit" value="登录" onclick="Logon()" class="btn btn-default" /><br />

    </div>
</div>

验证码显示在ID为spCode的标签里面在,通过jQuery的Ajax请求验证码的URL地址:"/api/Login/CreateValidate";
由于这个请求有 api前缀,所以它会经过下面的代理,如果请求成功就将验证服务器生成的验证码文字显示在界面上。  

5.2,代理获取验证码的API

 由于验证服务器(地址:【localhost:50697】)验证码功能是使用Session存储的,所以需要在代理配置文件(ProxyServer.config)中的代理路由配置项目添加会话支持,

指定 SessionRequired 属性为 true,如下所示:

 # 登录验证码配置
    {
      "Prefix":"/api/Login/CreateValidate",
      "Host":"localhost:50697",
      "Match":"/api/",
      "Map":"/",
      "SessionRequired":true
    },

这样,Web Port对验证码地址的代理请求的最终地址,就变成了:

http://localhost:50697/Login/CreateValidate

完整的代理配置文件请参考前面的【4.1 代理配置】。

5.3,生成验证码

看到示例的认证服务器项目 Demo.OAuth2.Mvc,在控制器LoginController 添加一个Action,随机生成6位数字验证码,然后存储在当前服务器的会话状态中:

 public string CreateValidate()
        {
            string vcode = (new Random().Next(100000, 999999)).ToString();
            Session["ValidateCode"] = vcode;
            /*
            //使用缓存的方式
            string cache_key = Session.SessionID + "_ValidateCode";
            HttpContext.Cache.Insert(cache_key,vcode,
                null,DateTime.Now.AddMinutes(10),Cache.NoSlidingExpiration, CacheItemPriority.Low,null);
            */
            return vcode;
        }

 

5.4,提交登录的请求增加验证码信息

提交登录请求时候,验证码信息随着用户名,密码信息一起提交到网关服务器的LogonController 上:

[HttpPost]
        [AsyncTimeout(60000)]
        public async Task<ActionResult> Index(LogonModel model)
        {
            LogonResultModel result = new LogonResultModel();
            //首先,调用授权服务器,以密码模式获取访问令牌
            //授权服务器会携带用户名和密码到认证服务器去验证用户身份
            //验证服务器验证通过,授权服务器生成访问令牌给当前站点程序
            //当前站点标记此用户登录成功,并将访问令牌存储在当前站点的用户会话中
            //当前用户下次访问别的站点的WebAPI的时候,携带此访问令牌。
          
            TokenManager tm = new TokenManager(model.UserName, Session.SessionID);
            var tokenResponse = await tm.CreateToken(model.Password,model.ValidationCode);
            if (tokenResponse != null && !string.IsNullOrEmpty(tokenResponse.AccessToken))
            {
                result.UserId = 123;
                result.UserName = model.UserName;
                result.LogonMessage = "OK";
                /* OWin的方式
                ClaimsIdentity identity = new ClaimsIdentity("Basic");
                identity.AddClaim(new Claim(ClaimTypes.Name, model.UserName));
                ClaimsPrincipal principal = new ClaimsPrincipal(identity);
                HttpContext.User = principal;
                */
                FormsAuthentication.SetAuthCookie(model.UserName, false);
            }
            else
            {
                result.LogonMessage = tm.TokenExctionMessage;
            }
            return Json(result);
        }

TokenManager在实例化的时候,将当前用户的会话标识传递进去,在调用生成验证码的方法的时候,一起使用。

 5.5,生成访问令牌的请求中包含验证码信息

在 OAuthClient 工具类中,我们封装了一个可以包含验证码的请求生成验证码的方法:

     /// <summary>
        /// 获取密码模式的访问令牌
        /// </summary>
        /// <param name="userName">用户名</param>
        /// <param name="password">密码</param>
        /// <param name="validationCode">验证码</param>
        /// <returns></returns>
        public Task<TokenResponse> GetTokenOfPasswardGrantType(string userName, string password, string validationCode)
        {
            string scope = string.Format("SessionID:{0} ValidationCode:{1}", this.SessionID, validationCode);
            return GetToken("password", null, userName, password,null, scope);
        }

注意:我们将当前用户在入口网站的会话标识和验证码信息一起作为OAuth2密码授权模式的一个Scope信息(授权范围参数,可选)传递给授权服务器。

在授权服务器的 OpenAuthorizationServerProvider 的GrantResourceOwnerCredentials 方法中,提取出这两个参数信息:

        public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
        {
            string validationCode = "";
            string sessionId = "";
            //其它代码略

            if (context.Scope.Count > 0)
            {
                //处理用户会话标识和验证码
                var temp= context.Scope.FirstOrDefault(p => p.Contains("ValidationCode:"));
                if (temp != null)
                {
                    validationCode = temp.Split(':')[1];
                }

                var temp1 = context.Scope.FirstOrDefault(p => p.Contains("SessionID:"));
                if (temp1 != null)
                {
                    sessionId = temp1.Split(':')[1];
                }
            }

            IdentityService service = new IdentityService();
            LoginResultModel user = await service.UserLogin(context.UserName, context.Password,sessionId, validationCode);
            
            //其它代码略
        }

最后,在IdentityService 的UserLogin 方法中,我们将获这里的会话标识传递到请求认证服务器的请求头里面去:

public async Task<LoginResultModel> UserLogin(string userName, string password,string sessionId, string validationCode)
{
                //其它代码略
                var parameters = new Dictionary<string, string>();
                //parameters.Add("ID", "");
                parameters.Add("UserName", userName);
                parameters.Add("Password", password);
                parameters.Add("ID", sessionId);
                parameters.Add("ValidationCode", validationCode);
                //parameters.Add("Roles", "");

                string loginUrl = System.Configuration.ConfigurationManager.AppSettings["IdentityWebAPI"];
                string sessionCookieName = System.Configuration.ConfigurationManager.AppSettings["SessionCookieName"];
                if (string.IsNullOrEmpty(sessionCookieName))
                    sessionCookieName = "ASP.NET_SessionId";

                //添加会话标识
                CookieContainer cc = new CookieContainer();
                HttpClientHandler handler = new HttpClientHandler();
                handler.CookieContainer = cc;
                handler.UseCookies = true;
                Cookie cookie = new Cookie(sessionCookieName, sessionId);
                cookie.Domain = (new Uri(loginUrl)).Host;
                cc.Add(cookie);

                HttpClient httpClient = new HttpClient(handler);
                LoginResultModel result = null;
              

                var response = await httpClient.PostAsync(loginUrl, new FormUrlEncodedContent(parameters));
                if (response.StatusCode != HttpStatusCode.OK)
                {
                    result = new LoginResultModel();
                    result.UserName = userName;
                    try
                    {
                        result.ErrorMessage = response.Content.ReadAsAsync<HttpError>().Result.ExceptionMessage;
                    }
                    catch 
                    {
                        result.ErrorMessage = "登录错误(错误信息无法解析),服务器状态码:"+response.StatusCode;
                    }
                }
                else
                {
                    result = await response.Content.ReadAsAsync<LoginResultModel>();
                }

               
                return result;

}

这样,就等于是客户端直接请求认证服务器了。

5.6,认证服务器校验验证码

 看到认证服务器的 Demo.OAuth2.Mvc.Controllers的控制器LoginController,在下面的方法中实现认证,校验登录的验证吗。为了简便起见,这里认为登录的都是合法用户,并且只有在有验证码参数才校验验证码:

     [HttpPost]
        public ActionResult Index(UserModel loginModel)
        {
            //由于是登录之前,这里的ID就是会话ID
            string sessionId = loginModel.ID;
            string vcode = Session["ValidateCode"] == null ? "" : Session["ValidateCode"].ToString();
            /*
            //使用缓存的方式
            string cache_key = sessionId + "_ValidateCode";
            string vcode = HttpContext.Cache[cache_key] == null ? "" : HttpContext.Cache[cache_key].ToString();
            */
            LoginResultModel result = new LoginResultModel();

            if (!string.IsNullOrEmpty(loginModel.ValidationCode))
            {
                if (loginModel.ValidationCode == vcode)
                {
                    result.UserName = loginModel.UserName;
                    result.ID = loginModel.ID;

                }
                else
                {
                    result.ErrorMessage = "验证码错误";
                }
            }
            else
            {
                result.UserName = loginModel.UserName;
                result.ID = loginModel.ID;
            }
            
            return Json(result);
        }

 当我们的验证码遇上分布式,遇上OAuth后,一个简单问题也成了复杂问题。PWMIS.OAuth2.0 实现了代理会话的功能,可以解决验证码的会话存储问题。

6,集成开发和部署

看了大家的留言,说使用IdentityServer4也就是几行代码的事情,甚至有朋友点击了“反对”,可能的确觉得我这个”轮子“造的不好,这么多代码,或者不值得造。

文章贴的代码的确太多,但都是讲原理的,而不是大家做集成开发必须的,真正集成开发的时候,PWMIS.OAuth2.0也仅仅需要几行代码。

部署也非常简单,建议部署前先看懂文章前面的架构图,剩下的就是部署相关的几个配置的地方了。

6.1,集成开发

集成开发只需要在资源服务器添加一行代码,在认证服务器实现一个接口,其它就没有了。

6.1.1,集成资源服务器

以ASP.NET MVC WebAPI 5 为例子做系统的资源服务器,比如本解决方案的项目Demo.OAuth2.WebApi ,按照下面的步骤:

1,添加引用 PWMIS.OAuth2.Tools.dll

2,在Global.asax.cs文件的WebApiApplication 添加一行代码:

namespace Demo.OAuth2.WebApi
{
    public class WebApiApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            GlobalConfiguration.Configure(WebApiConfig.Register);
            //添加下面一行代码
            GlobalConfiguration.Configuration.MessageHandlers.Add(new AuthenticationHandler());
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
        }
    }
}

其中 GlobalConfiguration.Configuration.MessageHandlers.Add(new AuthenticationHandler()); 就是我们添加的这一行代码,它表示在API的处理过程中添加一个授权处理器。


3,在配置文件添加 授权服务器 的地址

在Web.config的 appSettings 添加如下面所示的配置:

   <!--OAuth 2.0 授权服务器地址-->
    <add key="OAuth2Server" value="http://localhost:60186"/>

6.1.2 集成认证服务器

本解决方案提供了2种方式来提供认证,一种是直接在授权服务器通过实现指定的接口通过数据库来认证用户是否合法,另一种就是这里说的独立服的认证服务器的方式,也提供了两种方式,一种是ASP.NET WebAPI项目,一种是ASP.NET MVC项目,如果是比较老的ASP.NET Web项目或者非.NET平台的Web项目,参考前文【集成第三个认证服务】。

1,ASP.NET WebAPI 认证服务器

在本解决方案的Demo.OAuth2.IdentityServer 项目中,

在控制器Login 实现类似下面的方法:

 // POST api/<controller>
 public LoginResultModel Post([FromBody]UserModel loginModel)
{
   //代码略
}

UserModel定义:

namespace Demo.OAuth2.IdentityServer.Models
{
    public class UserModel
    {
        /// <summary>
        /// 用户标识或者会话标识
        /// </summary>
        public string ID { get; set; }
        public string UserName { get; set; }
        public string Password { get; set; }
        public string Roles { get; set; }
        public string ValidationCode { get; set; }
    }
}

LoginResultModel 定义:

namespace Demo.OAuth2.IdentityServer.Models
{
    public class UserModel
    {
        /// <summary>
        /// 用户标识或者会话标识
        /// </summary>
        public string ID { get; set; }
        public string UserName { get; set; }
        public string Password { get; set; }
        public string Roles { get; set; }
        public string ValidationCode { get; set; }
    }
}

namespace Demo.OAuth2.IdentityServer.Models
{
    public class LoginResultModel:UserModel
    {
        public string ErrorMessage { get; set; }
    }
}

2,ASP.NET MVC 认证服务器

在本解决方案的Demo.OAuth2.Mvc 项目中,在控制器Login 中:

        [HttpPost]
        public ActionResult Index(UserModel loginModel)
        {
            LoginResultModel result = new LoginResultModel();
            //其它代码略
            return Json(result);
        }

UserModel 定义:

namespace Demo.OAuth2.Mvc.Models
{
    public class UserModel
    {
        /// <summary>
        /// 用户标识或者会话标识
        /// </summary>
        public string ID { get; set; }
        public string UserName { get; set; }
        public string Password { get; set; }
        public string Roles { get; set; }
        public string ValidationCode { get; set; }
    }
}

LoginResultModel 定义:

namespace Demo.OAuth2.Mvc.Models
{
    public class LoginResultModel:UserModel
    {
        public string ErrorMessage { get; set; }
    }
}

3,添加认证服务器配置

在授权服务器的Web.config添加认证服务器的URL配置,如下:

    <!--IdentityLoginMode 认证登录模式,值为DataBase/WebAPI ,默认为WebAPI;配置为WebAPI将使用 IdentityWebAPI 配置的地址访问WebAPI来认证用户-->
    <add key="IdentityLoginMode" value=""/>
    <!--IdentityWebAPI 认证服务器身份认证接口-->
    <!--<add key="IdentityWebAPI" value="http://localhost:61001/api/Login"/>-->
    <add key="IdentityWebAPI" value="http://localhost:50697/Login"/>

 6.2 部署

完成上面的认证服务器的集成开发后,就只剩下部署了。

部署主要需要部署下面几个服务器:

6.2.1,网关服务器

如本解决方案的示例 Demo.OAuth2.Port 项目,如果你像我这种开发架构,采用前后端分离,后端提供API,那么直接将前端发布的静态资源文件和网关项目程序部署到IIS的一个站点即可,程序不用做任何修改。部署之后,仅仅需要做下Web.config的修改和配置下代理网关的配置文件ProxyServer.config ,这两个文件的配置前面已经详细做了说明。

6.2.2,资源服务器

将你集成的资源服务器发布到一个独立的IIS站点即可,有关配置信息前面已经详细做了说明。

6.2.3,认证服务器

将你开发的认证服务器发布到一个独立的IIS站点即可,有关开发和配置信息前面已经详细做了说明。

6.2.4,授权服务器

直接将本解决方案的 PWMIS.OAuth2.AuthorizationCenter 项目程序部署到一个独立的IIS站点,有关配置信息前面已经详细做了说明。另外授权服务器默认使用了SQL SERVER Express Local DB,具体部署方式请参考文章结尾的《授权认证服务设计说明.docx文件说明,或者替换成其它数据库。SOD框架可以灵活的切换不同的数据库,程序无需做任何更改。有关更改授权服务器默认数据的问题,请加QQ群联系。

部署小结

建议以上各个服务器都部署到不同的服务器上,推荐每个服务器都部署到各自的虚拟机上,这样便于观察网络流量和其它资源使用情况。如果某个服务器负载比较高,可以采用负载均衡技术。

 

小结

如果你打算在你的软件项目中也使用OAuth2.0的密码认证方案,PWMIS.OAuth2.0可以作为一个样例解决方案,你可以直接使用,做好API的代理配置即可,不论你的API是不是.NET开发的。

有关本框架使用的接口定义和使用配置的详细内容,可以参考源码附带的文件《授权认证服务设计说明.docx 》,或者直接在线点击查看。

PWMIS.OAuth2.0 是一个开源项目,可以直接在你项目使用。如果有问题,请在本文回帖留言,感谢大家支持!

有关本框架的使用问题,你也可以加我们的QQ群:18215717 (暗号:PDF.NET技术交流)

附注:

为了解决大家觉得本方案过于复杂的问题,添加了第6节,集成开发和部署部署。

大家还觉得复杂么?如果是,请和我们联系。2016.5.7

 

posted on 2018-05-03 17:36  深蓝医生  阅读(13248)  评论(33编辑  收藏  举报

导航