客官,來看看AspNetCore的身份驗證吧


開篇

這段時間潛水了太久,終於有時間可以更新一篇文章了。

xx

通過本篇文章您將Get:

  • Http的一些身份驗證概念
  • AspNetCore中實現身份驗證方案
  • JWT等概念的基礎知識
  • 使用Bearer TokenWebAPI進行保護
  • 一些驗證中的小細節
  • 微信小程序驗證的源代碼

時長為大約有十五分鍾,內容豐富,建議先投幣再上車觀看😜

本文附帶了普通Bearer JwtToken驗證和微信小程序驗證登錄的源代碼,效果圖您可以參考下方的Gif圖片。

gif

該項目的倉庫地址,您可以點擊這里進行跳轉

注:該項目通過uni-app來編寫,沒有了解過的朋友也不用擔心,本文最后會對該演示項目進行一些說明解釋。

正文

對於大多數應用系統來說,幾乎都離不開身份驗證。因為我們需要保護一些數據,不讓“非法”用戶獲取。所以我們必須得根據自身項目情況來添加對身份驗證的支持功能。

在這之前,我們先不要考慮什么Bearer,JWT,OpenId等概念,忘掉他們,讓我們從0開始。

假如我們現在有一個Web Api應用程序,采用了AspNetCore來編寫。在沒有任何標准協議和框架的支持下,我們會如何對一個用戶進行身份驗證呢?

最基礎的驗證

或許您已經想到了,既然用戶是通過賬號和密碼來登錄的,那么我就可以通過賬號和密碼來對他進行驗證呀。讓用戶直接把用戶名密碼傳給我,我不就知道是他了嗎?

那怎么傳值呢?用Get? 比如下方的這個請求:

> http://your-address/Book/Get?user='myName'&pwd='abc123'

這樣每次請求的時候我就能夠得到用戶名密碼了,然后通過和數據庫校驗就能夠判斷當前的用戶是不是通過了。

但是這種方式您很快就能發現問題,每個api不都要增加一些參數嗎?url是一個很普通的東西,這樣很容易就把賬號密碼泄露了。

所以,我們改變一下方案,把用戶名密碼放到Http的請求頭(Header)里面,該項的Header Key值叫做Authorization

那么我們的請求可能就像這樣了:

Request URL: http://your-address/Book/Get

Request Header:
:method: GET
Authorization:myName:abc123

當然,如果把用戶名密碼信息在加密一下就更好了。為了讓服務端能夠解密,所以采用了Base64加密。所以請求就可能成為了這個樣子:

Request URL: http://your-address/Book/Get

Request Header:
:method: GET
Authorization:bXlOYW1lOmFiYzEyMw==

這樣服務端很容易就能夠通過Header來進行用戶驗證。 獲取header的Authorization項 -> 進行Base64解密 -> 根據數據庫內容判斷用戶名和密碼 -> 驗證通過。

這種驗證方案是不是很簡單呢? 但是到這里,您可能會說,這種方案也太簡陋了吧。如果我攔截到了請求的包,那不等於這個人直接把用戶名密碼送到我的手里嗎?

確實是這樣的,如果我們在進行Http請求的時候受到了中間人攻擊,那么賬號和密碼都將被泄露,“非法分子”可以拿着得到的用戶名和密碼登錄系統進行任何操作。

所以,我們必須采用Https傳輸。這樣,中間人得到的信息是加密的,他也無法解析出來。

而這種直接把用戶名密碼放置在請求頭中傳輸的方案,正是伴隨Http協議一同提出的Basic驗證方案:Wiki Basic access authentication

身份信息自包含

當身份驗證服務和咱們的業務系統粘連在一起的時候(比如傳統的單體環境),基礎的驗證方案其實能夠很好的滿足咱們的需求。但是,當身份驗證服務被獨立出來,我們就需要使用過多的成本去進行驗證:比如身份驗證服務部署在服務器A,而業務服務在服務器B,如果按照上面的驗證方案,我們每訪問一次服務器B,那么服務器B就需要把該請求所攜帶的信息轉發至服務器A去驗證,服務器A根據轉發過來的Header中的Authorization項,從數據庫中或者內存中查詢對應的身份信息,進行通過或者拒絕操作,然后服務器B再根據服務器A所返回的信息進行處理。

而網絡通信的成本是昂貴,假如不需要身份驗證的話,只需要一次就能夠完成業務,而現在,會被拆分成多次,時間開銷是很大的。再一點,所有的訪問壓力都會被推到身份驗證服務器,如果有B,C,D三個業務服務器,那豈不是所有的服務器都要於身份驗證服務器進行交互?

所以,我們必須得使用另外的手段來應對這種身份驗證方案,那就是自包含的身份信息:當身份驗證服務器驗證通過時,就發一個類似於令牌的東西給客戶端,與上面的那種方案較為不同的是,該令牌是一種包含了必要驗證信息的加密字符串。

比如我們每次身份驗證都是為了獲取到userId這一項信息。基礎驗證方案中,我們通過傳遞usernamepassword來獲取userId。而現在,我們就直接讓令牌來包含userId這一項內容,而以后我們每次攜帶該令牌去訪問API的時候,就不需要再到數據庫中進行查找用戶來獲取Id了。這樣就能大幅度夠減緩服務器的查找壓力。

用戶傳遞了usernamepassword到身份驗證服務器,服務器通過與數據庫中的用戶信息進行匹配,發現是userId = 3的用戶。此時身份驗證服務器則產生一個類似於userId:3&userName:myName的字符串返回給用戶,下一次用戶訪問時,就攜帶上該字符串在請求頭部進行傳遞,而其它的服務器看到該信息后,就認為此刻的用戶是userId為3的用戶,則返回該用戶對應的數據。

上方是咱們根據已有的結論來模擬的驗證方案,但是您會發現,該方案其實有很大的漏洞。 比如客戶端接收到了userId:3&userName:myName的驗證令牌,但是他突然起了壞心眼,既然我是id為3的用戶,那肯定在我之前就有id為2或者為1的用戶,那我直接改一下這個數值,然后再進行訪問,是不是就可以得到其它用戶的信息了呢? 當然,答案肯定為是的!

所以我們必須要做的事情就是:“將結果加密”。當然,加密的方式有對稱加密非對稱加密。對稱加密就是加密和解密共用一個密匙,比如密碼為123,那么加密使用123來加密,解密也需要用123來解密,所以密匙是必須得嚴格保護,不然泄露之后就涼涼啦。而非對稱加密就是產生一個公鑰私鑰,可以用私鑰來加密,然后別人可以用公鑰來進行解密驗證。

在咱們傳輸令牌的這個案例中,對稱加密非對稱加密咱們都可以使用。假如我們此處使用了AES的對稱加密算法,而加密的密碼為12345,那么userId:3&userName:myName將會被我們加密為:

JX9lHmBFuhckNOP3sGG0/X0TooCjlsXBGyI3Gz1UudA=

此時,客戶就沒有辦法再修改該內容了。而業務服務器,使用12345來對該令牌進行解密就能夠獲取到信息了。

但是有些時候,身份驗證服務器不願意與其它業務服務器共享12345這個密匙,因為知道的人越多,泄露的風險就越大,那么他就可以使用非對稱加密的方案。身份驗證服務器獨享一個私鑰來進行加密,而業務服務器可以從身份驗證服務器處獲取到公鑰來進行驗證。

這樣我們就完成了自包含的身份信息令牌的頒發,但是不要急,還有問題。因為這個令牌的生效區間是什么時候呢? 我們現在只是頒發了信息,但是您想啊,這樣不是一發出去了之后就一發不可收拾了嗎? 用戶可以一直使用該令牌來進行訪問,即使他已經更改了密碼,但是令牌還是依舊生效的,如果令牌一泄露,那他的賬號就永久的涼涼了。

所以,我們必須得給這個令牌一個過期時間,如果令牌超過了過期時間,那么該令牌就是無效的。所以我們依舊讓過期時間被自包含在令牌信息中,所以原有的令牌就可能被我們改成這樣:userId:3&userName:myName&expireTime:2020/02/02 12:00。這樣業務服務器進行驗證的時候,就首先驗證是否過期就行啦,果真爽歪歪~。

Javascript Object大家族

在看了上面介紹的基礎身份驗證方案之后,相信您已經對身份驗證有了一點的了解和認識。其實,上面的方案也是現代身份驗證的雛形,但是本質上原理是相通的。

既然是雛形,那么現在肯定有更完善的身份驗證方案。所以,請抬好小板凳,准備好瓜子花生,即將進行飛升。

接下來,您將看到WebApi最為常見的身份驗證方案JWT。在提及JWT之前,我想您可能已經聽過OAuth2.0或者OpenID Connect等標准驗證框架,亦或是在.NET平台下,它們的實現方案IdentityServer

關於OAuth2.0和OpenID的概念,由於篇幅有限,將會在下一篇文章中為大家帶來介紹.

來看一看OpenID Connect的架構圖,您可以看到,JWT是作為它的底成實現支持。所以,對於了解JWT來說是必要的。

OPENID

但是在該圖中,除了JWT您還會看到其它的類似單詞,比如:JWSJWEJWK等等。但是當您想去對他們進行了解的時候,很抱歉,百度居然不靠譜了。😭

不要慌張,在有了上面基礎驗證方案的思路之后,這些對於您來說都不是問題。

這些JW*的命名,其實他們都屬於一種東西:Javascript Object Signing and Encryption (JOSE)。從命名中其實就可以看出,它是負責了簽名加密解密的工作。而Javascript Object對於大家來說就更不陌生了,它定義了如何組織一套數據結構的規范。

在結合我們上面講的那個自包含的驗證,當時我們定義了一個類似於userId:3&userName:myName&expireTime:2020/02/02 12:00的令牌,該令牌我使用了&符號來進行拼接,雖然能符合我們的需求,但是很顯然這不是一個業界通用的做法。這就導致其它系統與咱們的系統對接的時候都需要重寫一次該驗證的處理流程。

所以,我們需要一個更通用,大家都認可的規范。而JOSE則正是充當了這樣的一個角色。

對於Python用戶來說,對於jose可能不是太陌生,因為在Py中有着很出名的jose處理庫。而在.NET中就沒有對該關鍵字很出名的支持庫。

好啦,回到這些規范上來,我們先來看看他們各自的一些定義:

術語 說明
JWS JSON Web Signature (RFC7515) 定義了使用JSON進行數字簽名的過程。
JWE JSON Web Encryption (RFC7516) 定義了使用JSON加密的過程。
JWA JSON Web Algorithm (RFC7518) 定義用於數字簽名或加密的算法列表
JWK JSON Web Key (RFC7517) 定義密碼密鑰和密鑰集的表示方式。

JWA

JWA規范了算法的簡寫描述,比如以下是應用於JWT的某些算法,就好像咱們在JWT中經常看到的alg:HS256,該HS256就是在該規范中被解釋的術語,代表了使用HMAC對稱加密后再使用SHA-256進行哈希摘要。

說明
HS256 HMAC w/ SHA-256 hash
HS384 HMAC w/ SHA-384 hash
RS256 RSA PKCS v1.5 w/ SHA-256 hash
RS384 RSA PKCS v1.5 w/ SHA-384 hash
ES256 ECDSA w/ P-256 curve and SHA-256 hash

JWS

JWS規范指出了使用JSON格式來表示加密內容。JWS由三個部分所組成:JOSE HeaderJWS PayloadJWS Signature

而JWS的核心在於第三個部分:JWS Signature簽名。它根據前面的兩個部分來計算處第三個部分的簽名,防止該信息再傳遞的過程中被修改。(想一想我們最初的加密自包含令牌)。

簽名的計算規則如下:

摘要算法(加密算法(ASCII(BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload))))

比如我們有這樣的一個頭部荷載內容:

{
    "typ":"JWT",
    "alg":"HS256"
}

{
    "iss":"joe",
    "exp":1300819380,
    "http://example.com/is_root":true
}

那么我們會對頭部進行編碼加密,通過BASE64URL加密,則對應內容為eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9。同樣我們再使用BASE64URL加密荷載部分,對應內容為eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFt cGxlLmNvbS9pc19yb290Ijp0cnVlfQ

因為BASE64URL加密是可逆的,所以我們還需要對這些內容進行簽名,才能在傳遞時保護數據安全。根據頭部的信息我們得知使用的是HS256,這就對應着JWA里面的信息,我們需要通過HMAC來加密,然后再使用SHA-256進行摘要。最終再使用BASE64URL編碼該簽名,我們就能夠得出簽名的最終結果為:dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

最后,將三個部分通過.鏈接起來,就構成了一個整體加密內容:

 eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.
 eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.
 dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

看到這里,您可能會說,這不是JWT嗎?格式明明一模一樣,是的,JWSJWA等就是JWT的基礎,JWT在這之上提供了新的規范,比如荷載中的Claim等信息。下面將會講到。

JWK

JWK規范定義了如何以JSON格式表示非對稱密鑰,並引入了密鑰集集合(JWKS),該集合為提供者發布簽名和加密密鑰提供了一種方法。

來看看JWK的格式例子:

{
    "kty":"RSA",
    "kid":"i0wnn",
    "use":"sig",
    "n":"mdrLAp5GR8o5d5qbwWTYqNGuSXHTIE6w9HxV445oMACOWRuwlOGVZeKJQXHM9cs5Dm7iUfNVk4pJBttUxzcnhVCRf
9tr20LJB7xAAqnFtzD7jBHARWbgJYR0p0JYVOA5jVzT9Sc-j4Gs5m8b-am2hKF93kA4fM8oeg18V_xeZf11WWcxnW5YZwX
9kjGBwbK-1tkapIar8K1WrsAsDDZLS_y7Qp0S83fAPgubFGYdST71s-B4bvsjCgl30a2W-je9J6jg2bYxZeJf982dzHFqV
QF7KdF4n5UGFAvNMRZ3xVoV4JzHDg4xe_KJE-gOn-_wlao6R8xWcedZjTmDhqqvUw",
    "e":"AQAB"
}

其中Kty(不是Ktv哈)表示了該算法的系列,比如RSA或者EC等。kid表示了該條密匙內容的id。而里面的ne分別代表了RSA加密中的modulusexponent

再想想最初的我們解釋的自包含令牌,對於非對稱加密,我們需要從服務端獲取到公鑰,那么現在問題就來了,公鑰怎么表示呢? 而JWK相當於就干了這樣一件事。

什么?你問我這東西哪兒見過?您的IdentityServer4里面是不是公開了一個節點叫做.well-known/openid-configuration/jwks,眼熟吧?jwks不就是這一個東西嗎? 點擊這里看看吧!

JWT

來吧,萬眾期待的JWT。在JOSE家族中,我們看到了這么多個JW*的東西,其實感覺上它們都是為了最后這一項東西所服務,那就是JWT。這也是為什么,大家僅僅聽過JWT,而對其它的概念都不是太了解的原因。

JWT是一種緊湊的、URL安全的方法,用於表示雙方之間要傳輸的聲明。JWT中的聲明被編碼為JSON對象,該對象用作JSON Web簽名(JWS)結構的有效負載或JSON Web加密(JWE)結構的明文,從而使聲明能夠通過消息身份驗證。

對於我們常用的JWT,是采用了JWS的簽名式加密方案。所以結構就是 "A.B.C"的樣子,用Header來描述了簽名加密所用的算法,該描述遵循了JWA,而使用Playload來包含咱們所需要的東西,在JWT里面,它們叫做JWT Claims Set,而JWT提出了很多內置的Claim規范,下面我們會看到。最后是Signature,這就是基於JWS所得到的內容。

JWT規范定義了七個可選的、已注冊的聲明(Claim),並允許將公共和私人聲明包括在令牌中,這七個已登記的聲明是:

Claim 描述
iss (Issuer) 確定了簽發JWT的主體(發行者)。一般是STRING或者URI,比如"http://my.identityServer.com/5000"
sub (Subject) JWT所代表的主題。主題值必須限定為在發行者的上下文中是本地唯一的,或者是全局唯一的。所以你會在某些例子中看到它保存了用戶的ID等。一般是STRING或者URI
aud (Audience) JWT的受眾(該單詞我也不知道該如何翻譯比較合適)。一般是STRING或者URI,比如"http://my.clientiIp.com/5000"
exp (expire) JWT的過期時間
nbf (not-before) JWT的生效時間
iat ((issued-at) JWT的頒發時間
jti (expire) JWT的唯一標識符(JWT ID)

當然,僅僅靠這些值我們一般是無法處理完整業務邏輯的,比如我們往往需要將用戶郵箱等信息放入Token中,所以我們可以在荷載中放入我們自定義的一些項,只要保證不要和內置的命名沖突就行啦。

Bearer Token

這個應該是好多同學經常搞暈的一個概念,可能大家都以為,Bearer Token就等於JWT

當然不是啦,因為BearerHTTP Authorization的類型規范,而JWT是一個數據結構的規范

還記得我們在最初的時候提到過一個Basic驗證嗎? 它的格式是這樣的:

Authorization : Basic xxxxxx

HTTP 1.0中提出了Authorization: <type> <credentials>這樣的格式。 如果Basic類型的驗證是Authorization : Basic,那么你已經可以想到Bearer是什么樣子了。

大家都遵守了這樣的規范,才能不亂套,所以為什么有時候我們取消掉Bearer關鍵字,有些框架就會不處理該Token

關於Bearer,它是伴隨OAuth2.0所提出,該規范僅僅定義了Bearer Token的格式(也就是需要帶上Bearer關鍵字在header中),並沒有說過Token一定要使用JWT格式。

所以如果說Bearer等於JWT,那肯定是不對的。

同該Bearer所提出的概念還有access_tokenrefresh_token。它們都是同OAuth2.0一起誕生的,同樣的,它們與JWT也並沒有直接的關系,所以並非我一定要用JWT來生成access_tokenrefresh_token,還有就是當我使用JWT的時候,也並非一定要使用refresh_token

但是就像我們最初設想的一樣,如果不使用自包含的驗證,服務器將承受巨大的壓力。所以在OAuth2.0中,還是推薦大家使用JWT,而該方案也同樣具有一個標准規范

AspNet Core中的身份驗證

有了這些基礎知識之后,我們再來看看AspNetCore中是如何實現身份驗證的,在這里我們同樣以WebApi的驗證方案來講解,關於基本的Cookies驗證方案,您可以直接查閱官方文檔,但是對於驗證來說,原理幾乎都是一樣的。

在這之前,您將有很多個關鍵類需要了解:ClaimClaimsIdentityClaimsPrincipal

Claim,是身份表示的最小單位,它由核心的Type和Value屬性構成。比如一個人會有很多標簽,比如身份證號碼,郵箱號碼,手機號碼等等。當您需要驗證這某一項信息時,就可以將它申明為一個Claim,比如:

new Claim('email', "bob@gmail.com")

ClaimsIdentity:是一組Claim的合集,一個用戶或者一個事物往往有多個標簽,所以我們可以將它抽象成個高級的事物,而ClaimsIdentity就是該事物。從ClaimsIdentity的構造函數您就可以看出,它接受了一個IEnumerable<Claim>

// 創建一個用戶身份,注意需要指定AuthenticationType,否則IsAuthenticated將為false。
var claimIdentity = new ClaimsIdentity("myAuthenticationType");
// 添加幾個Claim
claimIdentity.AddClaim(new Claim(ClaimTypes.Name, "bob"));
claimIdentity.AddClaim(new Claim(ClaimTypes.Email, "bob@gmail.com"));

ClaimsPrincipal:是一組ClaimsIdentity的合集,又進行了一次更高層次的抽象。比如一個用戶有教師的身份,里面有教師ID教師郵箱的聲明,但是同時他還具有拖拉機師傅的身份,具有執業編號等聲明,所以此時我們就可以使用ClaimsPrincipal來表示該用戶。

而ClaimsPrincipal就成為了表示一個用戶的單位,所以在AspNetCoreHttpContext上下文中有一個User的屬性,而該屬性就是ClaimsPrincipal。而當我們需要驗證他是不是拖拉機師傅的時候,就通過他身上的執業編號就可以驗證啦。

AspNetCore中的身份驗證,其實就是一個判斷身份正確和構建ClaimsPrincipal的過程。所以我們就來看看它是如何處理的。

很明顯,由於AspNetCore管道的特性,所以我們一下就能猜到它是在一個較早的中間件中進行的身份驗證的。這也是為什么咱們要把app.UseAuthentication();放到前面的原因。

而關於該驗證的中間件其實很簡單,它的代碼也只有幾句:

// 判斷當前是否需要進行遠程驗證,如果是就進行遠程驗證
var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
{
    var handler = await handlers.GetHandlerAsync(context, scheme.Name) as IAuthenticationRequestHandler;
    if (handler != null && await handler.HandleRequestAsync())
    {
        return;
    }
}

//獲取本地的驗證方案,進行驗證
var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
if (defaultAuthenticate != null)
{
    var result = await context.AuthenticateAsync(defaultAuthenticate.Name);
    if (result?.Principal != null)
    {
        context.User = result.Principal;
    }
}

await _next(context);

關於該部分的內容,由於篇幅有限,我僅僅從宏觀的角度來為大家介紹,如果您對於該部分源碼解析有興趣,可以參考園子中雨夜朦朧的博客.

AspNetCore的驗證是根據scheme來區分的,scheme是什么呢?其實就是咱們的驗證方案。一般來說,咱們一套系統往往會有多種登錄方案,比如博客園,現在就開放了多種外部登錄的方案:

x

AspNetCore為了便於擴展方便,所以使用了scheme來作用區分方法,這樣我們在不同的時候,指定不同的scheme就能夠進行對應的處理:

scheme 對應處理方案類
Google GoogleHandler
WeChat WeChatHandler
QQ QQHandler
Cookies CookiesHandler

比如我們現在要進行QQ登錄,那么我就需要指定scheme的值為QQ,然后就會找到對應的處理程序來進行處理。

所以,在很多身份驗證的地方,您都可以看到方法中會帶有scheme這個參數:

HttpContext.SignInAsync(scheme, principal, properties: null);

//我可以這樣調用
context.SignInAsync("QQ",...);  //代表我將使用QQ身份驗證方案.

那么這些Handler類中都做了一些什么事情呢? 這就回到了我們該篇文章最初的時候,基礎驗證方案和自包含驗證方案。 比如自包含驗證的JWT驗證,那內部肯定就是將A.B.C這種格式的字符串進行反解析,然后看當前的令牌是否過期等操作。

對於本地的驗證方案,我們可以很容易了解驗證過程。但是遠程的驗證方案是特殊的,我們往往會單獨來處理它,就像上方的中間件代碼,您會發現會優先判斷是否為遠程驗證,然后再執行本地驗證。

為什么呢?因為當使用遠程驗證方案的時候,所有的驗證邏輯其實都是在外部,那么本地是如何跟它進行交互進行驗證的呢? 難道每一次訪問API都要去遠程驗證服務器進行驗證一次?

當然不是啦,接下來我將用一個不嚴謹的遠程驗證例子來為大家舉例。有關真正的遠程驗證,我會在下一篇文章中為大家介紹。

此時有遠程驗證服務器A,和我本地業務服務器B。B會在A處申請一個密匙,該密匙是用來進行驗證Token。當一個請求來到B的時候,它會進入到驗證中間件,此時我已經在service中注冊了對應的遠程驗證方案(好比services.AddQQAuthentication())。那么B發現該請求沒有攜帶Cookies,那么B將直接拒絕此次請求。

這個時候客戶端會嘗試進行在登錄頁進行登錄后再訪問,登錄頁為它展示了一個QQ的登錄按鈕,毫無疑問,用戶會點擊該按鈕進行使用QQ賬號登錄。而該按鈕指向的地址是遠程服務器A的登錄地址,而地址中攜帶了回調的本地地址。比如像這樣的URL:"https://QQService.com/sign?callback=http://localhost/sign-qq"。 遠程服務器就會處理該請求,等待用戶登錄成功之后,他會生成一個Token,然后重定向到本地服務器的地址,該地址是剛才傳入的回調地址,比如: "http://localhost/sign-qq?token=xxxxx"。

這個時候,就證明您正在訪問本地的服務器,而此時注冊的遠程驗證Handler會根據url的參數進行判斷,是否需要進行攔截處理,比如QQHandler看到了該url的參數為sign-qq,那么它就會認為它要處理該請求,然后它將獲取到的Token進行驗證(根據申請到的密匙),驗證成功的話就會解析出該Token所攜帶的Claims,自然而然就會生成一個ClaimsPrincipal出來。最終將該ClaimsPrincipal傳遞給本地登錄方案,生成一個Cookies。這樣就完成了本地的身份驗證,下次訪問的時候,帶上該Cookies,就會通過驗證啦。

所以再來回顧中間件代碼:

//1. 遠程驗證成功,返回到http://localhost/sign-qq?token=xxxxx

//4. 下次正常訪問,攜帶上了Cookies。
var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
{
    //2.獲取到了QQHandler,該Handler看到URL的參數為sign-qq,那么將對他進行處理。處理過程為解析Token,然后保存到本地Cookies。

    //5. 發現正常訪問時候的URL不在攔截范圍內,則不做處理。
    var handler = await handlers.GetHandlerAsync(context, scheme.Name) as IAuthenticationRequestHandler;
    if (handler != null && await handler.HandleRequestAsync())
    {
        //3.處理成功,本次請求結束。
        return;
    }
}

// 6. 找到本地驗證方案,比如Cookies,那么對攜帶的Cookies進行驗證。
var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
if (defaultAuthenticate != null)
{
    var result = await context.AuthenticateAsync(defaultAuthenticate.Name);
    if (result?.Principal != null)
    {
        context.User = result.Principal;
    }
}

所以遠程登錄的本質其實就是攜帶某些信息,讓遠程服務器返回一個Token,然后本地根據從遠程服務器處申請到的密匙進行Token解析的過程。

遠程登錄往往會衍生出另外一個概念就是外部登錄,比如從QQ出登錄后返回了qqUserId = 3的用戶,但是該用戶是存在QQ系統的,我們的系統是沒有的,所以需要處理該用戶,常用的手段就是綁定該賬號。讓QQ的userid與我們系統的UserId關聯起來。這也是為什么您會在一些框架中看到一些叫做"xxExternalLoginInfo"的表或者信息的原因。

這種方案您可以在該文章所攜帶的代碼中看到,我們使用了微信小程序的用戶與業務用戶相關聯。

AspNetCore中的Jwt Bearer驗證

接下來我們將看到如何在AspNetCore中使用JWT Bearer驗證。如果您已經讀過了上方的內容,相信您會知道為什么它叫JWT Bearer,而不是JWT或者Bearer。以及為什么微軟在提供該包的時候,沒有涉及到refresh_token的頒發。

>Install-Package Microsoft.AspNetCore.Authentication.JwtBearer

AspNetCore中的JwtBearer驗證方案,是由官方所提供的Microsoft.AspNetCore.Authentication.JwtBearer包所提供,在安裝之后,我們可以在Startip.cs中進行注冊:

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(o =>
        {
        }

注冊時我們需要對JWT進行配置,因為當一個Token過來的時候,我們需要配置的密匙來對它進行解析呀,判斷它是不是有效,以及是否被篡改。

該配置項的類型為JwtBearerOptions,里面有好些參數,但是對於JWT來說,最最最核心的是類型為TokenValidationParametersTokenValidationParameters屬性。因為JwtBearerOptions有部分JWT的配置,會受到TokenValidationParameters的約束,比如:

/// <summary>
/// Gets or sets a single valid audience value for any received OpenIdConnect token.
/// This value is passed into TokenValidationParameters.ValidAudience if that property is empty.
/// </summary>
public string Audience { get; set; }

注意,下方的NuGet包可能會讓人有點頭暈:

TokenValidationParameters類是來自於Microsoft.IdentityModel.Tokens,該包是由AzureAD維護。還記得上面的JWK嗎?該包就提供了JWK.NET實現,和對應的加密算法的實現以及Token的抽象。

假如您想創建JWT,那么您會依賴該團隊另外的包。此時您一定會在NuGet上進行搜索,但是…………

x

MD,好家伙。兩個包描述一模一樣,開發一模一樣,部分單詞也一模一樣。我到底選哪一個?它們又有啥區別?

這個時候就還得需要上面我們所提到的JOSE大家庭的知識啦,在介紹JWT的時候,我們提到了它是由JWS或者JWE來實現的。所以微軟就使用Microsoft.IdentityModel.JsonWebTokens來實現了底層JWSJWE不同創建JWT的方案,而System.IdentityModel.Tokens.Jwt依賴於Microsoft.IdentityModel.JsonWebTokens,采用更簡化的方式來實現JWT

所以不用說,我們肯定應該安裝System.IdentityModel.Tokens.Jwt呀,畢竟下次數量也多一些。😝

OK,回到上面,關於TokenValidationParameters的配置,其實都來源於您對JWT的認識。比如下面這些配置,如果您已經閱讀了上文,其實一下就能看懂:

 .AddJwtBearer(jwtOptions =>
{
    jwtOptions.TokenValidationParameters = new TokenValidationParameters()
    {
        ValidateAudience = false,   //是否驗證Audience
        ValidateIssuer = true,  //是否驗證Issuer
        IssuerSigningKey = new SymmetricSecurityKey(seurityKey),  //簽名的KEY
        ValidIssuer = configuration["JwtConfig:Issuer"], //驗證的Issuer信息
        ValidAudience = configuration["JwtConfig:Audience"],//驗證的Audience信息
    };
});

這樣我們就會驗證每一次http請求中所攜帶的Bearer Token信息。因為其實我們啟用了驗證Issuer,所以必須保證創建的Token的荷載中是包含正確的Issuer的,還有就是簽名的密匙一定要正確,否則是不會認證通過的。

其實您會發現,在使用Microsoft.AspNetCore.Authentication.JwtBearer的時候,其實有一些配置是屬於OpenID,而該包只是提供了驗證jwt的功能,但是並沒有創建JWT的功能。因為對於一般的WEBAPI應用,其實都會使用OPENID這種單點登錄的方案,對於單獨的JWT Token驗證來說其實還是比較少見的,如果您是簡單的單體應用,那可以使用這樣的方案。但是當項目慢慢擴大的時候,還是推薦您使用IdentityServer來實現Oidc登錄。

附件代碼就使用了本地服務既創建Token又驗證Token的方案

一些你需要注意的小細節

  • 當API根據傳入的UserID來獲取對應資源的時候,一定要確保當前驗證的用戶和傳入的ID匹配。

我發現有些同學經常會犯這樣的錯誤,因為漏寫或者忘記驗證,導致一些用戶抓包后進行更改參數就獲得了一些其它信息。這種錯誤風險是很大的,設想一下你根據修改id就獲得了其它人的微信聊天記錄。 所以我們一定要避免這種錯誤,在演示項目中,我們使用了[CurrentUser]特性來處理,該特性是MiCakeAspNetCore所實現的自動驗證方案,關於實現,您可以參考下方的Github鏈接。

案例說明

正如您在開頭看到的那個演示圖片一樣,該演示項目的前端是使用的uni-app來開發的。

可能有些朋友對於純前端開發會感到比較陌生,因為平時都是使用的Razor這種嵌套C#代碼的方式來開發,或者有些朋友已經開始嘗鮮Blazor了,但是本質上都是沒有離開C#。(說到Blazor,推薦大家使用 ant-design-blazor )。

但是為了更容易生成小程序的方案,所以最終選擇了基於Vueuni-app。我知道很多人可能和我一樣,一直使用着C#的簡潔語法,對於原生js是很不習慣的。所以,該項目我將所有的代碼都轉換成了TypeScript,而且全都是類似C#寫法的代碼。

如果您有使用過WPF或者Winform,您就會感覺好像在寫Web前端版本的WPF。因為就基礎使用來說,TypeScript對於C# er來說,幾乎沒有任何切換成本。您可以來看看下面的幾句代碼,這是從演示項目中復制過來的:

export default class extends Vue {
    public mobile: string = "";
    public password: string = "";

    public async login() {
        if (!uniHelper.validator.isMobile(this.mobile)) {
            thorUiHelper.showTips(this.$refs.toast, '貌似手機號碼不正確呀~');
            return;
        }
        if (uniHelper.validator.isNullOrEmpty(this.password)) {
            thorUiHelper.showTips(this.$refs.toast, '貌似還沒有輸入密碼哦~');
            return;
        }

        var loginInfo = new LoginDto();
        loginInfo.phone = this.mobile;
        loginInfo.password = this.password;
        loginInfo.code = this.code;

        try {
            let result = await this.$httpClient.post<MiCakeApiModel<LoginResultDto>>('/User/Login', loginInfo);
        }catch{
            //...
        }
    }
}

這也是為什么這篇文章拖了好幾天的原因,因為我花了好些時間去把所有的代碼全轉成類似C#語法的Ts代碼,只是為了讓您能夠更好的閱讀。

哦,對了。在前端項目里面我引用了Vuex,這是一個全局狀態管理的東西。所以搞得有些代碼看起來很復雜,剛開始您其實不需要關注它,把它理解為保存一個類似於C#中的static變量就行啦。

附錄

以下是本文所提及到各個倉庫的源碼地址:

最后,點個推薦吧😭


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM