Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

websocket 项目笔记[1] - 鉴权、安全、心跳 #15

Open
HXWfromDJTU opened this issue Jul 6, 2020 · 4 comments
Open

websocket 项目笔记[1] - 鉴权、安全、心跳 #15

HXWfromDJTU opened this issue Jul 6, 2020 · 4 comments

Comments

@HXWfromDJTU
Copy link
Owner

HXWfromDJTU commented Jul 6, 2020

前言

最近在做一个行情模块,后端同学建议直接上websocket练练手,也符合业界基操。这里就记录一些开发中遇到了一些问题,聊一聊解决方案。这里不再去一点点陈列websocket的知识点,主要会围绕项目的痛点来说。

ps: 食用本文时,建议出发点需要向下沉,从传输层开始思考,做类似http(应用层)需要完成的事。

选型

第三方lib

语言 框架(lib) 环境
后端 Go gin CentOS
前端 TS axios 浏览器

项目当前的前后端选型如上表,因为websocket是一个基于tcp的应用层协议,就像http客户端服务器约定的请求头响应头cookie等约定,和一发一收交互形式,websocket在使用的时候相当于将这部分约定的权利,重新交给了我们开发者。

那么如果考虑使用websocktlib进行项目构建时,则需要考察该方案在两端是否都有实现的方案,

框架(lib) 服务端支持(Go) 浏览器支持 周下载量 包大小 其他
socket.io go-socket.io 3,309,990 55.9 kB 支持策略退化到Polling
ws 25,770,149 110 kB

原生封装

因为这次的模块是websocket尝鲜,所以没有考虑太多,最后决定前端这边使用浏览器原生支持Websocket对象,根据这次的要求进行简答的封装,先趟趟坑,正式上线后再慢慢考察框架。

开工前,几个前端小伙伴做到了一起,提出了自己对这个SDK的期望。

  1. 该模块是个人模块,需要考虑需要进行鉴权。
  2. 作为调用方,我可不想管你接口是websocket还是http,请自己在接口层封装好。
  3. 你作为一个请求libreqresinterceptor还是要有的吧。
  4. 啊呀,我们用axios都习惯了,接口返回的是一个请求的Promise,这次也最后保持一致吧

办公室不大,后端的同学也听到了讨论,附和到:

  1. 这边还需要加个心跳💓机制啊,十分钟吧,没有发送消息,我这边就断开啦。
  2. 虽然,前期我们只做信道复用实现无刷新请求。可别忘啦,后期我们还是要做服务端消息推送的。
  3. 你们赶紧吧,下下周可就deadline了...

嘚嘞,上马开工...

鉴权

项目当前的鉴权是依赖用户登录后,服务端下发用户tokencookie中,搭配header中的某个字段进行使用。

信道建立时鉴权

由于websocket在传输数据的时候,并不存在和http协议一样的cookierequest header机制。但信道建所用的请求,仍是http请求,你也一定见过这个请求的报文。

前端视角

服务端视角
const crypto = require('crypto');

// 生成 websocket AcceptKey
function generateAcceptKey (websocketReqKey: string): string {
  const magic = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
  return crypto.createHash('sha1')
  .update(websocketReqKey + magic)
  .digest('base64');
}

const headerParser = require('parse-headers')

const server = net.createServer(socket => {

  socket.on('data', async chunk => {
    const headers = headerParser(Buffer.from(chunk).toString())

    // 检测到是 websocket 建立信道的请求
    if (headers['upgrade'] && headers['sec-websocket-key']) {
      const secWebSocketAccept = generateAcceptKey(headers['sec-websocket-key'])

      const cookies = header['cookie']

      // 请求用户服务身份验证
      const authorized = await checkAuth(cookie.authToken)

      if (authorized) {
        socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n' +
          'Upgrade: WebSocket\r\n' +
          'Connection: Upgrade\r\n' +
          'Sec-WebSocket-Accept: ' + secWebSocketAccept + '\r\n' +
          '\r\n');
      } else {
        socket.write('HTTP/1.1 403 Unauthorized\r\n' +
          '\r\n');
      }
    } else {
      socket.write('data')
    }
  })
}).listen(serverConfig.port, serverConfig.host);

ps: 对应的框架实现,可以参考ws - npm模块的verifyClient的用法,传送门👉

数据传输时鉴权

数据传输时的鉴权建议基于信道建立时鉴权的方案,用户第一次认证后,回传给客户端一个类似token的令牌,用户在每一次使用websocket进行数据传输时,则需要回传这个token到服务端进行验证。

ps: 没有什么神秘的,这里其实相当于实现了个手动cookie

心跳机制

首先说明一点,心跳机制在RFC协议中没有做规定,原则上一个连接可以无限制时间去连接,但是我们知道,服务器的内存、打开文件数量是有限的,特别是需要在同一个时间服务更多用户,则需要及时发现并断开那些已经不在线不活跃的连接。

ps: 比如做一个内部系统、公司大屏什么的,连接数不多,可以不需要心跳机制也行。

基础的心跳

  • client
    const ws = new WebSocket("ws://localhost:9527")
    
    // 大家都喜欢的 ping-pong
    ws.send('ping')
    
    // 获取接口版本号来保活
    ws.send({
      jsonrpc: '2.0',
      method: 'version',
      data: null
    })
    
  • server
    socket.on('data', data => {
       if (data === 'ping') {
         resetTimer() // 重置断开计时器
         socket.send('pong') // 发送心跳返回
       }
    })
    

调皮的nginx

项目开发完的总结会中,查看日志才发现webscoket连接从未因为达到过上限10 min未发送心跳包而断开,倒是发现不少60 s断开的日志。后端同学恍然大悟 ---- nginx

proxy_read_timeout 90;

为了可以统一在业务代码中处理心跳机制,而不是通过nginx来实现,所以我们将针对websocket请求的nginx配置调整了一下

http {
    server {
        location /ws {
            root   html;
            index  index.html index.htm;
            proxy_pass xxx.xxx.xxx.xx:9527;
            proxy_http_version 1.1;
            proxy_connect_timeout 4s;
            proxy_read_timeout 700s;      # 需要比业务心跳更长一点
            proxy_send_timeout 12s;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "Upgrade";
        }
    }
}

天有不测之风云

你一定知道客户端关闭webcsocketclose语句

// client
const ws = new WebSocket("ws://localhost:9527")
ws.close() // 关闭连接

// server
const wss = new WebSocket.Server({ port: serverConfig.port });

wss.on('connection', function connection(ws) {
  ws.on('close', function (message) {
     console.log('websocket peer closed', message) // websocket peer closed 1005
  })
})

但是在各个种情况下断开的websocket,对端又是否能够正常知晓呢?

关闭场景 出现情况 己端是否知晓 对端是否知晓 备注
ws.close() 程序代码主动关闭
刷新浏览器 程序上下文丢失
关闭浏览器1 用户点击关闭浏览器
关闭浏览器2 kill 命令杀死浏览器进程
突然断网 手痒拔网线 进电梯 心跳机制超时、nginx 设置超时才会被发现,若在超市范围内重新发送了心跳,则相当于没有断开过

跨域 与 安全

跨站点 WebSocket 劫持

信道建立依赖于http

若是项目验证身份的token是保存在cookie中的,并且我们知道websocket的信道建立是要通过http协议的upgrade完成的,那么也就存在浏览器中的CSRF问题。

不存在跨域

跨域资源共享不适应于 WebSocket,WebSocket 没有明确规定跨域处理的方法。

也就是说在浏览器层面,不需要跨域访问的资源的服务器返回Access-Control-Allow-OriginResponse Header,数据仍然能够正常返回并且解析。

原本打算的方案

针对普通的CSRF问题,先前的做法是在接口的HTTP请求头中,添加自定义的请求签名自定义头字段。这样做可以基本做到发起请求的页面,是出自我们自己的业务代码,而其他伪造请求的代码,会因为得不到签名字段而被后端拦截掉。

不支持修改的请求头

相同的,也想在websocket 信道建立的请求中照葫芦画瓢。

但实践中发现,不同于http请求,websocket请求的http Connect-upgrade请求是浏览器内部发出的,市面上常见的浏览器都不支持我们对请求头进行编写、拓展、删除。

所以这个方法行不通。

可行的方案

来了看看rfc是如何建议我们解决问题的吧

其中蓝色部分已经支出,我们可以通过信道建立http请求的origin字段进行校验,服务端可以直接拒绝掉非本站点发起的请求。

补充的方案 - token

这次的项目虽然只是web,但我们进一步司考,若发起请求(重放请求)的攻击方环境并不是浏览器呢?是客户端,甚至是脚本拦截请求后的重放呢?

这时候,我们就需要给信道建立的http请求加上更加严格的束缚 - 一次性过期的Token。

可行的实际过程可以是:

  • 服务端先通过 http 请求下发一个Token给客户端,可以是放到cookie中,但必须是一个一次性的 Token。
  • 客户端使用这个 Token 来建立信道,建立成功后之后,Token 也就随即废弃。
  • 即使遇到了重放、CSRF 劫持,也无需害怕不明的恶意攻击者能够连接上你的webscoket 服务了。

补充

这里补充说一下websocket请求头中的一些字段,算是项目安全决策的一些辅助知识。

  • request
    • Sec-WebSocket-Key: 是随机的字符串,用于后续校验。
    • Origin: 请求源
    • Upgrade: websocket
    • Connection: Upgrade
  • response
    • Sec-WebSocket-Accept: 用匹配寻找客户端连接的值,计算公式为toBase64(sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ) )
      这里的258EAFA5-E914-47DA-95CA-C5AB0DC85B11 为魔术字符串,为常量。

    若计算不正确,或者没有返回该字段,则websocket连接不能建立成功

总结

  1. websocket是基于tcp上的应用层协议,http遇到的问题websocket都会遇到,鉴权保活签名,这些在http都有现成基础可以操作,在websocket都需要使用者进行设计实现。

  2. 本文了解了websocket与后端部分传输信道的问题,下一篇则会着重讲讲如何实现一个简单的符合业务需求的websocket请求lib

参考资料

[1] 深入理解跨站点 WebSocket 劫持漏洞的原理及防范
[2] How to Use Websockets in Golang: Best Tools and Step-by-Step Guide
[3] WebSocket 的鉴权授权方案 - Mo Ye
[4] RFC - The WebSocket Protocol

@HXWfromDJTU HXWfromDJTU changed the title websocket 项目笔记 - 后端部分 websocket 项目笔记[1] - 鉴权、安全、心跳 Jul 12, 2020
@HXWfromDJTU
Copy link
Owner Author

2020 - 07 -24 补充了安全建立信道的一些方法

① 请求头参数签名 ② 检查origin ③ 一次性token

@suyar
Copy link

suyar commented Jul 7, 2021

忍不住点了个赞

@wynn-w
Copy link

wynn-w commented May 8, 2022

勘误:
[文字错误] (心跳机制 > 天有不测之风云 > 备注): 超市超时

@bestK
Copy link

bestK commented Jun 28, 2022

mark

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants