04.18 實現一個靠譜的Web認證

Web認證是任何一個認真一點的網站都必須實現的基本功能。這個功能解決了讓服務器“認識你就是你“的問題。這個功能看起來貌似很簡單,但是實際上處處是坑。因為認證是依靠一套技術整體運作才能完成,所以僅僅是把一些現成的技術簡單拼起來是不夠的。你必須瞭解每一種技術能做什麼,不能做什麼,解決了哪些問題,才能精心設計一套認證功能。

兩種認證

目前市面上能見到的認證方式分為兩大種——基於Session的和基於Token的。

所謂基於Session的認證,是指在客戶端存儲一個Session Id。認證時,請求攜帶Session Id,並由服務器從Session數據存儲中找到對應的Session。這種方式在很多網站框架下都有

所謂基於Token的認證,是指將所有認證相關的信息在服務器端編碼成一個Token,並由服務器簽名,以確保不被篡改。Token本身是明文的。存在Token裡的信息可以有比如user id、權限列表、用戶暱稱一類的。這樣服務器只要拿著token和token的簽名,就可以直接驗證用戶的身份是合法的。在現實當中,基於Token的認證的主要標準是Json Web Token (JWT),見RFC 7519。

實現一個靠譜的Web認證

認證方式

但是我不得不說的是,基於Token的認證在現實當中並不是很實用……

JWT

一個JWT大概長這樣:

base64(header).base64(json payload).signature
  • header部分描述一些基本信息,比如這個token是用什麼算法簽名的,是什麼版本的等等。
  • payload就是一個json object。你可以任意放置你想要的信息,只要符合json的格式即可。標準中已經規定好了有一些字段的意思,比如iat表示issue at,token簽發的時間;exp表示token過期的時間等等。根據這些約定就可以實現一些小的代碼庫來檢查比如token是不是過期了等等。但是請注意,很多人誤解,認為JWT是加了密的,但其實payload是明文的
  • signature是一個簽名。服務器端可以自行選擇一個算法和一個secret,與payload拼接上,得到一個簽名。secret並不會在網絡中傳輸,所以客戶端無法偽造一個JWT。這樣,一旦一個簽名生成,再傳回給服務器,服務器就可以知道這個token是不是它當初生成的。

通過這樣的機制,JWT中可以存儲一些認證必要的信息。給定一個JWT,服務器只要驗證:

  • 這個JWT的簽名是對的
  • 這個JWT還在生效(即當前時間在JWT生效時刻之後,在失效時刻之前)

之後服務器就可以信任這個JWT中包含的信息,包括user id、包含的權限等等。服務器不需要自己再去查詢一遍這個用戶的信息,以及這個用戶的權限信息,就可以對請求作出相應。不用session了,無狀態大法好!然而,需要潑一下冷水的是:

  • 使用了JWT,無法實現在服務器端對用戶請求進行管理——管理員沒法統計多少個人登錄了,一個人登錄了多少次,登陸了什麼設備;同時,也無法強行“踢”掉一個用戶的登錄——JWT一旦生成,在失效之前,總是有效的。如果實現了一個token黑名單之類的功能,就等價於實現了Session機制,無狀態帶來的好處就無從談起。這個限制對於任何一個要認真做用戶風險控制的網站來說都是不可能接受的。
  • 使用了JWT,無法很好的控制payload的數據量。儘管規範表示,應該只把認證的相關信息放到payload裡。但實際上,開發人員往往會誤用,把幾乎所有和user相關的數據都放到payload裡。而payload的尺寸過大,比如達到數KB,就會極大的損耗帶寬和IO性能。要記得,為了達成“無狀態”,每個請求都必須把全量的JWT都帶著……

這兩個嚴重的缺陷限定了JWT只能用到一些不太認真的場景。而對於真正的社交、金融、遊戲等認真一點的服務,還是要選擇基於Session的認證。

當然,token中的簽名還是有好處的,簽名可以確保token的確是服務器產生的,不會被篡改。如果token中包含了user id,那麼還可以實現簡單的前端錯誤上報;如果token中還有session id,就可以在服務器端實現基於Session的認證。因此,你可以將user id、session id、token過期時間等幾個關鍵數據放到payload裡——只放這幾個,不放其他的數據,得到一個用來做Session認證的JWT。更進一步,如果你把JWT的規範稍微小改一下,比如payload不用JSON,而是更緊湊的格式;定死了簽名算法,即可省略JWT的header了;最後再優化一下編碼格式,就能得到一個你自己的token。

但,無論用session還是token,還是什麼其他的名字,這些都不重要。重要的是服務器這邊必須實現session機制,以便於對用戶登錄信息進行有效的管理。

有人告訴過我一個使用基於Token + 無狀態的認證方式的原因:他們的存儲是一個雲服務,並且按照調用次數收費。所以他們讓用戶每次將Token傳給服務器,就是希望儘量少的調用那個雲服務。對此我表示很無語……

怎麼存儲認證信息

談完了session和token,我們來說所說這個信息在客戶端怎麼存儲。客戶算也分兩大類——瀏覽器和Native App。先說說瀏覽器。

瀏覽器

瀏覽器中的存儲主要是Local Storage和Cookie。

其實瀏覽器用於存放認證信息的存儲還有Session Storage,但是它和Local Storage差不多,只是失效的機制不太一樣。這裡只用Local Storage討論。

使用基於Token認證的開發人員很喜歡使用Header + Local Storage。因為這樣可以有效防止CSRF (下一小節專門講)。

但是使用Local Storage,反而會增加中招XSS(Crossing Site Script)的機會。一旦中招XSS,攻擊者可以輕易的拿到認證信息,並且傳回自己的接受網址而不被用戶察覺。這樣一來攻擊者能夠輕易的代替用戶登錄了。

整個瀏覽器中,只有一種資源是腳本無法訪問到的。這就是被設置為HttpOnly的cookie。這是非常理想的放置認證token/session id的地方。設置這種token只需要在Set Cookie時這麼寫:

Set-Cookie: access_token=xxxxxxxxxxxxxxxxxx; HttpOnly; Secure; Same-Site=strict; Path=/; 

(Secure和Same-Site是什麼?下文會解釋)

XSS攻擊者沒有任何辦法從HttpOnly的Cookie中拿到你的認證信息,除非他能在你登錄網站後,直接進入你的電腦,打開瀏覽器的開發者工具並人肉複製粘貼(叫你不鎖屏,哼)。

有些人堅稱自己的程序可以保證不受XSS的攻擊,所以可以放心的用Local Storage。比如使用React框架開發的程序理論上所有的DOM節點都由React的虛擬DOM產生,所有的標籤生成都進行了escape。espace掉的腳本就無法執行,也就不可能XSS了。

這樣講沒有錯誤,但是XSS最令人頭疼的地方在於你很難保證你的網站對所有用戶的輸入都進行了escape。

  • 你編寫的是一個寫文章的網站,需要支持用戶手工輸入HTML,並且HTML必須得直接展示;
  • 你編寫的網站99%是React這樣的框架生成的,但是可能會有一些邊角,為了方便還是使用jquery等傳統技術
  • 你的網站是一個團隊開發,儘管開發規範要求大家都要對用戶的輸入進行escape處理,但是隻要是人就會忘,而escape這件事情不一定能進入到測試的Case清單;
  • ……

只要有一個漏洞存在,那麼整個防護體系就完全失效。這就是為什麼HttpOnly Cookie這樣的絕對隔離措施很關鍵的原因。

Native App

Native App比瀏覽器相對簡單。一般Native App都是靜態編譯產生,不存在XSS的問題。同時移動操作系統都會有沙箱機制,避免其他App讀取到自己的數據(除非你root了……)。因此,Native App可以比較放心的將數據存在App沙箱內某個存儲即可。如果不放心,可以考慮如iOS KeyChain或者Android KeyStore這樣的設施。

但Native App與服務器交互有一些區別。Native App一般是不直接支持Cookie機制的。所以如果一個服務器端使用Cookie來保存認證信息,就需要Natvie App手工解析Set-Cookie Header,同時,Native App因為不直接支持Cookie,所以傾向於在請求中使用AuthorizationHeader來傳入認證信息。這也需要服務器適配。當然,最簡單的辦法是讓Native App引入一個模擬Cookie行為的庫。

防止CSRF

CSRF代表Crossing Site Recource Forge。大致的觸發流程是:

  1. 用戶登錄了站點A,並且在Cookie中留下了A站點的認證信息
  2. 用戶進入了站點B,而站點B用一些方式(比如一個提交行為是到A站點某關鍵接口的表單)引誘用戶去點擊。當用戶點擊時,會發出到A站點的請求。而瀏覽器會給這個請求附帶上A站點的認證信息,從而讓這個請求能夠執行。這種行為可能是,但不限於,給某個A站點的某個其他用戶提權/轉賬/發文辱罵等等。

上文中提到了,很多人用JWT+Local Storage的本心是為了防護CRSF。這樣做的原因是——因為Cookie的發送是完全由瀏覽器控制的,不受網頁本身的控制。所以最簡單直接的辦法,就是不用Cookie,不讓自動發送認證信息成為可能。問題在於,這麼幹是有XSS風險的。從上文中可以看到,為了避免XSS,就必須用HttpOnlyCookie。

那麼怎麼在使用Cookie的同時,還能防範CSRF呢?

傳統頁面Web網站

在傳統頁面Web網站中,一般會使用CSRF Token。這是個非常流行的做法。像Tomcat這類的容器都會自帶CSRF Token的產生和檢查Filter。

CSRF Token是這樣工作的。客戶端要首先向服務器請求一個帶有提交表單的頁面,服務器返回的頁面中會嵌入一個CSRF Token。當用戶提交表單時,CSRF Token會被一起攜帶發給服務器做驗證。所以當服務器看到CSRF Token,就可以放心大膽的確認

用戶的的確確是看看到了提交前的表單界面,從而避免了用戶稀裡糊塗提交一個被偽造的表單的可能性。

SPA

CSRF Token只適合於傳統的頁面請求,在SPA的情況下會比較尷尬。

在SPA中,客戶端與服務器之間的交互主要是通過接口完成的,沒有頁面的概念。此時,你的確可以照貓畫虎的做一個接口讓用戶拿到CSRF Token,但這樣什麼也確認不了。因為攻擊者可以調用同樣的接口,拿到合法的CSRF Token。

這時有幾種辦法:

  • 給所有接口都添加一個請求secret,來標記其來自於合法的客戶端。這個secrect可以是固定的隨機字符串,也可以通過某些動態算法產生。對於CSRF,瀏覽器只會做自動傳Cookie而已,並不能幫助傳入secret。這樣一來,就可以確定消除CSRF的風險。但注意,這個機制僅能防範CSRF,而不能防範人為的攻擊。黑客只要拿得到客戶端,就一定能找到生成secret的辦法。
  • secret有一個順帶的功能是提高了第三方用戶隨意調用接口的門檻——他們必須得去查看客戶端源代碼,學會了怎麼生成secret才能調用接口。
  • 用Same-Site Cookie。回到上面CSRF步驟的第二步驟。當用戶看到了B站點偽造的表單,點擊了提交,向站點A發出請求時,被標記了Same-Site=strict的Cookie是不會被攜帶的,因為當時的主站點域名B和提交的站點域名A不一樣。這是Same-Site=strict標記是個相對較新的標準。目前大部分瀏覽器都已經支持了。但如果大量的用戶群還在類似於IE8這樣的老系統上,這個招數便是無效的。
  • 歪招,總是用json格式提交。CSRF可能發生的一個前提條件是必須用傳統表單提交。這是因為傳統表單提交可以跨域——你在站點B,可以提交表單給站點A。而Ajax的請求除非開啟CORS,是不允許跨域的,所以天然的屏蔽掉了這個問題。傳統表單的提交的格式必然是application/x-www-form-urlencoded。因此只要保證服務器能夠拒絕處理所有application/x-www-form-urlencoded格式的POST請求,就能確保SPA不受CSRF的影響。那用啥呢?JSON - application/json。(我專門寫這一條的原因是,jquery的ajax庫的默認行為正是使用application/x-www-form-urlencoded格式。如果你還在用,可以考慮改一下。)
  • 另一個歪招,雙認證。將你的認證信息同時放在HttpOnly Cookie和Authorization Header。服務器要先比對這兩個值是一樣的,然後再去執行認證過程。這樣可以同時防範XSS和CSRF。代價是,如果你的認證信息比較長,會浪費一些帶寬。

總是使用https

大學上網絡課時,老師講解了http的一些原理,然後給我們留了個作業——去外邊提供WIFI的餐廳用嗅探器扒別人的密碼。兩週後,我們做完了作業,心情是悲催的——尼瑪互聯網都發明瞭十幾年了,連最基本的防護都沒有……

http是明文傳輸的。在http下,用戶輸入的任何信息,從他的電腦到服務器之間的每個鏈路節點都是明文的。在這裡個鏈路中的任何地方,都可以截取到完整的數據,包含你的密碼,認證token……

這就是為什麼https是必須的。https主要提供三個保證:

  • 端端加密。通過https交互的原始數據,只有用戶的瀏覽器和最終的服務器可以看到。其他中間節點無法獲)。
  • 客戶端可以認定要訪問的服務器就是那個服務器。這是被證書體系所支撐的。一旦瀏覽器的地址欄出現了網址的證書信息,並且是綠色的提示,那麼用戶的心裡就可以穩了。(當然國內其實也不完全是這樣,講多了查水錶,懂者自懂)。
  • 服務器可以認定訪問的客戶端就是合法的客戶端。這種模式被稱為“2-Way SSL”或者“Mutal SSL”。這種模式是可選的,需要多配置一個客戶端證書,一般場景用不到,多見於企業Web服務。

早些時候,很多人對https有一些牴觸,大致的原因是,支持https需要軟件改造;服務器對證書進行密碼學運算要耗費很多CPU;同時也會帶來跟多的網絡請求和響應(多了ssl握手)。這無疑會帶來一些成本和體驗上的問題。但那已經是10多年前的事情了。現在的軟硬件處理能力和網絡基礎設施比起10年前都有數倍的提高。如果今天,一個商業網站仍然堅持不用https,那麼可以請他的老闆去大街上裸奔。

使用了https後,為了進一步保證安全,將Cookie設置為Secure。這樣,瀏覽器就可以只在訪問https網址時才會攜帶Cookie。如果不做這樣的設置,通過https站點設置的Cookie,仍然會向http站點發送。當這個站點的域名解析被劫持,就可能造成向一個偽造的服務器發出你的認證信息。

認證信息不應該永久有效

很多人為了“用戶體驗”,選擇讓一個登錄永久有效。這樣做是非常危險的。因為一旦用戶的認證信息被別人獲取了,就永久性的失去了防禦的機會(記得上面說的不鎖電腦屏幕的後果嗎?)。

因此,總是要保證認證信息的有效期是有限的。一般根據業務場景的安全級別不同,可以設為若干分鐘~若干天。就算是社交娛樂類的應用,有效期最好也不要超過兩週。

但,為了讓頻繁使用的用戶體驗更好,可以考慮實現會話期續期。但需要注意,這裡說的續期是指從用戶角度看可以延續其不需要登錄的時間長度,而不是直接讓session/token有效期變長。必須實現為給用戶替換一個新的session id/token。這樣做,既能保證同一個認證信息不會永久有效,又能讓正常的、頻繁使用的用戶免除登錄之苦。

總結一下

總結下來,一個靠譜的Web認證應該:

  • 可以使用Session也可以使用Token做認證,但是總是要保證服務器端可以管理Session,通過Session是否存在來最終確定認證的有效性;
  • 將認證信息存放在標記為HttpOnly,Secure,Same-Site=strict的Cookie中,從而避免XSS和CSRF;
  • 總是使用https,只要你的網絡鏈路經過了公網;
  • 如果是傳統的頁面網站,請使用CSRF Token機制;
  • 如果可以,做一個簡單的請求secret,可以輔助防止CSRF,也可以稍稍的提高接口被爬取的門檻;
  • 如果是SPA應用,放心大膽的禁用對application/x-www-form-urlencoded的支持
  • 保證token/session必須有一個有效期

如果你也覺得靠譜,就不妨照著做。

鏈接:https://www.jianshu.com/p/805dc2a0f49e


分享到:


相關文章: