兩種給 Http 添加狀態的方式,都不完美
我們知道,http 是無狀態的,也就是說上一次請求和下一次請求之間沒有任何關聯。但是我們要實現應用的功能,很多時候是需要有狀態的,比如登錄之后,再添加購物車,那就應該識別出是登錄用戶做的。
怎么給 http 請求添加上狀態呢?
這個問題的解決有兩種方案:服務端存儲的 session + cookie 的方案,客戶端存儲的 token 的方案。
但其實這兩種方案都不怎么樣,都不夠完美。
為什么這么說呢?我們分別來看一下:
服務端存儲的 session + cookie
給 http 添加狀態,那就給每個請求打上個標記,然后在服務端存儲這個標記對應的數據。這樣每個被標記的請求都可以找到對應的數據,自然可以做到登錄、權限等狀態的存儲。
這個標記應該是自動帶上的,所以 http 設計了 cookie 的機制,在里面存儲的數據是每次請求都會帶上的。
然后根據 cookie 里的標記去查找的服務端對應的數據叫做 session,這個標記就是 session 的 id。
如圖,因為請求自動帶上 cookie,那兩次請求就都可以找到 id 為 1 對應的 session,自然就知道當前登錄的用戶是誰,也可以存儲其他的狀態數據。
這就是 session + cookie 的給 http 添加狀態的方案。
大家覺得這種方案有問題么?
有問題,而且問題還挺多的。
最大的一個問題就是臭名昭著的 CSRF(跨站請求偽造):
CSRF
因為 cookie 會在請求時自動帶上,那你在一個網站登錄了,再訪問別的網站,萬一里面有個按鈕會請求之前那個網站的,那 cookie 依然能帶上。而這時候就不用再登錄了。
這樣萬一點了這個按鈕之后做了一些危險的操作呢?
是不是就很危險。
而且一般這種利用 CSRF 漏洞的網站都會偽裝的很好,讓你很難看出破綻來,這種網站叫做釣魚網站。
為了解決這個問題,我們一般會驗證 referer,就是請求是哪個網站發起的,如果發起請求的網站不對,那就阻止掉。
但這樣依然不能完全解決問題,萬一你用的瀏覽器也是有問題的,能偽造 referer 呢?
所以一般會用隨機值來解決,每次登錄隨機生成一個值,放到 session 中,后面的請求需要包含這個值才行,否則就認為是非法的。
這個隨機值叫做 token,可以放在參數中,也可以放在 header 中,因為釣魚網站拿不到這個隨機值,就算帶了 cookie 也沒發通過服務端的驗證。
這是 session + cookie 這種方案的一個缺點,但是是有解決方案的。
它還有別的缺點,比如分布式的時候:
分布式 session
session 是把狀態數據保存在服務端,那么問題來了,如果有多臺服務器呢?
當并發量上去了,單臺服務器根本承受不了,自然需要做集群,也就需要多臺服務器來提供服務。
而且現在后端還會把不同的功能拆分到不同的服務中,也就是微服務架構,自然也需要多臺服務器。
那不同服務器之間的 session 怎么同步?
登錄之后 session 是保存在某一臺服務器的,之后可能會訪問到別的服務器,這時候那臺服務器是沒有對應的 session 的,就沒法完成對應的功能。
這個問題的解決有兩種方案:
一種是 session 復制,也就是通過一種機制在各臺機器自動復制 session,并且每次修改都同步下。這個有對應的框架來做,比如 java 的 spring-session。
各臺服務器都做了 session 復制了,那你訪問任何一臺都能找到對應的 session。
還有一種方案是把 session 保存在 redis,這樣每臺服務器都去那里查,只要一臺服務器登錄了,其他的服務器也就能查到 session,這樣就不需要復制了。
還好,session 在分布式時的這個問題也算是有解決方案的。
但你你以為這就完了么?session + cookie 還有跨域的問題:
跨域
cookie 為了安全,是做了 domain 的限制的,設置 cookie 的時候會指定一個 domain,只有這個 domain 的請求才會帶上這個 cookie。
而且還可以設置過期時間、路徑等:
那萬一是不同 domain 的請求呢?也就是跨域的時候,怎么帶 cookie 呢?
a.guang.com 和 b.guang.com 這種還好,只要把 domain 設置為頂級域名 guang.com 就可以了,那二三級域名不同也能自動帶上。
但如果頂級域名也不同就沒辦法了,這種只能在服務端做下中轉,把這倆個域名統一成同一個。
上面說的不是 ajax 請求,ajax 請求有額外的機制:
ajax 請求跨域的時候是不會挾帶 cookie 的,除非手動設置 withCredentials 為 true 才可以。
而且也要求后端代碼設置了對應的 header:
Access-Control-Allow-Origin: "當前域名";
Access-Control-Allow-Credentials: true
這里的 allow origin 設置 * 都不行,必須指定具體的域名才能接收跨域 cookie。
這是 session + cookie 方式的第三個坑,好在也是有解決方案的。
我們做下小結:
session + cookie 的給 http 添加狀態的方案是服務端保存 session 數據,然后把 id 放入 cookie 返回,cookie 是自動攜帶的,每個請求可以通過 cookie 里的 id 查找到對應的 session,從而實現請求的標識。這種方案能實現需求,但是有 CSRF、分布式 session、跨域等問題,不過都是有解決方案的。
session + cookie 的方案確實不太完美,我們再來看另一種方式怎么樣:
客戶端存儲的 token
session + cookie 的方案是把狀態數據保存在服務端,再把 id 保存在 cookie 里來實現的。既然這樣的方案有那么多的問題,那我反其道而行之,不把狀態保存在服務端了,直接全部放在請求里,也不放在 cookie 里了,而是放在 header 里,這樣是不是就能解決那一堆問題了呢?
token 的方案常用 json 格式來保存,叫做 json web token,簡稱 JWT,我們就拿這個來說吧。
JWT 是保存在 request header 里的一段字符串(比如用 header 名可以叫 authorization),它分為三部分:
如圖 JWT 是由 header、payload、verify signature 三部分組成的:
header 部分保存當前的加密算法,payload 部分是具體存儲的數據,verify signature 部分是把 header 和 payload 還有 salt 做一次加密之后生成的。(salt,鹽,就是一段任意的字符串,增加隨機性)。
這三部分會分別做 Base64,然后連在一起就是 JWT 的 header,放到某個 header 比如 authorization 中:
authorization: barer xxxxx.xxxxx.xxxx
請求的時候把這個 header 帶上,服務端就可以解析出對應的 header、payload、verify signature 這三部分,然后根據 header 里的算法也對 header、payload 加上 salt 做一次加密,如果得出的結果和 verify signature 一樣,就接受這個 token。
把狀態數據都保存在 payload 部分,這樣就實現了有狀態的 http:
而且這種方式是沒有 session + cookie 那些問題的,不信我們分別來看一下:
CSRF:因為不是通過自動帶的 cookie 來關聯服務端的 session 保存的狀態,所以沒有 CSRF 問題,沒法通過 cookie 攻擊。
分布式 session:因為狀態不是保存在服務端,所以無論訪問哪臺服務器都行,只要能從 token 里解析出狀態數據就行。
跨域:因為不是 cookie 那一套,自然也沒有跨域的限制,只要手動帶上 JWT 的 header 就行。
看起來這種方式好像很完美?
其實也不是,JWT 有 JWT 的問題:
安全性
因為 JWT 把數據直接 Base64 之后就放在了 header 里,那別人就可以輕易從中拿到狀態數據,比如用戶名等敏感信息,也能根據這個 JWT 去偽造請求。
所以 JWT 要搭配 https 來用,讓別人拿不到 header。
性能
JWT 把狀態數據都保存在了 header 里,每次請求都會帶上,比起只保存個 id 的 cookie 來說,請求的內容變多了,性能也會差一些。
所以 JWT 里也不要保存太多數據。
沒法讓 JWT 失效
session 因為是存在服務端的,那我們就可以隨時讓它失效,而 JWT 不是,因為是保存在客戶端,那我們是沒法手動讓他失效的。
所以 JWT 的過期時間不要設置的太長。
所以說,JWT 的方案雖然解決了很多 session + cookie 的問題,但也不完美。
小結下:
JWT 的方案是把狀態數據保存在 header 里,每次請求需要手動攜帶,沒有 session + cookie 方案的 CSRF、分布式、跨域的問題,但是也有安全性、性能、沒法控制等問題。
說了這么多,還是寫下代碼心里更踏實:
Nest.js 實現兩種方案
我們用 Nest.js 實現下兩種方案吧,不能光紙上談兵。
首先用 @nest/cli 快速創建一個 Nest.js 項目。
npx nest new status
會生成 module、controller、service 的基礎代碼:
我們先實現 session + cookie 的方式:
session + cookie
Nest.js 的底層是 express,它只是額外提供了一些架構的劃分,所以還是 session 實現還是用的 express 的方案:
安裝 express-session 和它的 ts 類型定義:
npm install express-session @types/express-session
然后在入口模塊里啟用它:
指定個加密 cookie 用的密碼就行。
然后在 controller 里就可以注入 session 對象了:
我在 session 里放了個 count 的變量,每次訪問加一,然后 body 返回這個 count。
這樣就可以判斷 http 請求是否有了狀態。
我們來測試下:
可以看到每次請求返回的數據都不同,而且返回了一個 cookie 是 connect.sid,這個就是對應 session 的 id。
因為 cookie 在請求的時候會自動帶上,就可以實現請求的標識,給 http 請求加上狀態。
session + cookie 的方式用起來還是很簡單的,我們再來看下 jwt 的方式:
jwt
jwt 需要引入 @nestjs/jwt 這個包,然后在入口 Module 里引入 JwtModule:
引入的時候指定密碼,也就是用來加到 jwt 里的鹽,也可以指定 token 過期時間。
因為我們引入了 JwtModule,那就可以在 Controller 里依賴注入了:
聲明對 JwtService 的依賴,Nest.js 就會自動注入對應的對象。
然后定義個 controller 方法,通過 Resonse 對象來設置 authorization 的 header:
用 jwtService 生成一個 token,記錄 count,然后放到 header 里返回,同時也放在 body 里。
后面的請求就是取出這個 header,拿到其中的數據,然后 +1 之后再放回去:
這樣也實現了給 http 添加狀態的需求,不過是把數據保存在了 header 里。
我們通過 postman 測試下:
第一次請求會返回一個 authorization 的 header,body 是 1:
把這個 header 手動添加到請求 header 里,再次請求:
body 變成 2 了,同時也返回了一個新的 authorization 的 header。
把這個新的 authorization 放到請求 header 里再次請求:
body 變成 3 了,同時也返回了一個新的 authorization 的 header。
有同學問,我不用新的 header,還是用上次的 header 會怎么樣:
那樣會報錯:
jwt 生成一次只能用一次,這個一次性也是它的一個特點。
這樣,我們就分別用 Nest.js 分別實現了 session + cookie 和 jwt 兩種保存 http 狀態的方式。
代碼上傳到了 github:https://github.com/QuarkGluonPlasma/nestjs-exercize。
總結
http 是無狀態的,也就是請求和請求之間沒有關聯,但我們很多功能的實現是需要保存狀態的。
給 http 添加狀態有兩種方式:
session + cookie:把狀態數據保存到服務端,session id 放到 cookie 里返回,這樣每次請求會帶上 cookie ,通過 id 來查找到對應的 session。這種方案有 CSRF、分布式 session、跨域的問題。
jwt:把狀態保存在 json 格式的 token 里,放到 header 中,需要手動帶上,沒有 cookie + session 的那些問題,但是也有安全性、性能、沒法控制和使用一次就失效的問題。
上面這兩種方案都不是完美的,但那些問題也都有解決方案。
軟件領域很多情況下都是這樣的,某種方案都解決了一些問題,但也相應的帶來了一些新的問題。沒有銀彈,還是要熟悉它們的特點,根據不同的需求靈活選用。