開篇
這段時間潛水了太久,終於有時間可以更新一篇文章了。
通過本篇文章您將Get:
Http
的一些身份驗證概念- 在
AspNetCore
中實現身份驗證方案 JWT
等概念的基礎知識- 使用
Bearer Token
對WebAPI
進行保護 - 一些驗證中的小細節
- 微信小程序驗證的源代碼
時長為大約有十五分鍾,內容豐富,建議先投幣再上車觀看😜
本文附帶了普通Bearer JwtToken
驗證和微信小程序
驗證登錄的源代碼,效果圖您可以參考下方的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
這一項信息。基礎驗證方案中,我們通過傳遞username
和password
來獲取userId
。而現在,我們就直接讓令牌來包含userId
這一項內容,而以后我們每次攜帶該令牌去訪問API的時候,就不需要再到數據庫中進行查找用戶來獲取Id了。這樣就能大幅度夠減緩服務器的查找壓力。
用戶傳遞了username
和password
到身份驗證服務器,服務器通過與數據庫中的用戶信息
進行匹配,發現是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
來說是必要的。
但是在該圖中,除了JWT
您還會看到其它的類似單詞,比如:JWS
、JWE
、JWK
等等。但是當您想去對他們進行了解的時候,很抱歉,百度居然不靠譜了。😭
不要慌張,在有了上面基礎驗證方案的思路之后,這些對於您來說都不是問題。
這些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 Header
、JWS Payload
和JWS 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
嗎?格式明明一模一樣,是的,JWS
和JWA
等就是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
。而里面的n
和e
分別代表了RSA
加密中的modulus
和exponent
。
再想想最初的我們解釋的自包含令牌
,對於非對稱加密
,我們需要從服務端獲取到公鑰,那么現在問題就來了,公鑰怎么表示呢? 而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
。
當然不是啦,因為Bearer
是HTTP 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_token
和refresh_token
。它們都是同OAuth2.0
一起誕生的,同樣的,它們與JWT
也並沒有直接的關系,所以並非我一定要用JWT
來生成access_token
和refresh_token
,還有就是當我使用JWT
的時候,也並非一定要使用refresh_token
。
但是就像我們最初設想的一樣,如果不使用自包含的驗證
,服務器將承受巨大的壓力。所以在OAuth2.0
中,還是推薦大家使用JWT
,而該方案也同樣具有一個標准規范。
AspNet Core中的身份驗證
有了這些基礎知識之后,我們再來看看AspNetCore
中是如何實現身份驗證的,在這里我們同樣以WebApi
的驗證方案來講解,關於基本的Cookies
驗證方案,您可以直接查閱官方文檔,但是對於驗證來說,原理幾乎都是一樣的。
在這之前,您將有很多個關鍵類需要了解:Claim
、ClaimsIdentity
、ClaimsPrincipal
。
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就成為了表示一個用戶的單位,所以在AspNetCore
的HttpContext
上下文中有一個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
是什么呢?其實就是咱們的驗證方案。一般來說,咱們一套系統往往會有多種登錄方案,比如博客園,現在就開放了多種外部登錄
的方案:
而AspNetCore
為了便於擴展方便,所以使用了scheme
來作用區分方法,這樣我們在不同的時候,指定不同的scheme
就能夠進行對應的處理:
scheme | 對應處理方案類 |
---|---|
GoogleHandler | |
WeChatHandler | |
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
來說,最最最核心的是類型為TokenValidationParameters
的TokenValidationParameters
屬性。因為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
上進行搜索,但是…………
MD,好家伙。兩個包描述一模一樣,開發一模一樣,部分單詞也一模一樣。我到底選哪一個?它們又有啥區別?
這個時候就還得需要上面我們所提到的JOSE
大家庭的知識啦,在介紹JWT
的時候,我們提到了它是由JWS
或者JWE
來實現的。所以微軟就使用Microsoft.IdentityModel.JsonWebTokens
來實現了底層JWS
和JWE
不同創建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]
特性來處理,該特性是MiCake
為AspNetCore
所實現的自動驗證方案,關於實現,您可以參考下方的Github鏈接。
案例說明
正如您在開頭看到的那個演示圖片一樣,該演示項目的前端是使用的uni-app
來開發的。
可能有些朋友對於純前端開發會感到比較陌生,因為平時都是使用的Razor
這種嵌套C#代碼的方式來開發,或者有些朋友已經開始嘗鮮Blazor
了,但是本質上都是沒有離開C#
。(說到Blazor
,推薦大家使用 ant-design-blazor )。
但是為了更容易生成小程序的方案,所以最終選擇了基於Vue
的uni-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變量就行啦。
附錄
以下是本文所提及到各個倉庫的源碼地址:
- 前端uni-app演示項目(MiCake實驗項目) : https://github.com/uoyoCsharp/MiCake.Samples
- MiCake: https://github.com/uoyoCsharp/MiCake
- MiCake的微信小程序登錄驗證包: https://github.com/uoyoCsharp/MiCake.Authentication.MiniProgram.WeChat
最后,點個推薦吧😭