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)必须遵守协议规范。但这不妨碍我们在协议切换完成后,进行鉴权授权:
鉴权
- 在连接建立时,检查连接的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 网关
本文分享到这里,给朋友们推荐一个前端公众号