引子
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. 發起請求的瀏覽器端,發出協商報文:
|
1
2
3
4
5
6
7
8
|
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通訊方式),其報文:
|
1
2
3
4
5
|
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 實現:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
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):
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
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 的哈希方法:
|
1
2
3
4
5
6
7
8
9
|
import crypto from 'crypto'
export default {
sha1(str) {
const shaAlog = crypto.createHash('sha1')
shaAlog.update(str)
return shaAlog.digest('hex')
},
}
|
引用
轉自:http://www.moye.me/2017/02/10/websocket-authentication-and-authorization/
