欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

使用微服务架构思想,设计部署OAuth2.0授权认证框架

程序员文章站 2022-07-09 18:31:45
文章目录: 1,授权认证与微服务架构;2,“授权\认证\资源”独立服务的OAuth2.0架构;3,PWMIS OAuth2.0 方案;4,PWMIS API Gateway;5,实战--为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网关代理。

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

使用微服务架构思想,设计部署OAuth2.0授权认证框架

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资源。

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,资源服务器根据用户标识信息,处理业务请求,最后发送响应结果给客户端。

下面是流程图:

使用微服务架构思想,设计部署OAuth2.0授权认证框架

注意:这个流程适用于资源服务器、授权服务器相分离的情况,否则,流程中的第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解决方案介绍

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

使用微服务架构思想,设计部署OAuth2.0授权认证框架

编号

角色

程序集名称

说明

1

授权服务器

PWMIS.OAuth2.AuthorizationCenter

授权中心

ASP.NET Web API+OWIN

2

资源服务器

Demo.OAuth2.WebApi

提供API资源

ASP.NET Web API+OWIN

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授权认证框架

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

使用微服务架构思想,设计部署OAuth2.0授权认证框架

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

使用微服务架构思想,设计部署OAuth2.0授权认证框架

如果能够看到这个页面,我们的OAuth2.0演示程序就成功了。下面我们来看看各个程序集项目的构建过程。

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的定义:

使用微服务架构思想,设计部署OAuth2.0授权认证框架
 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

 

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 方法,用来实现或者调用用户认证服务: 

使用微服务架构思想,设计部署OAuth2.0授权认证框架
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 Ta

                    
                
(0)
打赏 使用微服务架构思想,设计部署OAuth2.0授权认证框架 微信扫一扫

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

使用微服务架构思想,设计部署OAuth2.0授权认证框架
验证码: 使用微服务架构思想,设计部署OAuth2.0授权认证框架