前後端接口鑑權全解 Cookie-Session-Token 的區別

不知不覺也寫得比較長了,一次看不完建議收藏夾!本文主要解釋與請求狀態相關的術語(cookie、session、token)和幾種常見登錄的實現方式,希望大家看完本文後可以有比較清晰的理解,有感到迷惑的地方請在評論區提出。

衆所周知,http 是無狀態協議,瀏覽器和服務器不可能憑協議的實現辨別請求的上下文。

於是 cookie 登場,既然協議本身不能分辨鏈接,那就在請求頭部手動帶着上下文信息吧。

舉個例子,以前去旅遊的時候,到了景區可能會需要存放行李,被大包小包壓着,旅遊也不開心啦。在存放行李後,服務員會給你一個牌子,上面寫着你的行李放在哪個格子,離開時,你就能憑這個牌子和上面的數字成功取回行李。

cookie 做的正是這麼一件事,旅客就像客戶端,寄存處就像服務器,憑着寫着數字的牌子,寄存處(服務器)就能分辨出不同旅客(客戶端)。

你會不會想到,如果牌子被偷了怎麼辦,cookie 也會被偷嗎?確實會,這就是一個很常被提到的網絡安全問題——CSRF。

cookie 誕生初似乎是用於電商存放用戶購物車一類的數據,但現在前端擁有兩個 storage(local、session),兩種數據庫(websql、IndexedDB),根本不愁信息存放問題,所以現在基本上 100% 都是在連接上證明客戶端的身份。例如登錄之後,服務器給你一個標誌,就存在 cookie 裏,之後再連接時,都會自動帶上 cookie,服務器便分清誰是誰。另外,cookie 還可以用於跟蹤一個用戶,這就產生了隱私問題,於是也就有了 “禁用 cookie” 這個選項(然而現在這個時代禁用 cookie 是挺麻煩的事情)。

設置方式

現實世界的例子明白了,在計算機中怎麼才能設置 cookie 呢?一般來說,安全起見,cookie 都是依靠 set-cookie 頭設置,且不允許 JavaScript 設置。

Set-Cookie: <cookie-name>=<cookie-value>
Set-Cookie: <cookie-name>=<cookie-value>; Expires=<date>
Set-Cookie: <cookie-name>=<cookie-value>; Max-Age=<non-zero-digit>
Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>
Set-Cookie: <cookie-name>=<cookie-value>; Path=<path-value>
Set-Cookie: <cookie-name>=<cookie-value>; Secure
Set-Cookie: <cookie-name>=<cookie-value>; HttpOnly

Set-Cookie: <cookie-name>=<cookie-value>; SameSite=Strict
Set-Cookie: <cookie-name>=<cookie-value>; SameSite=Lax
Set-Cookie: <cookie-name>=<cookie-value>; SameSite=None; Secure

// Multiple attributes are also possible, for example:
Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly

其中 <cookie-name>=<cookie-value> 這樣的 kv 對,內容隨你定,另外還有 HttpOnly、SameSite 等配置,一條 Set-Cookie 只配置一項 cookie。

Secure 和 HttpOnly 是強烈建議開啓的。SameSite 選項需要根據實際情況討論,因爲 SameSite 可能會導致即使你用 CORS 解決了跨越問題,依然會因爲請求沒自帶 cookie 引起一系列問題,一開始還以爲是 axios 配置問題,繞了一大圈,然而根本沒關係。

其實因爲 Chrome 在某一次更新後把沒設置 SameSite 默認爲 Lax,你不在服務器手動把 SameSite 設置爲 None 就不會自動帶 cookie 了。

發送方式

參考 MDN,cookie 的發送格式如下(其中 PHPSESSID 相關內容下面會提到):

Cookie: <cookie-list>
Cookie: name=value
Cookie: name=value; name2=value2; name3=value3

Cookie: PHPSESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1

在發送 cookie 時,並不會把上面提到的 Expires 等配置傳到服務器,因爲服務器在設置後就不需要關心這些信息了,只要現代瀏覽器運作正常,收到的 cookie 就是沒問題的。

Session

從 cookie 說到 session,是因爲 session 纔是真正的 “信息”,如上面提到的,cookie 是容器,裏面裝着 PHPSESSID=298zf09hf012fh2;,這就是一個 session ID。

不知道 session 和 session id 會不會讓你看得有點頭暈?

當初 session 的存在就是要爲客戶端和服務器連接提供的信息,所以我將 session 理解爲信息,而 session id 是獲取信息的鑰匙,通常是一串唯一的哈希碼。

接下來分析兩個 node.js express 的中間件,理解兩種 session 的實現方式。

session 信息可以儲存在客戶端,如 cookie-session,也可以儲存在服務器,如 express-session。使用 session ID 就是把 session 放在服務器裏,用 cookie 裏的 id 尋找服務器的信息。

客戶端儲存

對於 cookie-session 庫,比較容易理解,其實就是把所有信息加密後塞到 cookie 裏。其中涉及到 cookies 庫。在設置 session 時其實就是調用 cookies.set,把信息寫到 set-cookie 裏,再返回瀏覽器。換言之,取值和賦值的本質都是操作 cookie

瀏覽器在接收到 set-cookie 頭後,會把信息寫到 cookie 裏。在下次發送請求時,信息又通過 cookie 原樣帶回來,所以服務器什麼東西都不用存,只負責獲取和處理 cookie 裏的信息,這種實現方法不需要 session ID。

這是一段使用 cookie-session 中間件爲請求添加 cookie 的代碼:

const express = require('express')
var cookieSession = require('cookie-session')
const app = express()
app.use(
  cookieSession({
    name: 'session',
    keys: [
      /* secret keys */
      'key',
    ],
    // Cookie Options
    maxAge: 24 * 60 * 60 * 1000, // 24 hours
  })
)
app.get('/'function(req, res) {
  req.session.test = 'hey'
  res.json({
    wow: 'crazy',
  })
})

app.listen(3001)

在通過 app.use(cookieSession()) 使用中間件之前,請求是不會設置 cookie 的,添加後再訪問(並且在設置 req.session 後,若不添加 session 信息就沒必要、也沒內容寫到 cookie 裏),就能看到服務器響應頭部新增了下面兩行,分別寫入 session 和 session.sig:

Set-Cookie: session=eyJ0ZXN0IjoiaGV5In0=; path=/; expires=Tue, 23 Feb 2021 01:07:05 GMT; httponly
Set-Cookie: session.sig=QBoXofGvnXbVoA8dDmfD-GMMM6E; path=/; expires=Tue, 23 Feb 2021 01:07:05 GMT; httponly

然後你就能在 DevTools 的 Application 標籤看到 cookie 成功寫入。session 的值 eyJ0ZXN0IjoiaGV5In0= 通過 base64 解碼即可得到 {"test":"hey"},這就是所謂的 “將 session 信息放到客戶端”,因爲 base64 編碼並不是加密,這就跟明文傳輸沒啥區別,所以請不要在客戶端 session 裏放用戶密碼之類的機密信息

即使現代瀏覽器和服務器做了一些約定,例如使用 https、跨域限制、還有上面提到 cookie 的 httponly 和 sameSite 配置等,保障了 cookie 安全。但是想想,傳輸安全保障了,如果有人偷看你電腦裏的 cookie,密碼又恰好存在 cookie,那就能無聲無息地偷走密碼。相反的,只放其他信息或是僅僅證明 “已登錄” 標誌的話,只要退出一次,這個 cookie 就失效了,算是降低了潛在危險。

說回第二個值 session.sig,它是一個 27 字節的 SHA1 簽名,用以校驗 session 是否被篡改,是 cookie 安全的又一層保障。

服務器儲存

既然要儲存在服務器,那麼 express-session 就需要一個容器 store,它可以是內存、redis、mongoDB 等等等等,內存應該是最快的,但是重啓程序就沒了,redis 可以作爲備選,用數據庫存 session 的場景感覺不多。

express-session 的源碼沒 cookie-session 那麼簡明易懂,裏面有一個有點繞的問題,req.session 到底是怎麼插入的?

不關注實現可以跳過下面幾行,有興趣的話可以跟着思路看看 express-session 的源碼:

我們可以從 .session = 這個關鍵詞開始找,找到:

於是全局搜索 createSession,鎖定 index 裏的 inflate(就是填充的意思)函數。

最後尋找 inflate 的調用點,是使用 sessionID 爲參數的 store.get 的回調函數,一切說得通啦——

在監測到客戶端送來的 cookie 之後,可以從 cookie 獲取 sessionID,再使用 id 在 store 中獲取 session 信息,掛到 req.session 上,經過這個中間件,你就能順利地使用 req 中的 session。

那賦值怎麼辦呢?這就和上面儲存在客戶端不同了,上面要修改客戶端 cookie 信息,但是對於儲存在服務器的情況,你修改了 session 那就是 “實實在在地修改” 了嘛,不用其他花裏胡哨的方法,內存中的信息就是修改了,下次獲取內存裏的對應信息也是修改後的信息。(僅限於內存的實現方式,使用數據庫時仍需要額外的寫入)

在請求沒有 session id 的情況下,通過 store.generate 創建新的 session,在你寫 session 的時候,cookie 可以不改變,只要根據原來的 cookie 訪問內存裏的 session 信息就可以了。

var express = require('express')
var parseurl = require('parseurl')
var session = require('express-session')

var app = express()

app.use(
  session({
    secret: 'keyboard cat',
    resave: false,
    saveUninitialized: true,
  })
)

app.use(function(req, res, next) {
  if (!req.session.views) {
    req.session.views = {}
  }

  // get the url pathname
  var pathname = parseurl(req).pathname

  // count the views
  req.session.views[pathname] = (req.session.views[pathname] || 0) + 1

  next()
})

app.get('/foo'function(req, res, next) {
  res.json({
    session: req.session,
  })
})

app.get('/bar'function(req, res, next) {
  res.send('you viewed this page ' + req.session.views['/bar'] + ' times')
})

app.listen(3001)

兩種儲存方式的對比

首先還是計算機世界最重要的哲學問題:時間和空間的抉擇。

儲存在客戶端的情況,解放了服務器存放 session 的內存,但是每次都帶上一堆 base64 處理的 session 信息,如果量大的話傳輸就會很緩慢。

儲存在服務器相反,用服務器的內存拯救了帶寬。

另外,在退出登錄的實現和結果,也是有區別的。

儲存在服務器的情況就很簡單,如果 req.session.isLogin = true 是登錄,那麼 req.session.isLogin = false 就是退出。

但是狀態存放在客戶端要做到真正的 “即時退出登錄” 就很困難了。你可以在 session 信息里加上過期日期,也可以直接依靠 cookie 的過期日期,過期之後,就當是退出了。

但是如果你不想等到 session 過期,現在就想退出登錄!怎麼辦?認真想想你會發現,僅僅依靠客戶端儲存的 session 信息真的沒有辦法做到。

即使你通過 req.session = null 刪掉客戶端 cookie,那也只是刪掉了,但是如果有人曾經把 cookie 複製出來了,那他手上的 cookie 直到 session 信息裏的過期時間前,都是有效的。

說 “即時退出登錄” 有點標題黨的意味,其實我想表達的是,你沒辦法立即廢除一個 session,這可能會造成一些隱患。

Token

session 說完了,那麼出現頻率超高的關鍵字 token 又是什麼?

不妨谷歌搜一下 token 這個詞,可以看到冒出來幾個(年紀大的人)比較熟悉的圖片:密碼器。過去網上銀行不是隻要短信認證就能轉賬,還要經過一個密碼器,上面顯示着一個變動的密碼,在轉賬時你需要輸入密碼器中的代碼才能轉賬,這就是 token 現實世界中的例子。憑藉一串碼或是一個數字證明自己身份,這事情不就和上面提到的行李問題還是一樣的嗎……

** 其實本質上 token 的功能就是和 session id 一模一樣。** 你把 session id 說成 session token 也沒什麼問題(Wikipedia 裏就寫了這個別名)。

其中的區別在於,session id 一般存在 cookie 裏,自動帶上;token 一般是要你主動放在請求中,例如設置請求頭的 Authorizationbearer:<access_token>

然而上面說的都是一般情況,根本沒有明確規定!

劇透一下,下面要講的 JWT(JSON Web Token)!他是一個 token!但是裏面放着 session 信息!放在客戶端,並且可以隨你選擇放在 cookie 或是手動添加在 Authorization!但是他就叫 token!

所以,個人覺得你不能通過存放的位置判斷是 token 或是 session id,也不能通過內容判斷是 token 或是 session 信息,session、session id 以及 token 都是很意識流的東西,只要你明白他是什麼、怎麼用就好了,怎麼稱呼不太重要。

另外在搜索資料時也看到有些文章說 session 和 token 的區別就是新舊技術的區別,好像有點道理。

在 session 的 Wikipedia 頁面上 HTTP session token 這一欄,舉例都是 JSESSIONID (JSP)、PHPSESSID (PHP)、CGISESSID (CGI)、ASPSESSIONID (ASP) 等比較傳統的技術,就像 SESSIONID 是他們的代名詞一般;而在研究現在各種平臺的 API 接口和 OAuth2.0 登錄時,都是使用 access token 這樣的字眼,這個區別着實有點意思。

理解 session 和 token 的聯繫之後,可以在哪裏能看到 “活的” token 呢?

打開 GitHub 進入設置,找到 Settings / Developer settings,可以看到 Personal access tokens 選項,生成新的 token 後,你就可以帶着它通過 GitHub API,證明 “你就是你”。

在 OAuth 系統中也使用了 Access token 這個關鍵詞,寫過微信登錄的朋友應該都能感受到 token 是個什麼啦。

Token 在權限證明上真的很重要,不可泄漏,誰拿到 token,誰就是 “主人”。所以要做一個 Token 系統,刷新或刪除 Token 是必須要的,這樣在儘快彌補 token 泄漏的問題。

在理解了三個關鍵字和兩種儲存方式之後,下面我們正式開始說 “用戶登錄” 相關的知識和兩種登錄規範——JWT 和 OAuth2.0。

接着你可能會頻繁見到 Authentication 和 Authorization 這兩個單詞,它們都是 Auth 開頭,但可不是一個意思,簡單來說前者是驗證,後者是授權。在編寫登錄系統時,要先驗證用戶身份,設置登錄狀態,給用戶發送 token 就是授權

JWT

全稱 JSON Web Token(RFC 7519),是的,JWT 就是一個 token。爲了方便理解,提前告訴大家,JWT 用的是上面客戶端儲存的方式,所以這部分可能會經常用到上面提到的名稱。

結構

雖說 JWT 就是客戶端儲存 session 信息的一種,但是 JWT 有着自己的結構:Header.Payload.Signature(分爲三個部分,用 . 隔開)

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

typ 說明 token 類型是 JWT,alg 代表簽名算法,HMAC、SHA256、RSA 等。然後將其 base64 編碼。

Payload

{
  "sub""1234567890",
  "name""John Doe",
  "admin"true
}

Payload 是放置 session 信息的位置,最後也要將這些信息進行 base64 編碼,結果就和上面客戶端儲存的 session 信息差不多。

不過 JWT 有一些約定好的屬性,被稱爲 Registered claims,包括:

Signature

最後一部分是簽名,和上面提到的 session.sig 一樣是用於防止篡改,不過 JWT 把簽名和內容組合到一起罷了。

JWT 簽名的生成算法是這樣的:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

使用 Header 裏 alg 的算法和自己設定的密鑰 secret 編碼 base64UrlEncode(header) + "." + base64UrlEncode(payload)

最後將三部分通過 . 組合在一起,你可以通過 jwt.io Debugger 形象地看到 JWT 的組成原理:

如何使用

在驗證用戶,順利登錄後,會給用戶返回 JWT。因爲 JWT 的信息沒有加密,所以別往裏面放密碼,詳細原因在客戶端儲存的 cookie 中提到。

用戶訪問需要授權的連接時,可以把 token 放在 cookie,也可以在請求頭帶上 Authorization: Bearer <token>。(手動放在請求頭不受 CORS 限制,不怕 CSRF)

這樣可以用於自家登錄,也可以用於第三方登錄。單點登錄也是 JWT 的常用領域。

JWT 也因爲信息儲存在客戶端造成無法讓自己失效的問題,這算是 JWT 的一個缺點。

HTTP authentication

HTTP authentication 是一種標準化的校驗方式,不會使用 cookie 和 session 相關技術。請求頭帶有 Authorization: Basic <credentials> 格式的授權字段。

其中 credentials 就是 Base64 編碼的用戶名 + : + 密碼(或 token),以後看到 Basic authentication,意識到就是每次請求都帶上用戶名密碼就好了。

Basic authentication 大概比較適合 serverless,畢竟他沒有運行着的內存,無法記錄 session,直接每次都帶上驗證就完事了。

OAuth 2.0

OAuth 2.0(RFC 6749)也是用 token 授權的一種協議,它的特點是你可以在有限範圍內使用別家接口,也可以藉此使用別家的登錄系統登錄自家應用,也就是第三方應用登錄。(注意啦注意啦,OAuth 2.0 授權流程說不定面試會考哦!)

既然是第三方登錄,那除了應用本身,必定存在第三方登錄服務器。在 OAuth 2.0 中涉及三個角色:用戶、應用提供方、登錄平臺,相互調用關係如下:

     +--------+                               +---------------+
     |        |--(A)- Authorization Request ->|   Resource    |
     |        |                               |     Owner     |
     |        |<-(B)-- Authorization Grant ---|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(C)-- Authorization Grant -->| Authorization |
     | Client |                               |     Server    |
     |        |<-(D)----- Access Token -------|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(E)----- Access Token ------>|    Resource   |
     |        |                               |     Server    |
     |        |<-(F)--- Protected Resource ---|               |
     +--------+                               +---------------+

很多大公司都提供 OAuth 2.0 第三方登錄,這裏就拿小聾哥的微信舉例吧——

準備

一般來說,應用提供方需要先在登錄平臺申請好 AppID 和 AppSecret。(微信使用這個名稱,其他平臺也差不多,一個 ID 和一個 Secret)

獲取 code

什麼是授權臨時票據(code)?答:第三方通過 code 進行獲取 access_token 的時候需要用到,code 的超時時間爲 10 分鐘,一個 code 只能成功換取一次 access_token 即失效。code 的臨時性和一次保障了微信授權登錄的安全性。第三方可通過使用 https 和 state 參數,進一步加強自身授權登錄的安全性。

在這一步中,用戶先在登錄平臺進行身份校驗。

https://open.weixin.qq.com/connect/qrconnect?
appid=APPID&
redirect_uri=REDIRECT_URI&
response_type=code&
scope=SCOPE&
state=STATE
#wechat_redirect

tQeRLq

注意一下 scope 是 OAuth2.0 權限控制的特點,定義了這個 code 換取的 token 可以用於什麼接口。

正確配置參數後,打開這個頁面看到的是授權頁面,在用戶授權成功後,登錄平臺會帶着 code 跳轉到應用提供方指定的 redirect_uri

redirect_uri?code=CODE&state=STATE

授權失敗時,跳轉到

redirect_uri?state=STATE

也就是失敗時沒 code。

獲取 token

在跳轉到重定向 URI 之後,應用提供方的後臺需要使用微信給你的 code 獲取 token,同時,你也可以用傳回來的 state 進行來源校驗。

要獲取 token,傳入正確參數訪問這個接口:

https://api.weixin.qq.com/sns/oauth2/access_token?
appid=APPID&
secret=SECRET&
code=CODE&
grant_type=authorization_code

0QfsIQ

正確的返回:

{
  "access_token""ACCESS_TOKEN",
  "expires_in": 7200,
  "refresh_token""REFRESH_TOKEN",
  "openid""OPENID",
  "scope""SCOPE",
  "unionid""o6_bmasdasdsad6_2sgVt7hMZOPfL"
}

得到 token 之後你就可以根據之前申請 code 填寫的 scope 調用接口了。

使用 token 調用微信接口

Sv4Pth

例如獲取個人信息就是 GET https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN

注意啦,在微信 OAuth 2.0,access_token 使用 query 傳輸,而不是上面提到的 Authorization。

使用 Authorization 的例子,如 GitHub 的授權,前面的步驟基本一致,在獲取 token 後,這樣請求接口:

curl -H "Authorization: token OAUTH-TOKEN" https://api.github.com

說回微信的 userinfo 接口,返回的數據格式如下:

{
  "openid""OPENID",
  "nickname""NICKNAME",
  "sex": 1,
  "province":"PROVINCE",
  "city":"CITY",
  "country":"COUNTRY",
  "headimgurl":"https://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/46",
  "privilege":[ "PRIVILEGE1" "PRIVILEGE2" ],
  "unionid""o6_bmasdasdsad6_2sgVt7hMZOPfL"
}

後續使用

在使用 token 獲取用戶個人信息後,你可以接着用 userinfo 接口返回的 openid,結合 session 技術實現在自己服務器登錄。

// 登錄
req.session.id = openid
if (req.session.id) {
  //   已登錄
} else {
  //   未登錄
}
// 退出
req.session.id = null
// 清除 session

總結一下 OAuth2.0 的流程和重點:

OAuth2.0 着重於第三方登錄和權限限制。而且 OAuth2.0 不止微信使用的這一種授權方式,其他方式可以看阮老師的 OAuth 2.0 的四種方式。

其他方法

JWT 和 OAuth2.0 都是成體系的鑑權方法,不代表登錄系統就一定要這麼複雜。

簡單登錄系統其實就以上面兩種 session 儲存方式爲基礎就能做到。

  1. 使用服務器儲存 session 爲基礎,可以用類似 req.session.isLogin = true 的方法標誌該 session 的狀態爲已登錄。

  2. 使用客戶端儲存 session 爲基礎,設置 session 的過期日期和登錄人就基本能用了。

{
  "exp": 1614088104313,
  "usr""admin"
}

(就是和 JWT 原理基本一樣,不過沒有一套體系)

  1. 甚至你可以使用上面的知識自己寫一個 express 的登錄系統:

  2. 初始化一個 store,內存、redis、數據庫都可以

  3. 在用戶身份驗證成功後,隨機生成一串哈希碼作爲 token

  4. 用 set-cookie 寫到客戶端

  5. 再在服務器寫入登錄狀態,以內存爲例就是在 store 中添加哈希碼作爲屬性

  6. 下次請求帶着 cookie 的話檢查 cookie 帶來的 token 是否已經寫入 store 中即可

let store = {}

// 登錄成功後
store[HASH] = true
cookie.set('token', HASH)

// 需要鑑權的請求鍾
const hash = cookie.get('token')
if (store[hash]) {
  // 已登錄
} else {
  // 未登錄
}

// 退出
const hash = cookie.get('token')
delete store[hash]

總結

以下列出本文重點:

作者:ssshooter

https://ssshooter.com/2021-02-21-auth/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/yJAygqZHRPZBZj9hO5_f6g