作者 | 寫代碼的明哥
來源 | Python編程時光
這篇文章本應該是屬于 HTTP 里的一部分內容,但是我看內容也挺多的,就單獨劃分一篇文章來講下。
什么是跨域請求
要明白什么叫跨域請求,首先得知道什么叫域。
域,是指由 協議
+域名
+端口號
組成的一個虛擬概念。
如果兩個域的協議、域名、端口號都一樣,就稱他們為同域,但是只要有其中一個不一樣,就不是同域。
那么 跨域請求
又是什么意思呢?
簡單來說,就是在一個域內請求了另一個域的資源,由于域不一致,會有安全隱患。
跨域請求的安全隱患
有一個詞,叫 CSRF (Cross-site request forgery)攻擊,中文名是 跨站請求偽造
。
簡單來說呢,就是攻擊者盜用了你的身份,以你的名義發送惡意請求,它能做的壞事有很多,比如以你的名義發郵件,發消息,購物,盜取帳號等。
CSRF 的實際工作原理是怎樣的?
比如現在有兩個網站,A 網站是真實受信息的網站,而 B網站是危險網站。
當你登陸 A 網站后,瀏覽器會存儲 A 網站服務器給你生成的 sessionid 存入 cookie,有了這個 cookie ,就擁有了你的帳號權限,以后請求資料,就不用再次登陸啦。
對于真實用戶來說,是便利,可對于攻擊者來說,卻是可乘之機。
他們可以使用各種社工學引導你點擊他們的鏈接/網站,然后利用你的瀏覽器上存儲的 cookie ,然后在自己的 網站B 發起對 網站A 的請求,獲取一些隱私信息,做一些侵害用戶權益的事情。這便是一個完整的 CSRF 攻擊。
跨域請求的安全防御
完成一次完整的 CSRF 攻擊,只要兩個步驟:
-
登錄受信任網站A,并本地已經存儲了 Cookie
-
在不登出A的情況下,訪問危險網站B,網站 B 誘導你發 A 發請求。
很多瀏覽器用戶對于網絡安全是無意識的,因此我們不能指望通過規范用戶行為來避免CSRF攻擊。
那如何從技術手段規避一定的 CSRF 攻擊的風險呢?
-
利用瀏覽器的同源策略:最基礎的安全策略
-
對請求的來源進行驗證:Referer Check
-
使用驗證碼強制使用戶進行交互確認,保證請求是用戶發起
-
CSRF Token,注意不要使用 cookie 來存儲token
-
JSON Web Token
以上是我知道的歷史上用來抵御 CSRF 攻擊的方法
-
有的雖然實現簡單,但是不夠安全
-
有的雖然安全,但是用戶體驗不好
-
有的雖然安全,用戶體驗好,但是有缺點
具體應該選哪一種呢,不妨繼續往下看。
3.1 同源策略
瀏覽器上有一個同源策略(SOP,全稱 Same origin policy),它會在一定程度上禁止這種跨域請求的發生。
但同源策略是最基本的安全策略,對于低級的 CSRF 攻擊 ,它是很有效果的。
可以說 Web 是構建在同源策略基礎之上的,瀏覽器只是針對同源策略的一種實現。
同源策略在提升了 Web前端的安全性的同時,也犧牲了Web拓展上的靈活性。
設想若把html、js、css、flash,image等文件全部布置在一臺服務器上,小網站這樣湊活還行,大中網站如果這樣做服務器根本受不了的,因此同源策略,就像是雙刃劍。不過這些都是有解的。
3.2 Referer Check
在 HTTP 協議中,有一個字段叫做 Referer,它記錄了HTTP 請求的來源地址。
當發生 CSRF 攻擊時,這個來源地址,會變成危險網站 B,因此只要在服務端校驗這個 Referer 是不是和自己同一個域就可以判斷這個請求是跨站請求。
但這種方法,也是有局限性的,在一些非主流的瀏覽器,或者使用了那些非常古老的瀏覽器版本,這個 Referer 字段,是有可能會被篡改的。
退一步講,假設你使用了最安全的最新版本的瀏覽器,這個值無法被篡改,依舊還是有安全隱患。
因為有些用戶出于某些隱私考慮,會在瀏覽器設置關閉這個 Referer 字段,也有的網站會使用一些技術手段使用請求不攜帶 Referer 字段。
因此,當你要使用 Referer Check 來做為 防御 CSRF 攻擊的主要手段時,請確保你的用戶群體使用的一定是最安全的最新版本的瀏覽器,并且默認用戶不會手動關閉 Referer 。
3.3 加驗證碼
驗證碼,強制用戶必須與應用進行交互,才能完成最終請求。
其實加驗證碼,是能很好遏制 CSRF 攻擊,但是網站總不能給所有的操作都加上驗證碼吧,那樣的話,用戶估計都跑光光了,因此為了保證用戶體驗,驗證碼只能作為一種輔助手段,不能作為主要解決方案。
3.4 CSRF Token
CSRF 攻擊之所以能夠成功,是因為黑客可以完全偽造用戶的請求,該請求中所有的用戶驗證信息都是存在于 cookie 中,因此黑客可以在不知道這些驗證信息的情況下,直接利用用戶自己的 cookie 來通過安全驗證。
所以要抵御 CSRF,關鍵在于要在請求中放入黑客所不能偽造的信息,并且該信息不存在于 cookie 之中(不然黑客又能拿到了)。
業界普遍的防御方案是使用 CSRF Token
使用 CSRF Token 根據token驗證方式的不同,也可以分為兩種:
第一種:如圖所示
-
當用戶請求一個更新用戶名的頁面時,由服務端生成一個隨機數 Token,然后放入HTML表單中傳給瀏覽器,并且存入 session 中。
-
當用戶提交表單請求時,表單數據會帶上這個 Token 發送給服務端 ;
-
服務端收到表單請求后,會從表單數據里取出 Token,然后和 session 里的 token 進行對比,如果是一樣的,就是合法的用戶請求,將新的用戶名存入數據庫,如果不一樣,那就是非法的請求,應當拒絕。
第二種:
-
當用戶請求一個更新用戶名的頁面時,由服務端生成一個隨機數 Token,然后放入HTML表單中,并且會把這個 Token 放在 cookie 里發給瀏覽器。
-
當用戶提交表單請求時,表單數據會帶上這個 Token 發送給服務端,并且帶上攜帶 token 的 cookie ;
-
服務端收到表單請求后,會從表單數據里取出 Token,與 cookie 里的 token 進行對比,如果是一樣的,就是合法的用戶請求,將新的用戶名存入數據庫,如果不一樣,那就是非法的請求,應當拒絕。
3.5 新增 Header
使用上面的 CSRF Token 已經可以避免 CSRF 攻擊,但是它卻有可能又引入了另一個問題。
若 CSRF Token 沒有使用 cookie,就必須要將 Token 存儲在服務端的 Session 中,這樣就會面臨幾個問題
-
服務端每生成一個 Token,都會存放入 session 中,而隨著用戶請求的增多,服務端的開銷會明顯增大。
-
如果網站有多個子域,分別對應不同的服務器,比如 taobao.com 后臺是服務器 a,zhibo.baotao.com 后臺是 服務器b, 不同子域要想使用同一個 Token,就要求所有的服務器要能共享這個 Token。一般要有一個中心節點(且應是一個集群)來存儲這個Token,這樣看下來,架構就變得更加復雜了。
想要解決這些問題,可以使用我們接下來要講的 JWT(全稱:JSON Web Token)
使用了 JWT 后,有了哪些變化呢
-
服務器只負責生成 Token和校驗Token,而不再存儲Token
-
將服務器的壓力分攤給了所有的客戶端。
-
服務端的 鑒權不使用 cookie ,而是由新增的 Header 字段:Authorization 里的 JWT 。
JWT 是本篇文章重要知識點之一,下面我會詳細說說關于 JWT 的內容。
JWT 的工作原理及目的
為了讓你直觀感受 JWT 的工作原理,我畫了下面這張圖
-
用戶以 Web表單 的形式,將自己的用戶名和密碼 POST 到后端的接口。
-
后端核對用戶名和密碼成功后,會計算生成JWT Payload 字符串(具體計算方法,后續會講),然后返回 response 給瀏覽器。
-
瀏覽器收到 JWT 后,將其保存在 cookie 里或者 localStorage 或者 sessionStorage 里(具體如何選,后面會說)。
-
后續在該域上發出的請求,都會將 JWT放入HTTP Header 中的 Authorization 字段。
-
后端收到新請求后,會使用密鑰驗證 JWT 簽名。
-
驗證通過后后端使用 JWT 中包含的用戶信息進行其他相關操作,返回相應結果。
JWT 的誕生并不是解決 CSRF 跨域攻擊,而是解決跨域認證的難題。
舉例來說,A 網站和 B 網站是同一家公司的關聯服務。現在要求,用戶只要在其中一個網站登錄,再訪問另一個網站就會自動登錄,這應該如何實現呢?
一種解決方案是 session 數據持久化,寫入數據庫或別的持久層。各種服務收到請求后,都向持久層請求數據。這種方案的優點是架構清晰,缺點是工程量比較大。另外,持久層萬一掛了,就會單點失敗。
另一種方案是服務器索性不保存 session 數據了,所有數據都保存在客戶端,每次請求都發回服務器。
JWT 就是這種方案的一個優秀代表。
JWT 如何生成?
JWT 其實就是一個字符串,比如下面這樣
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
仔細觀察,會發現它里面有三個 .
,以.
為分界,可以將 JWT 分為三部分。
-
第一部分:頭部(Header)
-
第二部分:載荷(Payload)
-
第三部分:簽名(Signature)
5.1 頭部(Header)
JWT 的頭部承載兩部分信息:
-
聲明類型:這里是 JWT
-
聲明加密的算法:通常直接使用 Hmac SHA256
完整的頭部就像下面這樣的JSON:
{
"typ": "JWT",
"alg": "HS256"
}
然后將頭部進行 Base64URL 算法編碼轉換,構成了第一部分
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
5.2 載荷(Payload)
載荷,同樣也是個 JSON 對象,它是存放有效信息的地方,但不建議存放密碼等敏感信息。
JWT 規定了7個官方字段,供選用:
-
iss (issuer):簽發人
-
exp (expiration time):過期時間
-
sub (subject):主題
-
aud (audience):受眾
-
nbf (Not Before):生效時間
-
iat (Issued At):簽發時間
-
jti (JWT ID):編號
除了官方字段,你還可以在這個部分定義私有字段,下面就是一個例子。
注意,JWT 默認是不加密的,任何人都可以讀到,所以不要把秘密信息放在這個部分。
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
然后將其進行 Base64URL 算法轉換,得到 JWT 的第二部分。
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
5.3 簽名(Signature)
Signature 部分是對前兩部分的簽名,防止數據篡改。
首先,需要指定一個密鑰(secret)。這個密鑰只有服務器才知道,不能泄露給用戶。然后,使用 Header 里面指定的簽名算法(默認是 HMAC SHA256),按照下面的公式產生簽名。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
算出簽名以后,把 Header、Payload、Signature 三個部分拼成一個字符串,每個部分之間用"點"(.
)分隔,就可以返回給用戶。
如何手動生成 JWT?
如果你想手動生成一個 JWT 用于測試,有兩種方法
第一種:使用 https://jwt.io/ 這個網站 。
我使用前面的 header 和 payload,然后使用 secret 密鑰:Python
最后生成的 JWT 結果如下
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.3wGDum3_A8tAt1bdal5CpYbIUlpHfPQxs96Ijx883kI
第二種:使用 Python 代碼生成
首先安裝一下 pyjwt 這個庫
$pip install pyjwt
然后就可以在代碼中使用它
import jwt
import datetime
import uuid
salt = 'minggezuishuai'
# 構造header , 這里不寫默認的也是
headers = {
'typ': 'JWT',
'alg': 'HS256'
}
# 構造payload
payload = {
'user_id': str(uuid.uuid4), # 自定義用戶ID
'username': "wangbm", # 自定義用戶名
'exp': datetime.datetime.utcnow + datetime.timedelta(minutes=5) # 超時時間,取現在時間,五分鐘后token失效
}
token = jwt.encode(payload=payload, key=salt, algorithm="HS256", headers=headers).decode('utf-8')
# token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiODg4ZjIwZDktMDdlZC00MWJkLWIzMjktMTdjNmYwNThhMTRlIiwidXNlcm5hbWUiOiJ3YW5nYm0iLCJleHAiOjE1OTQ0MzQzMjZ9.kkEMhSx732lO6HWWNPNVQDHR9WuCEVxKgNol-LTbCP8
如果你只是測試使用,完全不用寫那么多代碼,用命令行即可
$ pyjwt --key="minggezuishuai" encode user_id=888f20d9-07ed-41bd-b329-17c6f058a14e username=wangbm exp=+120
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiODg4ZjIwZDktMDdlZC00MWJkLWIzMjktMTdjNmYwNThhMTRlIiwidXNlcm5hbWUiOiJ3YW5nYm0iLCJleHAiOjE1OTQ0MzQ4NTl9.A792th12kY1YnBWyVgbr5l6OQ5emRiETIjsnmIl4Ji8
Base64URL 算法
前面提到,Header 和 Payload 串型化的算法是 Base64URL。這個算法跟 Base64 算法基本類似,但有一些小的不同。
JWT 作為一個令牌(token),有些場合可能會放到 URL(比如 api.example.com/?token=xxx)。Base64 有三個字符+、/和=,在 URL 里面有特殊含義,所以要被替換掉:=被省略、+替換成-,/替換成_ 。這就是 Base64URL 算法。
JWT 如何保存?
關于瀏覽器應該將 JWT 保存在哪?這個問題,其實也困擾了我很久。
如果使用搜索引擎去查,我相信你也一定會被他們繞暈。
比如在這篇帖子(When and how to use it )里,作者的觀點是,不應該保存在 localstorage 和 session storage,因為這樣,第三方的腳本就能直接獲取到。
作者推薦的做法是,將 JWT 保存在 cookie 里,并設置 HttpOnly。
再比如這一篇帖子(JWT(JSON Web Token) : Implementation with Node)提到了要把 JWT 保存到 local-storage。
因此,我決定不再看網絡上關于 『應將 JWT 保存的哪?』的文章。而是自己思考,以下是我個人觀點,不代表一定正確,僅供參考 。
JWT 的保存位置,可以分為如下四種
-
保存在 localStorage
-
保存在 sessionStorage
-
保存在 cookie
-
保存在 cookie 并設置 HttpOnly
第一種和第二種其實可以歸為一類,這一類有個特點,就是該域內的 js 腳本都可以讀取,這種情況下 JWT 通過 js 腳本放入 Header 里的 Authorization 字段,會存在 XSS 攻擊風險。
第三種,與第四種相比,區別在于 cookie 有沒有標記 HttpOnly,沒有標記 HttpOnly 的 cookie ,客戶端可以將 JWT 通過 js 腳本放入 Header 里的 Authorization 字段。這么看好像同時存在CSRF 攻擊風險和 XSS 攻擊風險,實則不然,我們雖然將 JWT 存儲在 cookie 里,但是我們的服務端并沒有利用 cookie 里的 JWT 直接去鑒權,而是通過 header 里的 Authorization 去鑒權,因此這種方法只有 XSS 攻擊風險,而沒有 CSRF 攻擊風險。
而第四種,加了 HttpOnly 標記,意味著這個 cookie 無法通過js腳本進行讀取和修改,杜絕了 XSS 攻擊的發生。與此同時,網站自身的 js 腳本也無法利用 cookie 設置 header 的Authorization 字段,因此只能通過 cookie 里的 JWT 去鑒權,所以不可避免還是存在 CSRF 攻擊風險。
如此看來,好像不管哪一種都有弊端,沒有一種完美的解決方案。
是的,事實也確實如此。
所以我的觀點是,開發人員應當根據實際情況來選擇 JWT 的存儲位置。
-
當訪問量/業務量不是很大時,可以使用 CSRF Token 來防止 CSRF 攻擊
-
而如果訪問量/業務量對服務器造成很大壓力,或覺得服務器共享 token 對架構要求太高了,那就拋棄CSRF Token 的方式,而改用 JWT。選擇了 JWT ,就面臨著要將 JWT 存儲在哪的問題。
-
若選擇了 JWT ,那么請不要使用 cookie HttpCookie 來存儲它,因為使用它還是會有 CSRF 攻擊風險。
-
那另外三種如何選擇呢?這三種無論使用哪種,都不可避免有 XSS 攻擊風險。我的思路是,XSS 攻擊通過其他的手段來規避,這里使用JWT 只有 防御 CSRF 攻擊與服務器性能的優化,這兩個目標。
-
那我剩下的三種,我建議是使用 cookie 存儲,但不使用 cookie 來鑒權。服務器鑒權還是通過請求里的 Authorization 字段(通過js寫入 Header 的)。
當然,如果你覺得你通過 Referer Check
、加驗證碼
等其他手段,已經可以保證不受 CSRF 攻擊的威脅,此時你使用 JWT ,就可以選擇使用 JWT + cookie HttpOnly,扼殺 XSS 攻擊的可能。
JWT 如何發送?
通過上面第七節的描述,其實我也講到了 JWT 根據不同場景可以選擇兩種發送方式
-
第一種:將 JWT 放在 Header 里的
Authorization
字段,并使用Bearer
標注
'Authorization': 'Bearer ' + ${token}
-
第二種:把 JWT 放入 cookie ,發送給服務端,雖然發送。但是不使用它來鑒權。
JWT 如何校驗?
后端收到請求后,從 Header 中取出 Authorization
里的 JWT ,使用之前的簽名算法對 header 和 payload 再次計算生成新的簽名,并與 JWT 里的簽名進行對比,如果一樣,說明校驗通過,是個合法的 Token。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
驗證是個合法的 Token 后,還要檢查這個 Token 是否過期,在 JWT 里的 payload 中,有 Token 的過期時間,可以通過它來檢查 Token 是否可以用?
payload 里同時還有用戶的相關信息,有了這些信息后,后端就可以知道這是哪個用戶的請求了,到這里一切都驗證通過,就可以執行相關的業務邏輯了。
前面我使用了 pyjwt
這個來生成 JWT ,事實上,這個庫也可以用來驗證 token。
使用 jwt 的 decode 會先驗簽再解碼取得 payload 的信息。
>>> import jwt
>>> token="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiODg4ZjIwZDktMDdlZC00MWJkLWIzMjktMTdjNmYwNThhMTRlIiwidXNlcm5hbWUiOiJ3YW5nYm0iLCJleHAiOjE1OTQ0MzQzMjZ9.kkEMhSx732lO6HWWNPNVQDHR9WuCEVxKgNol-LTbCP8"
>>> jwt.decode(token, 'minggezuishuai', algorithms=['HS256'])
{'user_id': '888f20d9-07ed-41bd-b329-17c6f058a14e', 'username': 'wangbm', 'exp': 1594434326}
>>>
驗簽同樣也可以使用命令行
$ pyjwt --key="minggezuishuai" decode eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiODg4ZjIwZDktMDdlZC00MWJkLWIzMjktMTdjNmYwNThhMTRlIiwidXNlcm5hbWUiOiJ3YW5nYm0iLCJleHAiOjE1OTQ0MzQ4NTl9.A792th12kY1YnBWyVgbr5l6OQ5emRiETIjsnmIl4Ji8
{"user_id": "888f20d9-07ed-41bd-b329-17c6f058a14e", "username": "wangbm", "exp": 1594434859}
如果不想驗證簽名及有效期,而只是想取下payload,只需加個--no-verify
參數即可
$pyjwt --key="minggezuishuai" decode --no-verify {token}
更的詳細使用方法,可以執行 pyjwt --help
學習或者前往官方文檔:https://pyjwt.readthedocs.io/en/latest/index.html
JWT 的最佳搭配
在真正的業務中,是有可能使用 payload 來存放一些用戶的敏感信息的,由于 payload 是采用 Base64URL 轉換而成,它是可逆的,因此當你在 payload 存放敏感信息時,需要保證 JWT 的安全性,不能讓其暴露在 『陽光』下。
為此,JWT 最好與 HTTPS 配合使用,利用 HTTPS 的非對稱加密來保證 JWT 的安全。
具體是如何保障的呢?
HTTPS 是基于 SSL/TLS 的非對稱加密算法工作的。
在非對稱加密算法的規則下,服務器會擁有一個叫做『私鑰』的東西,它是私有的,除了服務器之外,不能再有第二個人知道它。
而相對的,所有的客戶端(瀏覽器)同時也會有一個叫做『公鑰』的東西,它是對所有人公開的,任何人都可以擁有它。它與『私鑰』合稱為一個密鑰對。
公鑰和私鑰的規則是:
-
公鑰加密的東西,只有私鑰能解。因此如果 JWT 的 payload 里有你的敏感信息,那也不要緊,只要把 JWT 用公鑰(前提是這個公鑰得是正確的,下面會說到)加密一下,那黑客就算拿到了這個密文,也無法解密,因為私鑰只有服務器才有。
-
私鑰加密的東西,所有的公鑰也都能解。因此服務器發給客戶端的 JWT 的payload 盡量不要有敏感信息。
那么問題又來了,如果客戶端拿到的公鑰,是黑客偽造的,客戶端拿著這個假公鑰加密自己的敏感信息,然后發出去,黑客在拿到這個用自己偽造的公鑰加密的數據,非常開心,因為這個公鑰對應的私鑰在自己手里,自己是可以解密得到里面的數據的。
因此如何保證服務器發給客戶端(瀏覽器)的公鑰是正確的呢?
答案是通過數字證書來保證。但是由于這個不是本文的重點,因此我將這塊內容放在后面的文章詳細解釋。
寫在最后
最后,我總結一下,本文的要點:
-
CSRF 攻擊的產生,需要cookie 的『助攻』,否則無法完成。
-
CRSF 是利用 cookie,而不是盜取 cookie,這點一定要明白。
-
但也并不是使用了 cookie 就會有 CSRF 風險,而應該說是用 cookie 去做鑒權才會有 CSRF 風險,參考 CSRF Token (把 token 存儲在 cookie 的情況)和 JWT (把 token 存儲在 cookie 的情況)。
-
CSRF Token 和 JWT 雖然都可以做到防御 CSRF 攻擊,但其實無論是哪個都無法同時做到防御 CSRF 和 XSS 攻擊,在阻止了 CSRF 攻擊后, 需要再通過其他手段來減少 XSS 攻擊的可能性。
-
JWT 就是一個由服務端按照一定的規則生成的字符串,
-
JWT 的目的是為了做一個無狀態的 session,避免去頻繁查詢 session,減少了對服務器產生的壓力,簡化后端架構模型。它的主要用途是解決跨域認證的問題,而解決 CSRF 跨域攻擊只是它的附帶功能。
-
payload 是經過 base64URL 算法轉換而成的字符串,是可逆的,因此盡量不要存放敏感數據,如若非要存放敏感數據,最好與 HTTPS 協議搭配使用,避免數據泄露。
-
JWT 的保存位置與方式,沒有絕對的方案,具體如何選擇要視情況而定。