精华内容
下载资源
问答
  • 引言: 本文系《认证鉴权与API权限控制在微服务架构中的设计与实现》系列的第一篇,本系列预计四篇文章讲解微服务下的认证鉴权与API权限控制的实现。1. 背景最近在做权限相关服务的开发,在系统微服务化后,原有的...
    e4170a4b10090a5a255bc8c9dc89be55.png

    引言: 本文系《认证鉴权与API权限控制在微服务架构中的设计与实现》系列的第一篇,本系列预计四篇文章讲解微服务下的认证鉴权与API权限控制的实现。

    1. 背景

    ec97daee3306a21ec9e2f59773c7b036.png

    最近在做权限相关服务的开发,在系统微服务化后,原有的单体应用是基于Session的安全权限方式,不能满足现有的微服务架构的认证与鉴权需求。微服务架构下,一个应用会被拆分成若干个微应用,每个微应用都需要对访问进行鉴权,每个微应用都需要明确当前访问用户以及其权限。尤其当访问来源不只是浏览器,还包括其他服务的调用时,单体应用架构下的鉴权方式就不是特别合适了。在微服务架构下,要考虑外部应用接入的场景、用户–服务的鉴权、服务–服务的鉴权等多种鉴权场景。

    比如用户A访问User Service,A如果未登录,则首先需要登录,请求获取授权token。获取token之后,A将携带着token去请求访问某个文件,这样就需要对A的身份进行校验,并且A可以访问该文件。

    为了适应架构的变化、需求的变化,auth权限模块被单独出来作为一个基础的微服务系统,为其他业务service提供服务。

    2. 系统架构的变更

    ec97daee3306a21ec9e2f59773c7b036.png

    单体应用架构到分布式架构,简化的权限部分变化如下面两图所示。

    (1)单体应用简化版架构图:

    6dad6f7bb8d3bbada5933145497056d9.png

    (2)分布式应用简化版架构图:

    fc501ac232e7bd3e1b4f3c9d92c6c121.png

    分布式架构,特别是微服务架构的优点是可以清晰的划分出业务逻辑来,让每个微服务承担职责单一的功能,毕竟越简单的东西越稳定。

    但是,微服务也带来了很多的问题。比如完成一个业务操作,需要跨很多个微服务的调用,那么如何用权限系统去控制用户对不同微服务的调用,对我们来说是个挑战。当业务微服务的调用接入权限系统后,不能拖累它们的吞吐量,当权限系统出现问题后,不能阻塞它们的业务调用进度,当然更不能改变业务逻辑。新的业务微服务快速接入权限系统相对容易把控,那么对于公司已有的微服务,如何能不改动它们的架构方式的前提下,快速接入,对我们来说,也是一大挑战。

    3. 技术方案

    ec97daee3306a21ec9e2f59773c7b036.png

    这主要包括两方面需求:其一是认证与鉴权,对于请求的用户身份的授权以及合法性鉴权;其二是API级别的操作权限控制,这个在第一点之后,当鉴定完用户身份合法之后,对于该用户的某个具体请求是否具有该操作执行权限进行校验。

    3.1 认证与鉴权

    对于第一个需求,笔者调查了一些实现方案:

    • 分布式Session方案
    • 分布式会话方案原理主要是将关于用户认证的信息存储在共享存储中,且通常由用户会话作为key来实现的简单分布式哈希映射。当用户访问微服务时,用户数据可以从共享存储中获取。在某些场景下,这种方案很不错,用户登录状态是不透明的。同时也是一个高可用且可扩展的解决方案。这种方案的缺点在于共享存储需要一定保护机制,因此需要通过安全链接来访问,这时解决方案的实现就通常具有相当高的复杂性了。
    • 基于OAuth2 Token方案
    • 随着 Restful API、微服务的兴起,基于Token的认证现在已经越来越普遍。Token和Session ID 不同,并非只是一个key。Token一般会包含用户的相关信息,通过验证Token就可以完成身份校验。用户输入登录信息,发送到身份认证服务进行认证。AuthorizationServer验证登录信息是否正确,返回用户基础信息、权限范围、有效时间等信息,客户端存储接口。用户将Token放在HTTP请求头中,发起相关API调用。被调用的微服务,验证Token。ResourceServer返回相关资源和数据。

    这边选用了第二种方案,基于OAuth2 Token认证的好处如下:

    • 服务端无状态:Token机制在服务端不需要存储Session信息,因为Token自身包含了所有用户的相关信息。
    • 性能较好,因为在验证Token时不用再去访问数据库或者远程服务进行权限校验,自然可以提升不少性能。
    • 现在很多应用都是同时面向移动端和Web端,OAuth2 Token机制可以支持移动设备。
    • 最后一点,也是挺重要的,OAuth2与Spring Security结合使用,Spring Security OAuth2的文档写得较为详细。

    OAuth2根据使用场景不同,分成了4种模式:

    • 授权码模式(authorization code)
    • 简化模式(implicit)
    • 密码模式(resource owner password credentials)
    • 客户端模式(client credentials)

    对于上述OAuth2四种模式不熟的同学,可以自行百度OAuth2,阮一峰的文章有解释。常使用的是password模式和client模式。

    3.2 操作权限控制

    对于第二个需求,笔者主要看了Spring Security和Shiro。

    1. Shiro
    2. Shiro是一个强大而灵活的开源安全框架,能够非常清晰的处理认证、授权、管理会话以及密码加密。Shiro很容易入手,上手快控制粒度可糙可细。自由度高,Shiro既能配合Spring使用也可以单独使用。
    3. Spring Security
    4. Spring社区生态很强大。除了不能脱离Spring,Spring Security具有Shiro所有的功能。而且Spring Security对Oauth、OpenID也有支持,Shiro则需要自己手动实现。Spring Security的权限细粒度更高。但是Spring Security太过复杂。

    看了下网上的评论,貌似一边倒向Shiro。大部分人提出的Spring Security问题就是比较复杂难懂,文档太长。笔者综合评估了下复杂性与所要实现的权限需求,以及上一个需求调研的结果,最终选择了Spring Security。

    4. 系统架构

    ec97daee3306a21ec9e2f59773c7b036.png

    4.1 组件

    Auth系统的最终使用组件如下:

    OAuth2.0 JWT TokenSpring SecuritySpring boot

    4.2 步骤

    主要步骤为:

    • 配置资源服务器和认证服务器
    • 配置Spring Security

    上述步骤比较笼统,对于前面小节提到的需求,属于Auth系统的主要内容,笔者后面会另写文章对应讲解。

    4.3 Endpoint

    提供的Endpoint:

    /oauth/token?grant_type=password #请求授权token/oauth/token?grant_type=refresh_token #刷新token/oauth/check_token #校验token/logout #注销token及权限相关信息

    4.4 Maven依赖

    主要的jar包,pom.xml文件如下:

    com.auth0 java-jwt 2.2.0org.springframework.cloud spring-cloud-starter-security 1.2.1-SNAPSHOTorg.springframework.cloud spring-cloud-starter-oauth2 1.2.1-SNAPSHOTorg.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-tomcat org.springframework.boot spring-boot-starter-jersey 1.5.3.RELEASE

    4.5 AuthorizationServer配置文件

    AuthorizationServer配置主要是覆写如下的三个方法,分别针对Endpoints、Clients、Security配置。

    @Overridepublic void configure(AuthorizationServerSecurityConfigurer security) throws Exception { security.tokenKeyAccess("permitAll()").checkTokenAccess("isAuthenticated()");}@Overridepublic void configure(ClientDetailsServiceConfigurer clients) throws Exception {//配置客户端认证 clients.withClientDetails(clientDetailsService(dataSource));}@Overridepublic void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { //配置token的数据源、自定义的tokenServices等信息 endpoints.authenticationManager(authenticationManager) .tokenStore(tokenStore(dataSource)) .tokenServices(authorizationServerTokenServices()) .accessTokenConverter(accessTokenConverter()) .exceptionTranslator(webResponseExceptionTranslator);}

    4.6 ResourceServer配置

    资源服务器的配置,覆写了默认的配置。为了支持logout,这边自定义了一个CustomLogoutHandler并且将logoutSuccessHandler指定为返回http状态的HttpStatusReturningLogoutSuccessHandler。

    @Overridepublic void configure(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .requestMatchers().antMatchers("/**") .and().authorizeRequests() .antMatchers("/**").permitAll() .anyRequest().authenticated() .and().logout() .logoutUrl("/logout") .clearAuthentication(true) .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()) .addLogoutHandler(customLogoutHandler());

    4.7 执行Endpoint

    1、首先执行获取授权的Endpoint。

    method: post url: http://localhost:12000/oauth/token?grant_type=passwordheader:{Authorization: Basic ZnJvbnRlbmQ6ZnJvbnRlbmQ=,Content-Type: application/x-www-form-urlencoded}body:{username: keets,password: ***}

    上述构造了一个post请求,具体请求写得很详细。username和password是客户端提供给服务器进行校验用户身份信息。header里面的Authorization是存放的clientId和clientSecret经过编码的字符串。

    返回结果如下:

    { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJYLUtFRVRTLVVzZXJJZCI6ImQ2NDQ4YzI0LTNjNGMtNGI4MC04MzcyLWMyZDYxODY4ZjhjNiIsImV4cCI6MTUwODQ0Nzc1NiwidXNlcl9uYW1lIjoia2VldHMiLCJqdGkiOiJiYWQ3MmIxOS1kOWYzLTQ5MDItYWZmYS0wNDMwZTdkYjc5ZWQiLCJjbGllbnRfaWQiOiJmcm9udGVuZCIsInNjb3BlIjpbImFsbCJdfQ.5ZNVN8TLavgpWy8KZQKArcbj7ItJLLaY1zBRaAgMjdo
    展开全文
  • 微服务架构应该是什么样子 ...因为微服务的拆分不可避免的造成了系统的复杂性,团队间的合作管理和持续的交付等等,都是一项比较复杂的工程,如果没有好的团队管理规范和持续交付的流程等微服务是很难落地的。 ...

    微服务架构应该是什么样子

    在这之前先看一看一个微服务架构落地以后应该是什么样子的。平常所有的微服务架构更多的是从框架来讲的像Dubbo,SpringCloud等,从整个SpringCloud的生态来讲它也只包含微服务的一部分。因为微服务的拆分不可避免的造成了系统的复杂性,团队间的合作管理和持续的交付等等,都是一项比较复杂的工程,如果没有好的团队管理规范和持续交付的流程等微服务是很难落地的。

    下面简单介绍一下上图中微服务架构的每一层的功能和作用:

    1、基础设施层,这一项除非自己搭建IDC,基本上现在的阿里云、腾讯云和百度云等都已经很好的支撑,特别是对于小的公司来说,更节省成本。

    2、平台服务层,对于现有的微服务能够快速动态部署那就是Docker了,再加上现有k8s等容器管理工具等,更是让微服务的部署如虎添翼,如果系统已经达到已经规模以后,可以考虑使用此种方式进行动态的扩容,一般情况下使用Docker就能解决部署问题了。

    3、支撑服务层,这一层跟微服务框架贴的非常近了,像SpringCloud已经自带了很多功能,像注册中心、配置中心、熔断限流和链路跟踪等,Dubbo也自带注册中心。

    4、业务服务层,这一层主要解决的是业务系统如何使用微服务进行解耦,各业务模块间如何进行分层交互等,形成了以基础服务模块为底层和以聚合服务为前端的“大中台小前台”的产品策略。

    5、网关服务层,这一层解决了权限控制、外部调用如何进行模块的负载均衡,可以实现在该层实现权限和流量的解耦,来满足不同的端的流量和权限不同的需求。

    6、接入层,该层主要是为了解决相同网关多实例的负载均衡的问题,防止单点故障灯。

    7、微服务开发框架,现在流行的微服务框架主要是SpringCloud和Dubbo,SpingCloud提供了更加完整的生态,Dubbo更适合内部模块间的快速高并发的调用。

    8、持续交付流水线,快速进行需求迭代,从提交代码到部署上线,能够快速的交付。

    9、工程实践与规范,这一项做不好,那整个微服务实施起来绝对是痛不欲生啊,基础模块如何定义,基础模块如何与其他模块解耦,如何进行版本的管理这个我在之前的使用Git和Maven进行版本管理和迭代的方法进行了说明。

    10、端到端的工具链,这里就是敏捷运维工具,从研发代码到最终上线到生产环境,任何一部都要有工具去实现完成,实现点一个按钮就能最终上线的系统。

    以上讲了实现微服务架构应该要做哪些事情,现在可以想想你的微服务架构到底落地到生成程度了,闲话少说,书归正传,今天是用APP扫码登录网站这个功能来进行举例说明应该从哪些方面进行微服务的落地实践。

    网站扫码登录功能

    这个功能是指在网站上选择使用二维码扫码登录,网站展示二维码,使用已经登录的应用APP扫码并确认登录后,网站就能登录成功,这既简单快捷,又提高了安全性。现在实现扫码登录网站的技术基本上有两种,一种就是轮询,另一种就是长连接,长连接又分为服务器端单向通信和双向通信两种,服务端单向通信只能由服务器端向客户端一直发送数据,双向通信是客户端和服务器端可以相互发送数据。像微信、京东和淘宝都是采用轮询的方式进行扫码登录的,一直使用轮询的方式在请求服务器端。今天我设计的这个扫码登录的功能,是采用的长连接能够双向通信的WebSocket的方式实现的。

    网站扫码实现流程

    1、用户在网站上登录时选择扫码登录。

    2、服务器端收到请求,生成一个临时的令牌,前端生成带令牌的链接地址的二维码,在浏览器上显示。

    3、PC端同时要与后台建立起websocket连接,等待后台发送登录成功的指令过来。

    4、用户用应用扫码,这个时候如果已经登陆过,后台就能获取到当前用户的token,如果没有登录到系统中,需要提前做登录。

    5、用户在应用APP上已经显示了是否确认登录的按钮。

    6、用户点击确认按钮,应用APP发起后端的api调用。

    7、后端接收到调用,根据临时名牌向websocket模块发送当前用户的token,pc端接收到登录成功,跳转到用户个人首页。如果用户点击了取消按钮,会根据uid向websocket模块发送取消登录的指令。

    技术的选型

    1、微服务框架的选择

    现在比较流行的是SpringCloud和Dubbo这两个框架,RPC的微服务框架还有Motan都不错,这里我使用SpringCloud和Dubbo这两个框架,使用SpringCloud实现网关和聚合服务模块并对外提供http服务,使用Dubbo实现内部模块间的接口调用。注册中心使用Zookeeper,Zookeeper能够同时支持SpringCloud和Dubbo进行注册。

    2、Websocket框架选择

    其实Spring现在已经具备websocket的功能了,但是我没有选择使用它,因为它只是实现了websocket的基本功能,像websocket的集群,客户端的管理等等,使用spring实现的话都得从零开始写。之前就一直使用netty-socketio做websocket的开发,它具备良好的集群、客户端管理等功能,而且它本身通知支持轮询和websocket两种方式,所以选它省事省时。

    3、存储的选择

    临时令牌存放在redis中,用来进行websocket连接时的验证,防止恶意的攻击,用户数据放在mysql中。

    4、源码管理工具和构建工具的选择

    使用Git作为代码管理工具,方便进行代码持续迭代和发布上线,使用Gitlab作为源码服务器端,可以进行代码的合并管理,使整个代码质量更容易把控。采用Maven做为构建工具,并使用nexus创建自己的Maven私服,用来进行基础服务版本的管理和发布。搭建Sonar服务器,Maven中集成Sonar插件进行代码质量的自动化检测。

    5、持续构建和部署工具

     采用Docker部署的方式,快速方便。采用Jekins做持续构建,可以根据git代码变更快速的打包上线。

    模块功能设计

    根据《微服务架构:如何用十步解耦你的系统?》中微服务解耦的设计原则:

    1、将Websocket作为服务独立出来只用来进行数据的通信,保证其功能的单一性,独立对外提供SocketApi接口,通过Dubbo的方式来调用其服务。

    2、将用户功能作为服务独立出来,进行用户注册和登录的功能,并对外提供UserApi接口,通过Dubbo的方式来调用。

    3、对外展示的功能包括页面和静态文件都统一到WebServer模块中,需要操作用户数据或者需要使用Websocket进行通信的都统一使用Dubbo调用。

    4、对于基本的权限认证和动态负载均衡都统一放到Gateway模块中,Gateway可以实现http的负载均衡和websocket的负载均衡。

    5、如果访问量非常大时,就考虑将Gateway分开部署,单独进行http服务和websocket服务,将两者的流量解耦。

    6、webserver端访问量大时,可以考虑将静态页面发布到CDN中,减少该模块的负载。

    开发规范解耦公共服务

    指定良好的开发管理规范,使用Git做好版本代码的分支管理,每个需求迭代使用单独的分支,保证每次迭代都可以独立上线,Maven私服中每次SocketApi和UserApi的升级都要保留历史版本可用,Dubbo服务做好多版本的兼容支持,这样就能将基础公共的服务进行解耦。

    总结

    微服务的引入不仅仅是带来了好处,同时也带来了系统的复杂性,不能只从框架和代码的角度来考虑微服务架构的落地,更要从整个管理的角度去考虑如何括地,否则使用微服务开发只会带来更多麻烦和痛苦。

    转载于:https://www.cnblogs.com/dz11/p/10132717.html

    展开全文
  • 的处理,典型的设计如tomcat中的session。 例如登录:用户登录后,我们把登录者的信息保存在服务端session中,并且给用户一个cookie值,记 录对应的session。然后下次请求,用户携带cookie值来,我们就能识别到对应...

    首先来看看有状态和无状态是什么。

    无状态登录原理

    1.1.什么是有状态?

    有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求
    的处理,典型的设计如tomcat中的session。

    例如登录:用户登录后,我们把登录者的信息保存在服务端session中,并且给用户一个cookie值,记
    录对应的session。然后下次请求,用户携带cookie值来,我们就能识别到对应session,从而找到用户
    的信息。

    缺点是什么?

    • 服务端保存大量数据,增加服务端压力

    • 服务端保存用户状态,无法进行水平扩展

    • 客户端请求依赖服务端,多次请求必须访问同一台服务器

    1.2.什么是无状态?

    微服务集群中的每个服务,对外提供的都是Rest风格的接口。而Rest风格的一个最重要的规范就是:服
    务的无状态性,即:

    • 服务端不保存任何客户端请求者信息

    • 客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份

    带来的好处是什么呢?

    • 客户端请求不依赖服务端的信息,任何多次请求不需要必须访问到同一台服务

    • 服务端的集群和状态对客户端透明

    • 服务端可以任意的迁移和伸缩

    • 减小服务端存储压力

    什么是RSA加密?

    RSA称为非对称加密,加密技术是对信息进行编码和解码的技术,编码是把原来可读信息(又称明文)译成代码形式(又称密
    文)其逆过程就是解码(解密),加密技术的要点是加密算法,RSA会根据你给的 盐值 生成私钥和公钥:

    • 私钥:通过私钥加密的数据使用私钥或者公钥来解密。
    • 公钥:通过公钥加密的数据只能使用私钥来解密。
    • 优点:安全,难以破解
    • 缺点:算法比较耗时

    什么是JWT?

    JWT,全称是Json Web Token, 是JSON风格轻量级的授权身份认证规范,可实现无状态、分布式的
    Web应用授权。

    看名知意,其实就是一种加密方式,分为三部分:

    • Header(头部):一般只包含两部分信息:

      • 声明加密的算法,这里使用的是HS256的加密算法。
      • 声明Token的类型,这里使用的是JWT的风格类型。
    • Payload(载荷):存放一些有效数据比如用户ID、用户名称,解密之后可以获取载荷中的用户信息,因为采用Base64编码格式,所以可以被解码,不要放敏感信息,例如登录密码之类的。

    • Signature(签名):Signature由headerpayload经过 base64 编码后加 盐值得到的。

      • 生成Signature的算法如下:
    var encodedString = base64UrlEncode(header) + "." + base64UrlEncode(payload); 
        HMACSHA256(encodedString, '!Q@#$%^%&');
    

    在分布式下完成SSO单点登录

    只使用JWT来完成单点登录

    在这里插入图片描述在这里插入图片描述在这里插入图片描述

    有了JWT为什么还需要RSA?

    可以发现,每次鉴权都需要访问鉴权中心,系统间的网络请求频率过高,效率略差,鉴权中心的压力较大。

    这时就用到了前面说的RSA,我们可以把私钥留在授权中心,把公钥给网关或者其他微服务,那么就可以在网关或其他微服务当中直接解密JWT了,这样做的好处是减少了授权中心的压力。

    使用JWT+RSA完成单点登录

    在这里插入图片描述在这里插入图片描述在这里插入图片描述

    展开全文
  • 去年的时候,公司开发一款新产品,但人手不够,将B/S系统的Web开发外包,外包团队使用Vue.js框架,调用我们的WebAPI,但是这些WebAPI并不在一台服务器上,甚至可能是第三方提供的WebAPI。同时处于系统安全的架构设计...

    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

     

    展开全文
  • 微服务在线教育平台从设计到实现全流程讲解,前后端分离...业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第1季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第3季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第8季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第12季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第4季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第7季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第15季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第10季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第13季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第6季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第16季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第14季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第5季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第9季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。 本系列教程共计16季,本章节为第11季 提醒购买完整课程...
  • 业务实现上包含第三方登录、单点登录、短信验证、手机注册、AOP身份验证、Excel导入导出,全系统系统RESTful风格API接口设计,使用Swagger2生成接口文档。本系列教程共计16季,本章节为第2季提醒购买完整16季...
  • 1.3.10 微服务架构之单点登录 20 1.3.11 Netty百万长连接并发服务 20 1.3.12 Haar+Adaboost人脸识别技术 21 1.3.13 MVVM模式下Hybird跨平台应用前端框架 23 第2章 系统需求分析 25 2.1 系统功能总体介绍 25 2.1.1 ...
  • 演示环境有全方位监控示例:日志系统+ APM系统+ GPE系统 Gitee地址: ://gitee.com/zlt2000/microservices-platform Github地址: : 前一级分离企业级微服务架构 主要针对解决微服务和业务开发时常见非功能性...
  • 通过使用Spring Security OAuth2,我们可以在商家后台中进行单点登录(SSO)设计,从而为多个微服务应用的系统集成,使用统一安全控制管理。 SSO设计分为服务端和客户端两大部分。SSO服务端为每个应用提供了统一...
  • 这是在微服务架构中使用以下方法非常简化的保险销售系统的示例: .NET Core 3.1 实体框架核心 中介 貂 尤里卡 豹猫 JWT代币 轻松自在 RawRabbit NHibernate 波莉 NEST(ElasticSearch客户端) 精巧的人 动态...
  • 1,授权认证与微服务架构 1.1,由不同团队合作引发...另一方面,我们这个新产品还有一个C/S系统,C端登录的时候,要求统一到B/S端登录,可以从C端无障碍的访问任意B/S端的页面,也可以调用B/S系统的一些API,所以又增
  • 通过使用Spring Security OAuth2,我们可以在商家后台中进行单点登录(SSO)设计,从而为多个微服务应用的系统集成,使用统一安全控制管理。SSO设计分为服务端和客户端两大部分。SSO服务端为每...
  • 书籍《微服务设计》,地址:https://book.douban.com/subject/26772677/ 我们需要考虑安全,包括数据从一个点到另一个点传输过程安全,也包括底层操作系统及网络安全。 1、身份验证和授权 常见单点登录...

空空如也

空空如也

1 2 3 4 5 ... 9
收藏数 164
精华内容 65
关键字:

微服务登录系统的设计