WebSocket客戶端鑒權實現,和HTTP有什么不同?


引子

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)必須遵守協議規范。但這不妨礙我們在協議切換完成后,進行鑒權授權:

鑒權

  1. 在連接建立時,檢查連接的HTTP請求頭信息(比如cookies中關於用戶的身份信息)
  2. 在每次接收到消息時,檢查連接是否已授權過,及授權是否過期
  3. 以上兩點,只要答案為否,則服務端主動關閉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')
  },
}

引用

  1. https://devcenter.heroku.com/articles/websocket-security
  2. https://tools.ietf.org/html/rfc6455
  3. 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 網關



  本文分享到這里,給朋友們推薦一個前端公眾號 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM