lgwebdream / FE-Interview

🔥🔥🔥 前端面试,独有前端面试题详解,前端面试刷题必备,1000+前端面试真题,Html、Css、JavaScript、Vue、React、Node、TypeScript、Webpack、算法、网络与安全、浏览器
https://lgwebdream.github.io/FE-Interview/
Other
6.89k stars 895 forks source link

Day377:说一下你了解的 WebSocket 鉴权授权方案? #1211

Open Genzhen opened 3 years ago

Genzhen commented 3 years ago

每日一题会在下午四点在交流群集中讨论,五点小程序中更新答案 欢迎大家在下方发表自己的优质见解

二维码加载失败可点击 小程序二维码

扫描下方二维码,收藏关注,及时获取答案以及详细解析,同时可解锁800+道前端面试题。


一、WebSocket 协议

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 连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为“握手”。

实现步骤


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

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

直观的协商及通讯过程:

img

三、鉴权授权实现方案

通过对协议实现的解读可知:在 HTTP 切换到 Socket 之前,没有什么好的机会进行鉴权,因为在这个时间节点,报文(或者说请求的 Headers)必须遵守协议规范。但这不妨碍我们在协议切换完成后,进行鉴权授权:

3.1 鉴权

  1. 在连接建立时,检查连接的 HTTP 请求头信息(比如 cookies 中关于用户的身份信息)
  2. 在每次接收到信息时,检查连接是否已经授权过,及授权是否过期
  3. 以上两点,只要答案为否,则服务端主动关闭 socket 连接

3.2 授权

服务端在连接建立时,颁发一个 ticket 给 peer 端,这个 ticket 可以包含但不限于:

3.3 安全性补充说明

比如,这一套机制如何防范重放攻击?个人认为可以从以下几点出发:

3.4 代码实现

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 (!checkIdentify(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.upgrade.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.crated = 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',unique_id);
        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");
  },
};