引子
WebSocket 是個好東西,為我們提供了便捷且實時的通訊能力。然而,對於 WebSocket 客戶端的鑒權,協議的 RFC 是這么說的:
This protocol doesn’t prescribe any particular way that servers can authenticate clients during the WebSocket handshake. The WebSocket server can use any client authentication mechanism available to a generic HTTP server, such as cookies, HTTP authentication, or TLS authentication.
也就是說,鑒權這個事,得自己動手
協議原理
WebSocket 是獨立的、創建在 TCP 上的協議。
為了創建Websocket連接,需要通過瀏覽器發出請求,之后服務器進行回應,這個過程通常稱為“握手”。
實現步驟:
1. 發起請求的瀏覽器端,發出協商報文:
GET /chat HTTP/1.1 Host: server.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Origin: http://example.com Sec-WebSocket-Protocol: chat, superchat Sec-WebSocket-Version: 13
2. 服務器端響應101狀態碼(即切換到socket通訊方式),其報文:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Sec-WebSocket-Protocol: chat
3. 協議切換完成,雙方使用Socket通訊
直觀的協商及通訊過程:
方案
通過對協議實現的解讀可知:在 HTTP 切換到 Socket 之前,沒有什么好的機會進行鑒權,因為在這個時間節點,報文(或者說請求的Headers)必須遵守協議規范。但這不妨礙我們在協議切換完成后,進行鑒權授權:
鑒權
- 在連接建立時,檢查連接的HTTP請求頭信息(比如cookies中關於用戶的身份信息)
- 在每次接收到消息時,檢查連接是否已授權過,及授權是否過期
- 以上兩點,只要答案為否,則服務端主動關閉socket連接
授權
服務端在連接建立時,頒發一個ticket給peer端,這個ticket可以包含但不限於:
- peer端的uniqueId(可以是ip,userid,deviceid…任一種具備唯一性的鍵)
- 過期時間的timestamp
- token:由以上信息生成的哈希值,最好能加鹽
安全性的補充說明
這一套機制如何防范重放攻擊,可以從以下幾點出發:
- 可以用這里提到的expires,保證過期,如果你願意,甚至可以每次下發消息時都發送一個新的Ticket,只要上傳消息對不上這個Ticket,就斷開,這樣非Original Peer是沒法重放的
- 可以結合redis,實現 ratelimit,防止高頻刷接口,這個可以參考 express-rate-limit,原理很簡單,不展開
- 為防止中間人,最好使用wss(TLS)
代碼實現
WebSocket連接處理,基於 node.js 的 ws 實現
import url from 'url' import WebSocket from 'ws' import debug from 'debug' import moment from 'moment' import { Ticket } from '../models' const debugInfo = debug('server:global') // server 可以是 http server實例 const wss = new WebSocket.Server({ server }) wss.on('connection', async(ws) => { const location = url.parse(ws.upgradeReq.url, true) const cookie = ws.upgradeReq.cookie debugInfo('ws request from: ', location, 'cookies:', cookie) // issue & send ticket to the peer if (!checkIdentity(ws)) { terminate(ws) } else { const ticket = issueTicket(ws) await ticket.save() ws.send(ticket.pojo()) ws.on('message', (message) => { if (!checkTicket(ws, message)) { terminate(ws) } debugInfo('received: %s', message) }) } }) function issueTicket(ws) { const uniqueId = ws.upgradeReq.connection.remoteAddress return new Ticket(uniqueId) } async function checkTicket(ws, message) { const uniqueId = ws.upgradeReq.connection.remoteAddress const record = await Ticket.get(uniqueId) const token = message.token return record && record.expires && record.token && record.token === token && moment(record.expires) >= moment() } // 身份檢查,可填入具體檢查邏輯 function checkIdentity(ws) { return true } function terminate(ws) { ws.send('BYE!') ws.close() }
授權用到的 Ticket(這里存儲用到的是knex + postgreSQL):
import shortid from 'shortid' import { utils } from '../components' import { db } from './database' export default class Ticket { constructor(uniqueId, expiresMinutes = 30) { const now = new Date() this.unique_id = uniqueId this.token = Ticket.generateToken(uniqueId, now) this.created = now this.expires = moment(now).add(expiresMinutes, 'minute') } pojo() { return { ...this } } async save() { return await db.from('tickets').insert(this.pojo()).returning('id') } static async get(uniqueId) { const result = await db .from('tickets') .select('id', 'unique_id', 'token', 'expires', 'created') .where('unique_id', uniqueId) const tickets = JSON.parse(JSON.stringify(result[0])) return tickets } static generateToken(uniqueId, now) { const part1 = uniqueId const part2 = now.getTime().toString() const part3 = shortid.generate() return utils.sha1(`${part1}:${part2}:${part3}`) } }
utils 的哈希方法:
import crypto from 'crypto' export default { sha1(str) { const shaAlog = crypto.createHash('sha1') shaAlog.update(str) return shaAlog.digest('hex') }, }
引用
- https://devcenter.heroku.com/articles/websocket-security
- https://tools.ietf.org/html/rfc6455
- https://zh.wikipedia.org/wiki/WebSocket
HTTP鑒權
采用JWT(java web token):在網關中設置攔截器,如果是登陸請求,則發到校驗token微服務中;其他請求,則判斷是否攜帶有效token,通過了再轉發到具體服務,沒通過,用jwt工具類會報錯,catch錯誤,再直接通過response.setStatusCode(HttpStatus.UNAUTHORIZED);設置http狀態碼,表示失敗,並且調用return response.complete()提前完成響應。
實現:https://www.jianshu.com/p/604bb732ddd4
升級版:結合了jwt和 API 網關
本文分享到這里,給朋友們推薦一個前端公眾號