用 Socket.IO + Passport-jwt + Express.js 驗證 Bearer Token

一開始會接觸 Socket.IO 是因為 AlphaCamp 的作業專案中,有個加入聊天室功能的挑戰。
在該專案中,我的小組是使用前後端分離的方式作業,並且會用 Bearer Token 作為身分認證憑證。
加入 Socket.IO 的話,勢必也要通過同樣方式進行憑證認證。

實作時遇到的最大的困難點就是,要如何在 Socket.IO 傳回的訊息中,帶上 Bearer Token 的資訊。
以及在 Passport-jwt 中,要怎麼接到那個 Token。

前置

本篇介紹適用於已初步了解 Node.js、Express.js、Passport-jwt 的讀者。
本篇假設讀者已經會使用 Passport-jwt 的 Strategy 和 Passport 的 authenticate 進行驗證。
請確保已安裝 Node.js、Express.js、Passport-jwt 和 Socket.IO。

關於建立 Socket.IO 的伺服器,請參考此篇文章:使用 Socket.io + Express.js 建立聊天伺服器

步驟

1. 建立讓 Socket.IO 能使用 express middleware 的 helper

首先新增一個 socketio-helpers.js 檔:

1
module.export = middleware => (socket, next) => middleware(socket.request, {}, next)

沒錯,就是這麼簡潔的一行。
我個人習慣把小工具都另外拆成一個 .js 檔,喜歡的話也可以直接宣告一個 function 或變數。

要說明運作原理要從 socket.io 的 middleware 參數說起:

1
2
3
4
5
6
7
8
io.use((socket, next) => {
try {
next()
} catch {
next(new Error('error!'))
}
})

socket.io 的 middleware 要傳入的參數為 (socket, next) ,而 express 的 middleware 要傳入的參數為 (req, res, next)

我們在 helpers 中傳入我們要轉換給 socket.io 使用的 middleware,而 io.use 會傳入 (socket, next) ,接著我們要處理傳入的資訊,讓它們符合 express middleware 所需要的 (req, res, next)

  • req:socket.request 裡面帶有來自 client 端的 request reference
  • res:這裡沒有東西,放入空物件
  • next:直接使用 socket.io 傳入的 next

最後返回的就會是正確裝著 (req, res, next) 的 express middleware 了。

2. 在 client 端連線時加入 Bearer Token 的資訊

我們先找到設定 socket.io 連線的地方,改成這樣:

1
2
3
4
5
6
const token = 'eyJ...'
const socket = io({
auth: {
token: `Bearer ${token}`
}
})

這樣每次 socket.io 進行 emit 等連線的時候,就會在資訊中帶上 token 資料。
他會把資料放在回傳的 socket.handshake.auth 當中。

這時我們需要了解一下平常 Bearer Token 都會放在哪裡。

在我們進行的專案中,passport-jwt 設定取得 token 的方法為 fromAuthHeaderAsBearerToken()
試著輸出 req.headers 的結果我們會看到這樣的內容:

1
2
3
4
5
{
authorization: 'Bearer eyJ...',
...
}

從 passport 官網中有描述到,取得 token 的方法是可以自定義的。
可是我們要交給 passport-jwt 進行解析的是 req ,也就是 socket.request ,取到的不是 socket.handshake 也不是 socket 啊!

可是我在搜尋的時候看到有 extraHeaders 的寫法?

在許多資料內都提到可以改寫成類似這樣的格式:

1
2
3
4
5
6
const token = 'eyJ...'
const socket = io({
extraHeaders: {
authorization: `Bearer ${token}`
}
})

很遺憾的是,雖然號稱是附加 headers,但他會放在 socket.handshake.headers 裡面,而不是 req.headers 中。

此外,如果只啟用 WebSocket的傳輸方式,extraHeaders 的選項將被忽略,因為 WebSocket API 不允許提供自定義 headers。
不過,在 Node.js 或 React-Native 中會正常運作。

在這裡我卡了很久,到底要怎麼取得 token。
最後我想到了,req.headers 裡面沒有 token 的話就自己塞 token 進去就好了。

3. 加工 helper

我們把先前寫的 helper 做點修改:

1
2
3
4
module.export = middleware => (socket, next) => {
socket.request.headers.authorization = socket.handshake.auth.authorization
return middleware(socket.request, {}, next)
}

這樣就能用 passport-jwt 的fromAuthHeaderAsBearerToken() 接到 token 了!

4. 把 authenticate 放進去

最後,我們來為 socket.io 加上 middleware 吧!

1
2
3
4
5
6
7
8
9
const passport = reqire('./config/passport')
const wrapperForSocketIo = reqire('./helpers/socketio-helpers')

io.use(wrapperForSocketIo(passport.initialize()))
io.use(wrapperForSocketIo(passport.authenticate('jwt', { session: false })))

io.on('connection', socket => {
...
})

這裡預設已經有寫好 passport 的 Strategy ,關於 Strategy 的設定方法可參考 passport 官網

順利的話,這裡應該能再次看到 WebSocket 連線成功的訊息,而拿掉中間驗證用的 middleware 就會看不到訊息。

注意,由於執行 middleware 時,傳入的 socket 並沒有實際連線上 socket.io 的伺服器,如果在 middleware 間的連接失敗的話,原先要 socket 執行的動作就不會執行。
比如說,client 端傳了 socket.disconnect() 想要結束連線,若是 middleware 間連接失敗,便不會執行結束連線的動作。


這樣我們就完成了 socket.io 用 passport-jwt 驗證的寫法囉!


參考資料