使用微服務架構思想,設計部署API代理網關和OAuth2.0受權認證框架

1,受權認證與微服務架構

1.1,由不一樣團隊合做引起的受權認證問題

去年的時候,公司開發一款新產品,但人手不夠,將B/S系統的Web開發外包,外包團隊使用Vue.js框架,調用咱們的WebAPI,可是這些WebAPI並不在一臺服務器上,甚至多是第三方提供的WebAPI。同時處於系統安全的架構設計,後端WebAPI是不能直接暴露在外面的;另外一方面,咱們這個新產品還有一個C/S系統,C端登陸的時候,要求統一到B/S端登陸,能夠從C端無障礙的訪問任意B/S端的頁面,也能夠調用B/S系統的一些API,因此又增長了一個API網關代理。javascript

整個系統的架構示意圖以下:css

注:上圖還有一個iMSF,這是一個實時消息服務框架,這裏用來作文件服務,參見《消息服務框架使用案例之--大文件上傳(斷點續傳)功能》。在Web端會讀取這些上傳的文件。html

1.2,微服務--分佈式「最完全」的分

1.2.1,爲何須要分佈式

大部分狀況下,若是你的系統不是很複雜,API和受權認證服務,文件服務均可以放到一臺服務器:Web Port 服務器上,但要把它們分開部署到不一樣的站點,或者不一樣的服務器,主要是出於如下考慮:前端

1,職責單一:每個服務都只作一類工做,好比某某業務WebAPI,受權服務,用戶身份認證服務,文件服務等;職責單一使得開發、部署和維護變得容易,好比很容易知道當前是受權服務的問題,而不是業務API問題。java

2,系統安全:採用內外網隔離的方案,一些功能須要直接暴露在公網,這須要付出額外的成本,好比帶寬租用和安全設施;另一些功能部署在內網,這樣可以提供更大的安全保證。git

3,易於維護:每個服務職責都比較單一,因此每個服務都足夠小,那麼開發維護就更容易,好比要更新一個功能,只須要更新一個服務而不用全部服務器都暫停;另外一方面也更加容易監控服務器的負載,若是發現某一個服務器負載太大能夠增長服務器來分散負載。github

4,第三方接入:如今系統愈來愈複雜,內部的系統極可能須要跟第三方的系統對接,一塊兒協同工做;或者整個系統一部分是 .NET開發的,一部分又是Java平臺開發的,兩個平臺部署的環境有很大差別,無法部署在一塊兒;或者雖然同是ASP.NET MVC,可是一個是MVC3,一個是MVC5,因此須要分別獨立部署。web

以上就是各個服務須要分開部署的緣由,而這樣作的結果就是咱們常說的分佈式計算了,這是天然需求的結果,不是爲了分而才分。ajax

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

相關文章
相關標籤/搜索