Содержание

Разница между веб-сокетами и Socket.IO / Хабр

Доброго времени суток, друзья!

Веб-сокеты и Socket.IO, вероятно, являются двумя наиболее распространенными средствами коммуникации в режиме реального времени (далее — живое общение). Но чем они отличаются?

При построении приложения для живого общения наступает момент, когда необходимо выбрать средство для обмена данными между клиентом и сервером. Веб-сокеты и Socket.IO являются самыми популярными средствами живого общения в современном вебе. Какое из них выбрать? В чем разница между этими технологиями? Давайте выясним.

Веб-сокеты


Говоря о веб-сокетах, мы имеем ввиду протокол веб-коммуникации, представляющий полнодуплексный канал коммуникации поверх простого TCP-соединения. Проще говоря, эта технология позволяет установить связь между клиентом и сервером с минимальными затратами, позволяя создавать приложения, использующие все преимущества живого общения.

Например, представьте, что вы создаете чат: вам необходимо получать и отправлять данные как можно быстрее, верно? С этим прекрасно справляются веб-сокеты! Вы можете открыть TCP-соединение и держать его открытым сколько потребуется.

Веб-сокеты появились в 2010 году в Google Chrome 4, первый RFC (6455) опубликован в 2011.

Веб-сокеты используются в следующих случаях:

  • Чаты
  • Многопользовательские игры
  • Совместное редактирование
  • Социальные (новостные) ленты
  • Приложения, работающие на основе местоположения

и т.д.

Socket.IO


Socket.IO — библиотека JavaScript, основанная (написанная поверх) на веб-сокетах… и других технологиях. Она использует веб-сокеты, когда они доступны, или такие технологии, как Flash Socket, AJAX Long Polling, AJAX Multipart Stream, когда веб-сокеты недоступны. Легкой аналогией может служить сравнение Fetch API и Axios.

Разница между веб-сокетами и Socket.IO


Главными преимуществами Socket.IO является следующее:
  • В отличие от веб-сокетов, Socket.IO позволяет отправлять сообщения всем подключенным клиентам. Например, вы пишете чат и хотите уведомлять всех пользователей о подключении нового пользователя. Вы легко можете это реализовать с помощью одной операции. При использовании веб-сокетов, для реализации подобной задачи вам потребуется список подключенных клиентов и отправка сообщений по одному.
  • В веб-сокетах сложно использовать проксирование и балансировщики нагрузки. Socket.IO поддерживает эти технологии из коробки.
  • Как отмечалось ранее, Socket.IO поддерживает постепенную (изящную) деградацию.
  • Socket.IO поддерживает автоматическое переподключение при разрыве соединения.
  • С Socket.IO легче работать.

Может показаться, что Socket.IO — лучшее средство для живого общения. Однако существует несколько ситуаций, когда лучше использовать веб-сокеты.

Во-первых, веб-сокеты поддерживаются всеми современными браузерами. Поэтому вы редко нуждаетесь в поддержке других технологий, предоставляемой Socket.IO.

Если говорить о сетевом трафике, то веб-сокеты отправляют всего два запроса:

  • GET для получения HTML страницы
  • UPGRADE для соединения с веб-сокетами

Это позволяет установить соединение с сервером. А что насчет Socket.IO?
  • GET для получения HTML страницы
  • Клиентская библиотека Socket.IO (207кб)
  • Три long polling (длинные опросы) Ajax-запроса
  • UPGRADE для соединения с веб-сокетами

В мире JS 207кб — это много. Какое нерациональное использование сетевого трафика!

В npm существует пакет «websocket-vs-socket.io», который позволяет сравнить сетевой трафик этих технологий:

Сетевой трафик веб-сокетов:

Сетевой трафик Socket.IO:

Разница очевидна!

Пишем код


Простой сервер на веб-сокетах

В нашей программе на Node.js мы создадим сервер, работающий на порту 3001. При каждом подключении клиента мы будем присваивать ему уникальный ID. При отправке сообщения клиентом мы будем уведомлять его об успехе: []:
const WebSocket = require('ws') const UUID = require('uuid') const wss = new WebSocket.Server({ port: 3001 }) wss.on('connection', ws => { ws.id = UUID() ws.on('message', message => { ws.send(`[${ws.id}]: ${message}`) }) })

Отлично! Но что если мы хотим рассылать сообщение каждому подключенному клиенту? Веб-сокеты не поддерживают рассылку по умолчанию. Это можно реализовать следующим образом:
const WebSocket = require("ws")
const UUID      = require("uuid")
const wss       = new WebSocket.Server({ port: 3001 })

function broadcast(clientId, message) {
  wss.clients.forEach(client => {
    if(client.readyState === WebSocket.OPEN) {
      client.send(`[${clientId}]: ${message}`)
    }
  })
}

wss.on('conection', ws => {
  ws.id = UUID()
  ws.on('message', message => broadcast(ws.id, message))
})

Легко и просто! Как видите, WebSocket.Server хранит записи о каждом подключенном клиенте, поэтому мы можем сделать итерацию и отправить сообщение каждому. Вы можете протестировать код на компьютере (MacOS) или в браузере (Chrome).
Простой сервер на Socket.IO

Это было не сложно. Может ли Socket.IO сделать это еще проще? Как нам написать такой же сервер на Socket.IO?
const io = require('socket.io')
const server = io.listen(3002)

server.on('connection', socket => {
  socket.on('message', message => {
    socket.emit(`[${socket.id}]: ${message}`)
    socket.broadcast.emit(`[${socket.id}]: ${message}`)
  })
})

Код получился почти наполовину короче! Как видите, метод «broadcast» не отправляет уведомление отправителю, поэтому мы вынуждены делать это вручную.

Существует проблема: код нельзя протестировать на обычном клиенте веб-сокетов. Это связано с тем, что, как отмечалось ранее, Socket.IO использует не чистые веб-сокеты, а множество технологий для поддержки всех возможных клиентов. Так как же нам проверить его работоспособность?

// head
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/socket.io.slim.js"></script>

// body
<script>
  ioClient = io.connect('http://localhost:3002')
  ioClient.on('connect', socket => {
    ioClient.send('hello world')
    ioClient.on('message', msg => console.log(msg))
  })
</script>

Необходимо использовать специальный клиент. В приведенном примере мы загружаем его из CDN. Этот клиент позволяет нам провести быстрые (грязные) тесты в браузере.

Как видите, наши примеры не сильно отличаются. Однако, если говорить о совместимости, следует помнить о том, что Socket.IO работает с собственной библиотекой и его нельзя использовать в целях, не связанных с веб-разработкой. В тоже время веб-сокеты могут использоваться для решения широкого спектра задач, таких как P2P коммуникация, обмен данными между серверами в режиме реального времени и т.д.

На заметку


Горизонтальное масштабирование. Допустим, ваш чат обрел популярность и вам необходимо добавить еще один сервер и балансировщик нагрузки для обработки запросов. Ну, если вы открываете соединение на «server 1», затем балансировщик переключает вас на «server 2», вы получите ошибку: «Error during WebSocket handshake: Unexpected response code: 400». Socket.IO решает эту проблему с помощью cookie (или с помощью маршрутизации соединений на основе исходных адресов), а у веб-сокетов не существует подобного механизма.
Производительность. Как отмечалось ранее, Socket.IO предоставляет несколько абстрактных уровней над транспортным уровнем веб-сокетов. Также здесь используется упаковывание данных в формат JSON, так что возможность отправлять на сервер (и наоборот) бинарные данные отсутствует. Если вам необходим такой функционал, придется «поколдовать» над кодом библиотеки с целью обеспечения нужного поведения. С веб-сокетами таких проблем не возникает.

Так что же выбрать?


Решать вам.

Socket.IO облегчает жизнь, вам не нужно заботиться о проблемах, связанных с балансировкой нагрузки, разрывом соединений или рассылкой сообщений… но необходим ли вам такой функционал? Клиентская библиотека Socket.IO весит больше, чем пакеты React, Redux и React-Redux вместе взятые. Уверены ли вы, что не можете ограничиться веб-сокетами?

Еще одной важной вещью, о которой следует помнить, является то, что при использовании Socket.IO на стороне сервера, большая часть кода будет написана на абстракциях, предоставляемых этой библиотекой. Если у вас возникнет необходимость переписать Node.js-микросервисы на Go, Elixir, Java или другой язык программирования, вам придется переписывать почти всю логику. Например, для рассылки сообщений в Socket.IO используется метод «broadcast» (который в веб-сокетах реализуется вручную), поэтому при рефакторинге придется понять, как этот метод работает. В этом случае следует предпочесть веб-сокеты, поскольку их легче адаптировать.

Благодарю за внимание.

WebSocket — Веб-технологии для разработчиков

WebSocketChrome Полная поддержка 4Edge Полная поддержка 12Firefox Полная поддержка 11
Полная поддержка 11
Замечания See bug 695635.
Нет поддержки
7 — 11С префиксом Замечания
С префиксом Требует вендорный префикс: Moz
Замечания Message size limited to 16 MB (see bug 711205).
Нет поддержки 4 — 6
Замечания Message size limited to 16 MB (see bug 711205).
IE Полная поддержка 10Opera Полная поддержка 12.1Safari Полная поддержка 5WebView Android Полная поддержка ≤37Chrome Android Полная поддержка 18Firefox Android Полная поддержка 14
Полная поддержка 14
Замечания See bug 695635.
Нет поддержки 7 — 14С префиксом Замечания
С префиксом Требует вендорный префикс: Moz
Замечания Message size limited to 16 MB (see bug 711205).
Нет поддержки 4 — 6
Замечания Message size limited to 16 MB (see bug 711205).
Opera Android Полная поддержка 12.1Safari iOS Полная поддержка 4.2Samsung Internet Android Полная поддержка 1.0
WebSocket() constructorChrome Полная поддержка ДаEdge Полная поддержка ≤79Firefox Полная поддержка 7
Полная поддержка 7
Нет поддержки 4 — 7
Замечания Parameter protocols not supported.
IE ? Opera Полная поддержка ДаSafari ? WebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка 7Opera Android ? Safari iOS ? Samsung Internet Android Полная поддержка Да
binaryTypeChrome Полная поддержка ДаEdge Полная поддержка 12Firefox Полная поддержка ДаIE ? Opera Полная поддержка ДаSafari Полная поддержка ДаWebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка ДаOpera Android Полная поддержка ДаSafari iOS Полная поддержка ДаSamsung Internet Android Полная поддержка Да
bufferedAmountChrome Полная поддержка ДаEdge Полная поддержка 12Firefox Полная поддержка ДаIE ? Opera Полная поддержка ДаSafari Полная поддержка ДаWebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка ДаOpera Android Полная поддержка ДаSafari iOS Полная поддержка ДаSamsung Internet Android Полная поддержка Да
closeChrome Полная поддержка 4Edge Полная поддержка 12Firefox Полная поддержка 8
Полная поддержка 8
Нет поддержки 4 — 8
Замечания Parameters not supported, see bug 674716.
IE Полная поддержка 10Opera Полная поддержка 12.1Safari Полная поддержка 5WebView Android Полная поддержка ≤37Chrome Android Полная поддержка 18Firefox Android Полная поддержка 8
Полная поддержка 8
Нет поддержки 4 — 8
Замечания Parameters not supported, see bug 674716.
Opera Android Полная поддержка 12.1Safari iOS Полная поддержка 4.2Samsung Internet Android Полная поддержка 1.0
close eventChrome Полная поддержка ДаEdge Полная поддержка 12Firefox Полная поддержка ДаIE ? Opera Полная поддержка ДаSafari ? WebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка ДаOpera Android ? Safari iOS ? Samsung Internet Android Полная поддержка Да
error eventChrome Полная поддержка ДаEdge Полная поддержка 12Firefox Полная поддержка ДаIE ? Opera Полная поддержка ДаSafari ? WebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка ДаOpera Android ? Safari iOS ? Samsung Internet Android Полная поддержка Да
extensionsChrome Полная поддержка ДаEdge Полная поддержка 12Firefox Полная поддержка 8IE ? Opera Полная поддержка ДаSafari Полная поддержка ДаWebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка 8Opera Android Полная поддержка ДаSafari iOS Полная поддержка ДаSamsung Internet Android Полная поддержка Да
message eventChrome Полная поддержка ДаEdge Полная поддержка 12Firefox Полная поддержка ДаIE ? Opera Полная поддержка ДаSafari ? WebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка ДаOpera Android ? Safari iOS ? Samsung Internet Android Полная поддержка Да
oncloseChrome Полная поддержка ДаEdge Полная поддержка 12Firefox Полная поддержка ДаIE ? Opera Полная поддержка ДаSafari Полная поддержка ДаWebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка ДаOpera Android Полная поддержка ДаSafari iOS Полная поддержка ДаSamsung Internet Android Полная поддержка Да
onerrorChrome Полная поддержка ДаEdge Полная поддержка 12Firefox Полная поддержка ДаIE ? Opera Полная поддержка ДаSafari Полная поддержка ДаWebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка ДаOpera Android Полная поддержка ДаSafari iOS Полная поддержка ДаSamsung Internet Android Полная поддержка Да
onmessageChrome Полная поддержка ДаEdge Полная поддержка 12Firefox Полная поддержка ДаIE ? Opera Полная поддержка ДаSafari Полная поддержка ДаWebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка ДаOpera Android Полная поддержка ДаSafari iOS Полная поддержка ДаSamsung Internet Android Полная поддержка Да
onopenChrome Полная поддержка ДаEdge Полная поддержка 12Firefox Полная поддержка ДаIE ? Opera Полная поддержка ДаSafari Полная поддержка ДаWebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка ДаOpera Android Полная поддержка ДаSafari iOS Полная поддержка ДаSamsung Internet Android Полная поддержка Да
open eventChrome Полная поддержка ДаEdge Полная поддержка 12Firefox Полная поддержка ДаIE ? Opera Полная поддержка ДаSafari ? WebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка ДаOpera Android ? Safari iOS ? Samsung Internet Android Полная поддержка Да
protocolChrome Полная поддержка ДаEdge Полная поддержка 12Firefox Полная поддержка ДаIE ? Opera Полная поддержка ДаSafari Полная поддержка ДаWebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка ДаOpera Android Полная поддержка ДаSafari iOS Полная поддержка ДаSamsung Internet Android Полная поддержка Да
Supports protocol as specified by RFC 6455Chrome Полная поддержка 16Edge Полная поддержка 12Firefox Полная поддержка 11IE Полная поддержка 10Opera Полная поддержка 15Safari Полная поддержка 6WebView Android Полная поддержка ДаChrome Android Полная поддержка 18Firefox Android Полная поддержка 14Opera Android Полная поддержка 14Safari iOS Полная поддержка 6Samsung Internet Android Полная поддержка 1.0
readyStateChrome Полная поддержка 43Edge Полная поддержка 12Firefox Полная поддержка 19IE Полная поддержка 10Opera Полная поддержка 30Safari Полная поддержка 10WebView Android Полная поддержка 43Chrome Android Полная поддержка 43Firefox Android Полная поддержка 19Opera Android Полная поддержка 30Safari iOS Полная поддержка 10Samsung Internet Android Полная поддержка 4.0
sendChrome Полная поддержка 4Edge Полная поддержка 12Firefox Полная поддержка 18
Полная поддержка 18
Замечания See bug 775368.
Нет поддержки 11 — 18
Замечания Only parameter of type ArrayBuffer and String supported.
Нет поддержки 8 — 11
Замечания Only parameter of type String supported.
Нет поддержки 4 — 8
Замечания Only parameter of type String supported. Returns boolean.
IE Полная поддержка 10Opera Полная поддержка 12.1Safari Полная поддержка 5WebView Android Полная поддержка ≤37Chrome Android Полная поддержка 18Firefox Android Полная поддержка 18
Полная поддержка 18
Замечания See bug 775368.
Нет поддержки 14 — 18
Замечания Only parameter of type ArrayBuffer and String supported.
Нет поддержки 8 — 14
Замечания Only parameter of type String supported.
Нет поддержки 4 — 8
Замечания Only parameter of type String supported. Returns boolean.
Opera Android Полная поддержка 12.1Safari iOS Полная поддержка 4.2Samsung Internet Android Полная поддержка 1.0
urlChrome Полная поддержка ДаEdge Полная поддержка 12Firefox Полная поддержка ДаIE ? Opera Полная поддержка ДаSafari Полная поддержка ДаWebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка ДаOpera Android Полная поддержка ДаSafari iOS Полная поддержка ДаSamsung Internet Android Полная поддержка Да
Available in workersChrome Полная поддержка ДаEdge Полная поддержка ≤18Firefox Полная поддержка 37IE ? Opera ? Safari ? WebView Android Полная поддержка ДаChrome Android Полная поддержка ДаFirefox Android Полная поддержка 37Opera Android ? Safari iOS ? Samsung Internet Android Полная поддержка Да

Web-разработка • PHP и MySQL

Протокол WebSocket предназначен для решения разных задач и снятия ограничений обмена данными между браузером и сервером. Он позволяет пересылать любые данные, на любой домен, безопасно и почти без лишнего сетевого трафика. Для установления соединения WebSocket клиент и сервер используют протокол, похожий на HTTP. Клиент формирует особый HTTP-запрос, на который сервер отвечает определенным образом.

Простой сокет-сервер

В первую очередь надо в файле php.ini расскомментировать строку, позволяющую работать с сокетами и перезапустить сервер:

extension = php_sockets.dll

Вот как выглядит простейший сокет-сервер:

<?php
function SocketServer($limit = 0) {
    $starttime = time();
    echo 'SERVER START' . PHP_EOL;

    echo 'Socket create...' . PHP_EOL;
    $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);

    if (false === $socket) {
        die('Error: ' . socket_strerror(socket_last_error()) . PHP_EOL);
    }

    echo 'Socket bind...' . PHP_EOL;
    $bind = socket_bind($socket, '127.0.0.1', 7777); // привязываем к ip и порту
    if (false === $bind) {
        die('Error: ' . socket_strerror(socket_last_error()) . PHP_EOL);
    }

    echo 'Set options...' . PHP_EOL;
    // разрешаем использовать один порт для нескольких соединений
    $option = socket_set_option($socket, SOL_SOCKET, SO_REUSEADDR, 1);
    if (false === $option) {
        die('Error: ' . socket_strerror(socket_last_error()) . PHP_EOL);
    }

    echo 'Listening socket...' . PHP_EOL;
    $listen = socket_listen($socket); // слушаем сокет
    if (false === $listen) {
        die('Error: ' . socket_strerror(socket_last_error()) . PHP_EOL);
    }

    while (true) { // бесконечный цикл ожидания подключений
        echo 'Waiting for connections...' . PHP_EOL;
        $connect = socket_accept($socket); // зависаем, пока не получим ответа
        if ($connect !== false) {
            echo 'Client connected...' . PHP_EOL;
            echo 'Send message to client...' . PHP_EOL;
            socket_write($connect, 'Hello, Client!');
        } else {
            echo 'Error: ' . socket_strerror(socket_last_error()) . PHP_EOL;
            usleep(1000);
        }

        // останавливаем сервер после $limit секунд
        if ($limit && (time() - $starttime > $limit)) {
            echo 'Closing connection...' . PHP_EOL;
            socket_close($socket);
            echo 'SERVER STOP' . PHP_EOL;
            return;
        }
    }
}

error_reporting(E_ALL); // выводим все ошибки и предупреждения
set_time_limit(0);      // бесконечное время работы скрипта
ob_implicit_flush();    // включаем вывод без буферизации

// Запускаем сервер в работу, завершение работы через 60 секунд
SocketServer(60);

Запустим его в работу:

> php.exe -f simple.php
SERVER START
Socket create...
Socket bind...
Set option...
Listening socket...
Waiting for connections...

Попробуем пообщаться с сервером с помощью telnet:

> telnet

Получив приглашение telnet, даем команду:

> open 127.0.0.1 7777

И видим сообщение от сервера:

Наш сервер в другом окне тоже встрепенулся:

WebSocket сервер

Протокол WebSocket работает над TCP. Это означает, что при соединении браузер отправляет по HTTP специальные заголовки, спрашивая: «Поддерживает ли сервер WebSocket?». Если сервер в ответных заголовках отвечает «Да, поддерживаю», то дальше HTTP прекращается и общение идёт на специальном протоколе WebSocket, который уже не имеет с HTTP ничего общего.

GET /chat HTTP/1.1
Host: websocket.server.com
Upgrade: websocket
Connection: Upgrade
Origin: http://www.example.com
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13

Здесь GET и Host — стандартные HTTP-заголовки, а Upgrade и Connection указывают, что браузер хочет перейти на WebSocket.

Сервер может проанализировать эти заголовки и решить, разрешает ли он WebSocket с данного домена Origin. Ответ сервера, если он понимает и разрешает WebSocket-подключение:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=

Для тестирования работы сервера нам нужен клиент:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8" />
    <title>Простой WebSocket клиент</title>
    <link rel="stylesheet" href="style.css" type="text/css" />
    <script src="socket.js" type="text/javascript"></script>
</head>
<body>
    <div>
        <span>Сервер</span>
        <input type="text" value="" />
    </div>
    <div>
        <input type="button" value="Установить соединение" />
        <input type="button" value="Разорвать соединение" />
    </div>
    <div>
        <span>Сообщение</span>
        <input type="text" value="" />
        <input type="button" value="Отправить сообщение" />
    </div>
    <div>
        <span>Информация</span>
        <div></div>
    </div>
</body>
</html>
window.addEventListener('DOMContentLoaded', function () {

    var socket;

    // показать сообщение в #socket-info
    function showMessage(message) {
        var div = document.createElement('div');
        div.appendChild(document.createTextNode(message));
        document.getElementById('socket-info').appendChild(div);
    }

    /*
     * Установить соединение с сервером и назначить обработчики событий
     */
    document.getElementById('connect').onclick = function () {
        // новое соединение открываем, если старое соединение закрыто
        if (socket === undefined || socket.readyState !== 1) {
            socket = new WebSocket(document.getElementById('server').value);
        } else {
            showMessage('Надо закрыть уже имеющееся соединение');
        }

        /*
         * четыре функции обратного вызова: одна при получении данных и три – при изменениях в состоянии соединения
         */
        socket.onmessage = function (event) { // при получении данных от сервера
            showMessage('Получено сообщение от сервера: ' + event.data);
        }
        socket.onopen = function () { // при установке соединения с сервером
            showMessage('Соединение с сервером установлено');
        }
        socket.onerror = function(error) { // если произошла какая-то ошибка
            showMessage('Произошла ошибка: ' + error.message);
        };
        socket.onclose = function(event) { // при закрытии соединения с сервером
            showMessage('Соединение с сервером закрыто');
            if (event.wasClean) {
                showMessage('Соединение закрыто чисто');
            } else {
                showMessage('Обрыв соединения'); // например, «убит» процесс сервера
            }
            showMessage('Код: ' + event.code + ', причина: ' + event.reason);
        };
    };

    /*
     * Отправка сообщения серверу
     */
    document.getElementById('send-msg').onclick = function () {
        if (socket !== undefined && socket.readyState === 1) {
            var message = document.getElementById('message').value;
            socket.send(message);
            showMessage('Отправлено сообщение серверу: ' + message);
        } else {
            showMessage('Невозможно отправить сообщение, нет соединения');
        }
    };

    /*
     * Закрыть соединение с сервером
     */
    document.getElementById('disconnect').onclick = function () {
        if (socket !== undefined && socket.readyState === 1) {
            socket.close();
        } else {
            showMessage('Соединение с сервером уже было закрыто');
        }
    };

});
body > div {
    margin-bottom: 15px;
    overflow: hidden;
}
span {
    display: block;
    margin-bottom: 2px;
}
input {
    padding: 5px;
    box-sizing: border-box;
}
input[type="text"] {
    width: 100%;
}
input[type="button"] {
    width: 25%;
    float: left;
    margin-top: 5px;
    margin-right: 5px;
}
div#socket-info {
    padding: 5px;
    border: 1px solid #ddd;
}

Проверим его в работе. Открываем HTML-страницу в браузере и заполняем первое поле «Сервер»:

ws://echo.websocket.org

Это гарантированно работающий WebSocket echo-сервер, которые отправляет все сообщения обратно. Жмем кнопку «Установить соединение», набираем текст сообщения в поле «Сообщение», жмем кнопку «Отправить сообщение»:

А теперь код WebSocket сервера на PHP:

<?php
/**
 * Класс WebSocket сервера
 */
class WebSocketServer {

    /**
     * Функция вызывается, когда получено сообщение от клиента
     */
    public $handler;

    /**
     * IP адрес сервера
     */
    private $ip;
    /**
     * Порт сервера
     */
    private $port;
    /**
     * Сокет для принятия новых соединений, прослушивает указанный порт
     */
    private $connection;
    /**
     * Для хранения всех подключений, принятых слушающим сокетом
     */
    private $connects;

    /**
     * Ограничение по времени работы сервера
     */
    private $timeLimit = 0;
    /**
     * Время начала работы сервера
     */
    private $startTime;
    /**
     * Выводить сообщения в консоль?
     */
    private $verbose = false;
    /**
     * Записывать сообщения в log-файл?
     */
    private $logging = false;
    /**
     * Имя log-файла
     */
    private $logFile = 'ws-log.txt';
    /**
     * Ресурс log-файла
     */
    private $resource;


    public function __construct($ip = '127.0.0.1', $port = 7777) {
        $this->ip = $ip;
        $this->port = $port;

        // эта функция вызывается, когда получено сообщение от клиента;
        // при создании экземпляра класса должна быть переопределена
        $this->handler = function($connection, $data) {
            $message = '[' . date('r') . '] Получено сообщение от клиента: ' . $data . PHP_EOL;
            if ($this->verbose) {
                echo $message;
            }
            if ($this->logging) {
                fwrite($this->resource, $message);
            }
        };
    }

    public function __destruct() {
        if (is_resource($this->connection)) {
            $this->stopServer();
        }
        if ($this->logging) {
            fclose($this->resource);
        }
    }

    /**
     * Дополнительные настройки для отладки
     */
    public function settings($timeLimit = 0, $verbose = false, $logging = false, $logFile = 'ws-log.txt') {
        $this->timeLimit = $timeLimit;
        $this->verbose = $verbose;
        $this->logging = $logging;
        $this->logFile = $logFile;
        if ($this->logging) {
            $this->resource = fopen($this->logFile, 'a');
        }
    }

    /**
     * Выводит сообщение в консоль и/или записывает в лог-файл
     */
    private function debug($message) {
        $message = '[' . date('r') . '] ' . $message . PHP_EOL;
        if ($this->verbose) {
            echo $message;
        }
        if ($this->logging) {
            fwrite($this->resource, $message);
        }
    }

    /**
     * Отправляет сообщение клиенту
     */
    public static function response($connect, $data) {
        socket_write($connect, self::encode($data));
    }

    /**
     * Запускает сервер в работу
     */
    public function startServer() {

        $this->debug('Try start server...');

        $this->connection = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);

        if (false === $this->connection) {
            $this->debug('Error socket_create(): ' . socket_strerror(socket_last_error()));
            return;
        }

        $bind = socket_bind($this->connection, $this->ip, $this->port); // привязываем к ip и порту
        if (false === $bind) {
            $this->debug('Error socket_bind(): ' . socket_strerror(socket_last_error()));
            return;
        }

        // разрешаем использовать один порт для нескольких соединений
        $option = socket_set_option($this->connection, SOL_SOCKET, SO_REUSEADDR, 1);
        if (false === $option) {
            $this->debug('Error socket_set_option(): ' . socket_strerror(socket_last_error()));
            return;
        }

        $listen = socket_listen($this->connection); // слушаем сокет
        if (false === $listen) {
            $this->debug('Error socket_listen(): ' . socket_strerror(socket_last_error()));
            return;
        }

        $this->debug('Server is running...');

        $this->connects = array($this->connection);
        $this->startTime = time();

        while (true) {

            $this->debug('Waiting for connections...');

            // создаем копию массива, так что массив $this->connects не будет изменен функцией socket_select()
            $read = $this->connects;
            $write = $except = null;

            /*
             * Сокет $this->connection только прослушивает порт на предмет новых соединений. Как только поступило
             * новое соединение, мы создаем новый ресурс сокета с помощью socket_accept() и помещаем его в массив
             * $this->connects для дальнейшего чтения из него.
             */

            if ( ! socket_select($read, $write, $except, null)) { // ожидаем сокеты, доступные для чтения (без таймаута)
                break;
            }

            // если слушающий сокет есть в массиве чтения, значит было новое соединение
            if (in_array($this->connection, $read)) {
                // принимаем новое соединение и производим рукопожатие
                if (($connect = socket_accept($this->connection)) && $this->handshake($connect)) {
                    $this->debug('New connection accepted');
                    $this->connects[] = $connect; // добавляем его в список необходимых для обработки
                }
                // удаляем слушающий сокет из массива для чтения
                unset($read[ array_search($this->connection, $read) ]);
            }

            foreach ($read as $connect) { // обрабатываем все соединения, в которых есть данные для чтения
                $data = socket_read($connect, 100000);
                $decoded = self::decode($data);
                // если клиент не прислал данных или хочет разорвать соединение
                if (false === $decoded || 'close' === $decoded['type']) {
                    $this->debug('Connection closing');
                    socket_write($connect, self::encode('  Closed on client demand', 'close'));
                    socket_shutdown($connect);
                    socket_close($connect);
                    unset($this->connects[ array_search($connect, $this->connects) ]);
                    $this->debug('Closed successfully');
                    continue;
                }
                // получено сообщение от клиента, вызываем пользовательскую
                // функцию, чтобы обработать полученные данные
                if (is_callable($this->handler)) {
                    call_user_func($this->handler, $connect, $decoded['payload']);
                }
            }

            // если истекло ограничение по времени, останавливаем сервер
            if ($this->timeLimit && time() - $this->startTime > $this->timeLimit) {
                $this->debug('Time limit. Stopping server.');
                $this->stopServer();
                return;
            }

        }

    }

    /**
     * Останавливает работу сервера
     */
    public function stopServer() {
        // закрываем слушающий сокет
        socket_close($this->connection);
        if (!empty($this->connects)) { // отправляем все клиентам сообщение о разрыве соединения
            foreach ($this->connects as $connect) {
                if (is_resource($connect)) {
                    socket_write($connect, self::encode('  Closed on server demand', 'close'));
                    socket_shutdown($connect);
                    socket_close($connect);
                }
            }
        }
    }

    /**
     * Для кодирования сообщений перед отправкой клиенту
     */
    private static function encode($payload, $type = 'text', $masked = false) {
        $frameHead = array();
        $payloadLength = strlen($payload);

        switch ($type) {
            case 'text':
                // first byte indicates FIN, Text-Frame (10000001):
                $frameHead[0] = 129;
                break;
            case 'close':
                // first byte indicates FIN, Close Frame(10001000):
                $frameHead[0] = 136;
                break;
            case 'ping':
                // first byte indicates FIN, Ping frame (10001001):
                $frameHead[0] = 137;
                break;
            case 'pong':
                // first byte indicates FIN, Pong frame (10001010):
                $frameHead[0] = 138;
                break;
        }

        // set mask and payload length (using 1, 3 or 9 bytes)
        if ($payloadLength > 65535) {
            $payloadLengthBin = str_split(sprintf('%064b', $payloadLength), 8);
            $frameHead[1] = ($masked === true) ? 255 : 127;
            for ($i = 0; $i < 8; $i++) {
                $frameHead[$i + 2] = bindec($payloadLengthBin[$i]);
            }
            // most significant bit MUST be 0
            if ($frameHead[2] > 127) {
                return array('type' => '', 'payload' => '', 'error' => 'frame too large (1004)');
            }
        } elseif ($payloadLength > 125) {
            $payloadLengthBin = str_split(sprintf('%016b', $payloadLength), 8);
            $frameHead[1] = ($masked === true) ? 254 : 126;
            $frameHead[2] = bindec($payloadLengthBin[0]);
            $frameHead[3] = bindec($payloadLengthBin[1]);
        } else {
            $frameHead[1] = ($masked === true) ? $payloadLength + 128 : $payloadLength;
        }

        // convert frame-head to string:
        foreach (array_keys($frameHead) as $i) {
            $frameHead[$i] = chr($frameHead[$i]);
        }
        if ($masked === true) {
            // generate a random mask:
            $mask = array();
            for ($i = 0; $i < 4; $i++) {
                $mask[$i] = chr(rand(0, 255));
            }
            $frameHead = array_merge($frameHead, $mask);
        }
        $frame = implode('', $frameHead);

        // append payload to frame:
        for ($i = 0; $i < $payloadLength; $i++) {
            $frame .= ($masked === true) ? $payload[$i] ^ $mask[$i % 4] : $payload[$i];
        }

        return $frame;
    }

    /**
     * Для декодирования сообщений, полученных от клиента
     */
    private static function decode($data) {
        if ( ! strlen($data)) {
            return false;
        }

        $unmaskedPayload = '';
        $decodedData = array();

        // estimate frame type:
        $firstByteBinary = sprintf('%08b', ord($data[0]));
        $secondByteBinary = sprintf('%08b', ord($data[1]));
        $opcode = bindec(substr($firstByteBinary, 4, 4));
        $isMasked = ($secondByteBinary[0] == '1') ? true : false;
        $payloadLength = ord($data[1]) & 127;

        // unmasked frame is received:
        if (!$isMasked) {
            return array('type' => '', 'payload' => '', 'error' => 'protocol error (1002)');
        }

        switch ($opcode) {
            // text frame:
            case 1:
                $decodedData['type'] = 'text';
                break;
            case 2:
                $decodedData['type'] = 'binary';
                break;
            // connection close frame:
            case 8:
                $decodedData['type'] = 'close';
                break;
            // ping frame:
            case 9:
                $decodedData['type'] = 'ping';
                break;
            // pong frame:
            case 10:
                $decodedData['type'] = 'pong';
                break;
            default:
                return array('type' => '', 'payload' => '', 'error' => 'unknown opcode (1003)');
        }

        if ($payloadLength === 126) {
            $mask = substr($data, 4, 4);
            $payloadOffset = 8;
            $dataLength = bindec(sprintf('%08b', ord($data[2])) . sprintf('%08b', ord($data[3]))) + $payloadOffset;
        } elseif ($payloadLength === 127) {
            $mask = substr($data, 10, 4);
            $payloadOffset = 14;
            $tmp = '';
            for ($i = 0; $i < 8; $i++) {
                $tmp .= sprintf('%08b', ord($data[$i + 2]));
            }
            $dataLength = bindec($tmp) + $payloadOffset;
            unset($tmp);
        } else {
            $mask = substr($data, 2, 4);
            $payloadOffset = 6;
            $dataLength = $payloadLength + $payloadOffset;
        }

        /**
         * We have to check for large frames here. socket_recv cuts at 1024 bytes
         * so if websocket-frame is > 1024 bytes we have to wait until whole
         * data is transferd.
         */
        if (strlen($data) < $dataLength) {
            return false;
        }

        if ($isMasked) {
            for ($i = $payloadOffset; $i < $dataLength; $i++) {
                $j = $i - $payloadOffset;
                if (isset($data[$i])) {
                    $unmaskedPayload .= $data[$i] ^ $mask[$j % 4];
                }
            }
            $decodedData['payload'] = $unmaskedPayload;
        } else {
            $payloadOffset = $payloadOffset - 4;
            $decodedData['payload'] = substr($data, $payloadOffset);
        }

        return $decodedData;
    }

    /**
     * «Рукопожатие», т.е. отправка заголовков согласно протоколу WebSocket
     */
    private function handshake($connect) {

        $info = array();

        $data = socket_read($connect, 1000);
        $lines = explode("\r\n", $data);
        foreach ($lines as $i => $line) {
            if ($i) {
                if (preg_match('/\A(\S+): (.*)\z/', $line, $matches)) {
                    $info[$matches[1]] = $matches[2];
                }
            } else {
                $header = explode(' ', $line);
                $info['method'] = $header[0];
                $info['uri'] = $header[1];
            }
            if (empty(trim($line))) break;
        }

        // получаем адрес клиента
        $ip = $port = null;
        if ( ! socket_getpeername($connect, $ip, $port)) {
            return false;
        }
        $info['ip'] = $ip;
        $info['port'] = $port;

        if (empty($info['Sec-WebSocket-Key'])) {
            return false;
        }

        // отправляем заголовок согласно протоколу вебсокета
        $SecWebSocketAccept = 
            base64_encode(pack('H*', sha1($info['Sec-WebSocket-Key'] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')));
        $upgrade = "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";
        socket_write($connect, $upgrade);

        return true;

    }

}

Для тестирования напишем небольшой PHP-скрипт, который запускает в работу сервер и все сообщения клиента отправляет обратно (echo-сервер):

<?php 
error_reporting(E_ALL);
set_time_limit(0);
ob_implicit_flush();

require 'WebSocketServer.class.php';

$server = new WebSocketServer('127.0.0.1', 7777);
// максимальное время работы 100 секунд, выводить сообщения в консоль
$server->settings(100, true);

// эта функция вызывается, когда получено сообщение от клиента
$server->handler = function($connect, $data) {
    // полученные от клиента данные отправляем обратно
    WebSocketServer::response($connect, $data);
};

$server->startServer();

Запускаем скрипт в работу:

> php.exe -f echo-server.php
[Fri, 12 Oct 2018 15:08:13 +0300] Try start server...
[Fri, 12 Oct 2018 15:08:13 +0300] Server is running...
[Fri, 12 Oct 2018 15:08:13 +0300] Waiting for connections...

Еще один пример использования сервера — клиент отправляет команды, а сервер их выполняет:

<?php 
error_reporting(E_ALL);
set_time_limit(0);
ob_implicit_flush();

require 'WebSocketServer.class.php';

$server = new WebSocketServer('127.0.0.1', 7777);
// максимальное время работы 100 секунд, выводить сообщения в консоль
$server->settings(100, true);

// эта функция вызывается, когда получено сообщение от клиента
$server->handler = function($connect, $data) {
    // анализируем поступившую команду и даем ответ
    if ( ! in_array($data, array('date', 'time', 'country', 'city'))) {
        WebSocketServer::response($connect, 'Неизвестная команда');
        return;
    }
    switch ($data) {
        case 'date'   : $response = date('d.m.Y'); break;
        case 'time'   : $response = date('H:i:s'); break;
        case 'country': $response = 'Россия';      break;
        case 'city'   : $response = 'Москва';      break;
    }
    WebSocketServer::response($connect, $response);
};

$server->startServer();

Альтернативная реализация WebSocket сервера с использованием функций для работы с потоками:

<?php
/**
 * Класс WebSocket сервера
 */
class WebSocketServer {

    /**
     * Функция вызывается, когда получено сообщение от клиента
     */
    public $handler;

    /**
     * IP адрес сервера
     */
    private $ip;
    /**
     * Порт сервера
     */
    private $port;
    /**
     * Для хранения слушающего сокета потока
     */
    private $connection;
    /**
     * Для хранения всех подключений
     */
    private $connects;

    /**
     * Ограничение по времени работы сервера
     */
    private $timeLimit = 0;
    /**
     * Время начала работы сервера
     */
    private $startTime;
    /**
     * Выводить сообщения в консоль?
     */
    private $verbose = false;
    /**
     * Записывать сообщения в log-файл?
     */
    private $logging = false;
    /**
     * Имя log-файла
     */
    private $logFile = 'ws-log.txt';
    /**
     * Ресурс log-файла
     */
    private $resource;


    public function __construct($ip = '127.0.0.1', $port = 7777) {
        $this->ip = $ip;
        $this->port = $port;

        // эта функция вызывается, когда получено сообщение от клиента;
        // при создании экземпляра класса должна быть переопределена
        $this->handler = function($connection, $data) {
            $message = '[' . date('r') . '] Получено сообщение от клиента: ' . $data . PHP_EOL;
            if ($this->verbose) {
                echo $message;
            }
            if ($this->logging) {
                fwrite($this->resource, $message);
            }
        };
    }

    public function __destruct() {
        if (is_resource($this->connection)) {
            $this->stopServer();
        }
        if ($this->logging) {
            fclose($this->resource);
        }
    }

    /**
     * Дополнительные настройки для отладки
     */
    public function settings($timeLimit = 0, $verbose = false, $logging = false, $logFile = 'ws-log.txt') {
        $this->timeLimit = $timeLimit;
        $this->verbose = $verbose;
        $this->logging = $logging;
        $this->logFile = $logFile;
        if ($this->logging) {
            $this->resource = fopen($this->logFile, 'a');
        }
    }

    /**
     * Выводит сообщение в консоль или записывает в лог-файл
     */
    private function debug($message) {
        $message = '[' . date('r') . '] ' . $message . PHP_EOL;
        if ($this->verbose) {
            echo $message;
        }
        if ($this->logging) {
            fwrite($this->resource, $message);
        }
    }

    /**
     * Отправляет сообщение клиенту
     */
    public static function response($connect, $data) {
        fwrite($connect, self::encode($data));
    }

    /**
     * Запускает сервер в работу
     */
    public function startServer() {
        
        $this->debug('Try start server...');

        $this->connection = stream_socket_server('tcp://' . $this->ip . ':' . $this->port, $errno, $errstr);
        
        if ( ! $this->connection) {
            $this->debug('Cannot start server: ' .$errstr. '(' .$errno. ')');
            return false;
        }

        $this->debug('Server is running...');

        $this->connects = array();
        $this->startTime = time();

        while (true) {

            $this->debug('Waiting for connections...');

            // формируем массив прослушиваемых сокетов
            $read = $this->connects;
            $read[] = $this->connection;
            $write = $except = null;

            if ( ! stream_select($read, $write, $except, null)) { // ожидаем сокеты доступные для чтения (без таймаута)
                break;
            }

            if (in_array($this->connection, $read)) { // есть новое соединение
                // принимаем новое соединение и производим рукопожатие
                if (($connect = stream_socket_accept($this->connection, -1)) && $this->handshake($connect)) {
                    $this->debug('New connection accepted');
                    $this->connects[] = $connect; // добавляем его в список необходимых для обработки
                }
                unset($read[ array_search($this->connection, $read) ]);
            }

            foreach ($read as $connect) { // обрабатываем все соединения
                $data = fread($connect, 100000);
                $decoded = self::decode($data);
                // если клиент не прислал данных или хочет разорвать соединение
                if (false === $decoded || 'close' === $decoded['type']) {
                    $this->debug('Connection closing');
                    fwrite($connect, self::encode('  Closed on client demand', 'close'));
                    fclose($connect);
                    unset($this->connects[ array_search($connect, $this->connects) ]);
                    $this->debug('Closed successfully');
                    continue;
                }
                // получено сообщение от клиента, вызываем пользовательскую
                // функцию, чтобы обработать полученные данные
                if (is_callable($this->handler)) {
                    call_user_func($this->handler, $connect, $decoded['payload']);
                }
            }

            // если истекло ограничение по времени, останавливаем сервер
            if ($this->timeLimit && time() - $this->startTime > $this->timeLimit) {
                $this->debug('Time limit. Stopping server.');
                $this->stopServer();
                return;
            }
        }
    }

    /**
     * Останавливает работу сервера
     */
    public function stopServer() {
        fclose($this->connection); // закрываем слушающий сокет
        if (!empty($this->connects)) { // отправляем все клиентам сообщение о разрыве соединения
            foreach ($this->connects as $connect) {
                if (is_resource($connect)) {
                    fwrite($connect, self::encode('  Closed on server demand', 'close'));
                    fclose($connect);
                }
            }
        }
    }

    /**
     * Для кодирования сообщений перед отправкой клиенту
     */
    private static function encode($payload, $type = 'text', $masked = false) {
        $frameHead = array();
        $payloadLength = strlen($payload);

        switch ($type) {
            case 'text':
                // first byte indicates FIN, Text-Frame (10000001):
                $frameHead[0] = 129;
                break;
            case 'close':
                // first byte indicates FIN, Close Frame(10001000):
                $frameHead[0] = 136;
                break;
            case 'ping':
                // first byte indicates FIN, Ping frame (10001001):
                $frameHead[0] = 137;
                break;
            case 'pong':
                // first byte indicates FIN, Pong frame (10001010):
                $frameHead[0] = 138;
                break;
        }

        // set mask and payload length (using 1, 3 or 9 bytes)
        if ($payloadLength > 65535) {
            $payloadLengthBin = str_split(sprintf('%064b', $payloadLength), 8);
            $frameHead[1] = ($masked === true) ? 255 : 127;
            for ($i = 0; $i < 8; $i++) {
                $frameHead[$i + 2] = bindec($payloadLengthBin[$i]);
            }
            // most significant bit MUST be 0
            if ($frameHead[2] > 127) {
                return array('type' => '', 'payload' => '', 'error' => 'frame too large (1004)');
            }
        } elseif ($payloadLength > 125) {
            $payloadLengthBin = str_split(sprintf('%016b', $payloadLength), 8);
            $frameHead[1] = ($masked === true) ? 254 : 126;
            $frameHead[2] = bindec($payloadLengthBin[0]);
            $frameHead[3] = bindec($payloadLengthBin[1]);
        } else {
            $frameHead[1] = ($masked === true) ? $payloadLength + 128 : $payloadLength;
        }

        // convert frame-head to string:
        foreach (array_keys($frameHead) as $i) {
            $frameHead[$i] = chr($frameHead[$i]);
        }
        if ($masked === true) {
            // generate a random mask:
            $mask = array();
            for ($i = 0; $i < 4; $i++) {
                $mask[$i] = chr(rand(0, 255));
            }
            $frameHead = array_merge($frameHead, $mask);
        }
        $frame = implode('', $frameHead);

        // append payload to frame:
        for ($i = 0; $i < $payloadLength; $i++) {
            $frame .= ($masked === true) ? $payload[$i] ^ $mask[$i % 4] : $payload[$i];
        }

        return $frame;
    }

    /**
     * Для декодирования сообщений, полученных от клиента
     */
    private static function decode($data) {
        if ( ! strlen($data)) {
            return false;
        }

        $unmaskedPayload = '';
        $decodedData = array();

        // estimate frame type:
        $firstByteBinary = sprintf('%08b', ord($data[0]));
        $secondByteBinary = sprintf('%08b', ord($data[1]));
        $opcode = bindec(substr($firstByteBinary, 4, 4));
        $isMasked = ($secondByteBinary[0] == '1') ? true : false;
        $payloadLength = ord($data[1]) & 127;

        // unmasked frame is received:
        if (!$isMasked) {
            return array('type' => '', 'payload' => '', 'error' => 'protocol error (1002)');
        }

        switch ($opcode) {
            // text frame:
            case 1:
                $decodedData['type'] = 'text';
                break;
            case 2:
                $decodedData['type'] = 'binary';
                break;
            // connection close frame:
            case 8:
                $decodedData['type'] = 'close';
                break;
            // ping frame:
            case 9:
                $decodedData['type'] = 'ping';
                break;
            // pong frame:
            case 10:
                $decodedData['type'] = 'pong';
                break;
            default:
                return array('type' => '', 'payload' => '', 'error' => 'unknown opcode (1003)');
        }

        if ($payloadLength === 126) {
            $mask = substr($data, 4, 4);
            $payloadOffset = 8;
            $dataLength = bindec(sprintf('%08b', ord($data[2])) . sprintf('%08b', ord($data[3]))) + $payloadOffset;
        } elseif ($payloadLength === 127) {
            $mask = substr($data, 10, 4);
            $payloadOffset = 14;
            $tmp = '';
            for ($i = 0; $i < 8; $i++) {
                $tmp .= sprintf('%08b', ord($data[$i + 2]));
            }
            $dataLength = bindec($tmp) + $payloadOffset;
            unset($tmp);
        } else {
            $mask = substr($data, 2, 4);
            $payloadOffset = 6;
            $dataLength = $payloadLength + $payloadOffset;
        }

        /**
         * We have to check for large frames here. socket_recv cuts at 1024 bytes
         * so if websocket-frame is > 1024 bytes we have to wait until whole
         * data is transferd.
         */
        if (strlen($data) < $dataLength) {
            return false;
        }

        if ($isMasked) {
            for ($i = $payloadOffset; $i < $dataLength; $i++) {
                $j = $i - $payloadOffset;
                if (isset($data[$i])) {
                    $unmaskedPayload .= $data[$i] ^ $mask[$j % 4];
                }
            }
            $decodedData['payload'] = $unmaskedPayload;
        } else {
            $payloadOffset = $payloadOffset - 4;
            $decodedData['payload'] = substr($data, $payloadOffset);
        }

        return $decodedData;
    }

    /**
     * «Рукопожатие», т.е. отправка заголовков согласно протоколу WebSocket
     */
    private function handshake($connect) {
        $info = array();

        $line = fgets($connect);
        $header = explode(' ', $line);
        $info['method'] = $header[0];
        $info['uri'] = $header[1];

        // считываем заголовки из соединения
        while ($line = rtrim(fgets($connect))) {
            if (preg_match('/\A(\S+): (.*)\z/', $line, $matches)) {
                $info[$matches[1]] = $matches[2];
            } else {
                break;
            }
        }

        // получаем адрес клиента
        $address = explode(':', stream_socket_get_name($connect, true));
        $info['ip'] = $address[0];
        $info['port'] = $address[1];

        if (empty($info['Sec-WebSocket-Key'])) {
            return false;
        }

        // отправляем заголовок согласно протоколу вебсокета
        $SecWebSocketAccept = 
            base64_encode(pack('H*', sha1($info['Sec-WebSocket-Key'] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')));
        $upgrade = "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";
        fwrite($connect, $upgrade);

        return $info;
    }

}
Дополнительно

Поиск: HandShake • JavaScript • PHP • Server • Socket • Web-разработка • WebSocket • Клиент • Протокол • Сервер • Сокет

Nginx и Websockets / Хабр

В Nginx наконец добавили долгожданную функциональность по проксированию Websockets.
В связи с этим спешу поделиться конфигами и небольшими подробностями.

Для тех кто не знал, ранее проксирование было возможно через сторонний модуль, с ограничениями которые многих не устраивали. Ну, да мы не об этом.

Теперь на одном порту можно проксировать http и ws траффик, более того, например под одним SSL сертификатом, и все это с родным знакомым синтаксисом.

Ws доступны начиная с версии 1.3.13, а буквально сегодня добавили поддержку еще в модули ngx_http_uwsgi_module и ngx_http_scgi_module для 1.3.14

Вот, что говорит документация на счет используемого механизма.

Для превращения соединения между клиентом и сервером из HTTP/1.1 в WebSocket используется доступный в HTTP/1.1 механизм смены протокола.

Но есть сложность: поскольку “Upgrade” является hop-by-hop заголовком, то он не передаётся от клиента к проксируемому серверу. При прямом проксировании клиенты могут использовать метод CONNECT, чтобы обойти эту проблему. Однако при обратном проксировании такой подход не работает, так как клиент ничего о проксирующем сервере не знает, и требуется специальная обработка на проксирующем сервере.

Начиная с версии 1.3.13, в nginx предусмотрен особый режим работы, который позволяет установить туннель между клиентом и проксируемым сервером, если проксируемый сервер вернул ответ с кодом 101 (Switching Protocols), и клиент попросил сменить протокол с помощью заголовка “Upgrade” в запросе.

Как уже отмечалось выше, hop-by-hop заголовки, включая “Upgrade” и “Connection”, не передаются от клиента к проксируемому серверу, поэтому, для того чтобы проксируемый сервер узнал о намерении клиента сменить протокол на WebSocket, эти заголовки следует передать явно:

Конфиг.

Простой пример:
location /ws/ {
    proxy_pass http://localhost:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

}

Более сложный пример, в котором значение поля “Connection” в заголовке запроса к проксируемому серверу зависит от наличия поля “Upgrade” в заголовке запроса клиента:

http {
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    server {
        ...

        location /ws/ {
            proxy_pass http://localhost:8080;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
        }
    }

И немного неочевидный, но важный параметр это proxy_read_timeout, который стоит по дефолту в значении 60s, по истечении которых коннект обрывается, чего в случае ws обычно совсем не нужно.

Поэтому, мы добавили:

http {
      ...
      proxy_read_timeout 950s;
      ...
}


Скорее всего вашему приложению нужны будут другие цифры таймаута, поэтому не копипастите бездумно;)

Исследователи предупредили, что протокол WS-Discovery используется для DDoS-атак

ИБ-специалисты обеспокоены тем, что злоумышленники используют WS-Discovery (Web Services Dynamic Discovery) для DDoS-атак, так как протокол может давать коэффициент амплификации равный 300 и даже 500.

Издание ZDNet сообщает, что первые атаки такого рода начались еще в мае текущего года, однако тогда специалисты и издание решили не привлекать внимания к происходящему, и не подавать злоумышленникам идеи. Но в последнее время уже несколько хак-групп начали злоупотреблять WS-Discovery, и такие DDoS-атаки, к сожалению, становятся постоянным явлением.

WS-Discovery представляет собой многоадресный протокол, который можно использовать для обнаружения других устройств, которые обмениваются данными через определенный протокол или интерфейс. Так, он применяется для обнаружения и обмена данными посредством SOAP с использованием пакетов UDP, поэтому иногда WS-Discovery называют SOAP-over-UDP.

И хотя WS-Discovery нельзя назвать широко распространенным или общеизвестным протоколом, он одобрен отраслевой организацией ONVIF, членами которой являются такие компании, как Axis, Sony, Bosch и так далее. В итоге в настоящее время WS-Discovery поддерживают множество устройств, от IP-камер до принтеров, от бытовых приборов до DVR. По статистике поисковика BinaryEdge, в сети можно обнаружить примерно 630 000 таких девайсов.

По мнению ИБ-специалистов, WS-Discovery идеально подходит для проведения DDoS-атак по ряду причин. Во-первых, протокол основан на UDP, а значит, возможен спуфинг места назначения пакетов. Атакующие могут отправить UDP-пакет WS-Discovery-службе устройства, использовав при этом поддельный обратный IP-адрес. В результате устройство направит свой ответ на этот поддельный адрес, позволяя злоумышленникам манипулировать WS-Discovery-трафиком и направляя его на желаемую цель. Во-вторых, ответ WS-Discovery во много раз больше изначального запроса. Это позволяет атакующим без труда усиливать DDoS-атаки.

По информации ZDNet, WS-Discovery использовался в нескольких DDoS-атаках, коэффициент амплификации которых доходил до 300 и даже до 500. Это очень тревожные цифры, так как обычно коэффициент усиления для других UDP-протоколов равен в среднем 10. К счастью, исследователи полагают, что такие высокие коэффициенты амплификации – это скорее исключение из правила, а не норма. Так, по данным ИБ-компании ZeroBS GmbH, которая в этом месяце отслеживала волну DDoS-атак с использованием WS-Discovery, более распространенный коэффициент усиления все же составляет примерно 10.

Тем не менее, журналисты отмечают, что PoC-эксплоит для проведения DDoS-атак с использованием WS-Discovery, опубликованный на GitHub еще в конце 2018 года, помогает достичь коэффициента амплификации от 70 до 150 (ссылку издание по понятным причинам не приводит).

О первых масштабных атаках с использованием протокола WS-Discovery в мае текущего года сообщил ИБ-специалист Такер Престон (Tucker Preston). Тогда он наблюдал более 130 DDoS-атак, некоторые из которых достигли мощности более 350 Гбит/с. Позднее об этих атаках написали и эксперты компании Netscout в отчете, опубликованном в прошлом месяце.

Атаки, зафиксированные Такером Престоном

И хотя, по данным ZeroBS GmbH, потом атаки почти прекратились, в августе 2019 года они усилились снова. В отличие от первых волн, эти атаки были гораздо «скромнее», и исследователи полагают, что их проводили группировки, которые не до конца осведомлены о возможностях протокола или не имеют технических средств для его использования в полную силу. По информации ZeroBS GmbH, последние атаки достигали максимум 40 Гбит/с и коэффициентом усиления не более 10. Для этих атак использовалось всего порядка 5000 устройств (в основном IP-камеры и принтеры), входящих в ботнеты.

Данные ZeroBS GmbH

Эксперты предупреждают, что в настоящее время DDoS-атаки с применением WS-Discovery еще не используются ежедневно и в полную силу. Пока злоумышленники эксплуатируют возможности только небольшой части WS-Discovery-устройств, доступных в сети, и добиваются весьма небольших коэффициентов амплификации. Однако уже в ближайшие месяцы этот протокол может стать любимым инструментом операторов ботнетов, и ситуация существенно ухудшится.

Обзор способов и протоколов аутентификации в веб-приложениях

Я расскажу о применении различных способов аутентификации для веб-приложений, включая аутентификацию по паролю, по сертификатам, по одноразовым паролям, по ключам доступа и по токенам. Коснусь технологии единого входа (Single Sign-On), рассмотрю различные стандарты и протоколы аутентификации.

Перед тем, как перейти к техническим деталям, давайте немного освежим терминологию.

  • Идентификация — это заявление о том, кем вы являетесь. В зависимости от ситуации, это может быть имя, адрес электронной почты, номер учетной записи, итд.
  • Аутентификация — предоставление доказательств, что вы на самом деле есть тот, кем идентифицировались (от слова “authentic” — истинный, подлинный).
  • Авторизация — проверка, что вам разрешен доступ к запрашиваемому ресурсу.

Например, при попытке попасть в закрытый клуб вас идентифицируют (спросят ваше имя и фамилию), аутентифицируют (попросят показать паспорт и сверят фотографию) и авторизуют (проверят, что фамилия находится в списке гостей), прежде чем пустят внутрь.

Аналогично эти термины применяются в компьютерных системах, где традиционно под идентификацией понимают получение вашей учетной записи (identity) по username или email; под аутентификацией — проверку, что вы знаете пароль от этой учетной записи, а под авторизацией — проверку вашей роли в системе и решение о предоставлении доступа к запрошенной странице или ресурсу.

Однако в современных системах существуют и более сложные схемы аутентификации и авторизации, о которых я расскажу далее. Но начнем с простого и понятного.

Аутентификация по паролю

Этот метод основывается на том, что пользователь должен предоставить username и password для успешной идентификации и аутентификации в системе. Пара username/password задается пользователем при его регистрации в системе, при этом в качестве username может выступать адрес электронной почты пользователя.

Применительно к веб-приложениям, существует несколько стандартных протоколов для аутентификации по паролю, которые мы рассмотрим ниже.

HTTP authentication

Этот протокол, описанный в стандартах HTTP 1.0/1.1, существует очень давно и до сих пор активно применяется в корпоративной среде. Применительно к веб-сайтам работает следующим образом:
  1. Сервер, при обращении неавторизованного клиента к защищенному ресурсу, отсылает HTTP статус “401 Unauthorized” и добавляет заголовок “WWW-Authenticate” с указанием схемы и параметров аутентификации.
  2. Браузер, при получении такого ответа, автоматически показывает диалог ввода username и password. Пользователь вводит детали своей учетной записи.
  3. Во всех последующих запросах к этому веб-сайту браузер автоматически добавляет HTTP заголовок “Authorization”, в котором передаются данные пользователя для аутентификации сервером.
  4. Сервер аутентифицирует пользователя по данным из этого заголовка. Решение о предоставлении доступа (авторизация) производится отдельно на основании роли пользователя, ACL или других данных учетной записи.

Весь процесс стандартизирован и хорошо поддерживается всеми браузерами и веб-серверами. Существует несколько схем аутентификации, отличающихся по уровню безопасности:

  1. Basic — наиболее простая схема, при которой username и password пользователя передаются в заголовке Authorization в незашифрованном виде (base64-encoded). Однако при использовании HTTPS (HTTP over SSL) протокола, является относительно безопасной.

    Пример HTTP аутентификации с использованием Basic схемы.
  2. Digest — challenge-response-схема, при которой сервер посылает уникальное значение nonce, а браузер передает MD5 хэш пароля пользователя, вычисленный с использованием указанного nonce. Более безопасная альтернативв Basic схемы при незащищенных соединениях, но подвержена man-in-the-middle attacks (с заменой схемы на basic). Кроме того, использование этой схемы не позволяет применить современные хэш-функции для хранения паролей пользователей на сервере.
  3. NTLM (известная как Windows authentication) — также основана на challenge-response подходе, при котором пароль не передается в чистом виде. Эта схема не является стандартом HTTP, но поддерживается большинством браузеров и веб-серверов. Преимущественно используется для аутентификации пользователей Windows Active Directory в веб-приложениях. Уязвима к pass-the-hash-атакам.
  4. Negotiate — еще одна схема из семейства Windows authentication, которая позволяет клиенту выбрать между NTLM и Kerberos аутентификацией. Kerberos — более безопасный протокол, основанный на принципе Single Sign-On. Однако он может функционировать, только если и клиент, и сервер находятся в зоне intranet и являются частью домена Windows.

Стоит отметить, что при использовании HTTP-аутентификации у пользователя нет стандартной возможности выйти из веб-приложения, кроме как закрыть все окна браузера.
Forms authentication

Для этого протокола нет определенного стандарта, поэтому все его реализации специфичны для конкретных систем, а точнее, для модулей аутентификации фреймворков разработки.

Работает это по следующему принципу: в веб-приложение включается HTML-форма, в которую пользователь должен ввести свои username/password и отправить их на сервер через HTTP POST для аутентификации. В случае успеха веб-приложение создает session token, который обычно помещается в browser cookies. При последующих веб-запросах session token автоматически передается на сервер и позволяет приложению получить информацию о текущем пользователе для авторизации запроса.


Пример forms authentication.

Приложение может создать session token двумя способами:

  1. Как идентификатор аутентифицированной сессии пользователя, которая хранится в памяти сервера или в базе данных. Сессия должна содержать всю необходимую информацию о пользователе для возможности авторизации его запросов.
  2. Как зашифрованный и/или подписанный объект, содержащий данные о пользователе, а также период действия. Этот подход позволяет реализовать stateless-архитектуру сервера, однако требует механизма обновления сессионного токена по истечении срока действия. Несколько стандартных форматов таких токенов рассматриваются в секции «Аутентификация по токенам».

Необходимо понимать, что перехват session token зачастую дает аналогичный уровень доступа, что и знание username/password. Поэтому все коммуникации между клиентом и сервером в случае forms authentication должны производиться только по защищенному соединению HTTPS.
Другие протоколы аутентификации по паролю

Два протокола, описанных выше, успешно используются для аутентификации пользователей на веб-сайтах. Но при разработке клиент-серверных приложений с использованием веб-сервисов (например, iOS или Android), наряду с HTTP аутентификацией, часто применяются нестандартные протоколы, в которых данные для аутентификации передаются в других частях запроса.

Существует всего несколько мест, где можно передать username и password в HTTP запросах:

  1. URL query — считается небезопасным вариантом, т. к. строки URL могут запоминаться браузерами, прокси и веб-серверами.
  2. Request body — безопасный вариант, но он применим только для запросов, содержащих тело сообщения (такие как POST, PUT, PATCH).
  3. HTTP header —оптимальный вариант, при этом могут использоваться и стандартный заголовок Authorization (например, с Basic-схемой), и другие произвольные заголовки.
Распространенные уязвимости и ошибки реализации
Аутентификации по паролю считается не очень надежным способом, так как пароль часто можно подобрать, а пользователи склонны использовать простые и одинаковые пароли в разных системах, либо записывать их на клочках бумаги. Если злоумышленник смог выяснить пароль, то пользователь зачастую об этом не узнает. Кроме того, разработчики приложений могут допустить ряд концептуальных ошибок, упрощающих взлом учетных записей.

Ниже представлен список наиболее часто встречающихся уязвимостей в случае использования аутентификации по паролю:

  • Веб-приложение позволяет пользователям создавать простые пароли.
  • Веб-приложение не защищено от возможности перебора паролей (brute-force attacks).
  • Веб-приложение само генерирует и распространяет пароли пользователям, однако не требует смены пароля после первого входа (т.е. текущий пароль где-то записан).
  • Веб-приложение допускает передачу паролей по незащищенному HTTP-соединению, либо в строке URL.
  • Веб-приложение не использует безопасные хэш-функции для хранения паролей пользователей.
  • Веб-приложение не предоставляет пользователям возможность изменения пароля либо не нотифицирует пользователей об изменении их паролей.
  • Веб-приложение использует уязвимую функцию восстановления пароля, которую можно использовать для получения несанкционированного доступа к другим учетным записям.
  • Веб-приложение не требует повторной аутентификации пользователя для важных действий: смена пароля, изменения адреса доставки товаров и т. п.
  • Веб-приложение создает session tokens таким образом, что они могут быть подобраны или предсказаны для других пользователей.
  • Веб-приложение допускает передачу session tokens по незащищенному HTTP-соединению, либо в строке URL.
  • Веб-приложение уязвимо для session fixation-атак (т. е. не заменяет session token при переходе анонимной сессии пользователя в аутентифицированную).
  • Веб-приложение не устанавливает флаги HttpOnly и Secure для browser cookies, содержащих session tokens.
  • Веб-приложение не уничтожает сессии пользователя после короткого периода неактивности либо не предоставляет функцию выхода из аутентифицированной сессии.
Аутентификация по сертификатам

Сертификат представляет собой набор атрибутов, идентифицирующих владельца, подписанный certificate authority (CA). CA выступает в роли посредника, который гарантирует подлинность сертификатов (по аналогии с ФМС, выпускающей паспорта). Также сертификат криптографически связан с закрытым ключом, который хранится у владельца сертификата и позволяет однозначно подтвердить факт владения сертификатом.

На стороне клиента сертификат вместе с закрытым ключом могут храниться в операционной системе, в браузере, в файле, на отдельном физическом устройстве (smart card, USB token). Обычно закрытый ключ дополнительно защищен паролем или PIN-кодом.

В веб-приложениях традиционно используют сертификаты стандарта X.509. Аутентификация с помощью X.509-сертификата происходит в момент соединения с сервером и является частью протокола SSL/TLS. Этот механизм также хорошо поддерживается браузерами, которые позволяют пользователю выбрать и применить сертификат, если веб-сайт допускает такой способ аутентификации.


Использование сертификата для аутентификации.

Во время аутентификации сервер выполняет проверку сертификата на основании следующих правил:

  1. Сертификат должен быть подписан доверенным certification authority (проверка цепочки сертификатов).
  2. Сертификат должен быть действительным на текущую дату (проверка срока действия).
  3. Сертификат не должен быть отозван соответствующим CA (проверка списков исключения).


Пример X.509 сертификата.

После успешной аутентификации веб-приложение может выполнить авторизацию запроса на основании таких данных сертификата, как subject (имя владельца), issuer (эмитент), serial number (серийный номер сертификата) или thumbprint (отпечаток открытого ключа сертификата).

Использование сертификатов для аутентификации — куда более надежный способ, чем аутентификация посредством паролей. Это достигается созданием в процессе аутентификации цифровой подписи, наличие которой доказывает факт применения закрытого ключа в конкретной ситуации (non-repudiation). Однако трудности с распространением и поддержкой сертификатов делает такой способ аутентификации малодоступным в широких кругах.

Аутентификация по одноразовым паролям

Аутентификация по одноразовым паролям обычно применяется дополнительно к аутентификации по паролям для реализации two-factor authentication (2FA). В этой концепции пользователю необходимо предоставить данные двух типов для входа в систему: что-то, что он знает (например, пароль), и что-то, чем он владеет (например, устройство для генерации одноразовых паролей). Наличие двух факторов позволяет в значительной степени увеличить уровень безопасности, что м. б. востребовано для определенных видов веб-приложений.

Другой популярный сценарий использования одноразовых паролей — дополнительная аутентификация пользователя во время выполнения важных действий: перевод денег, изменение настроек и т. п.

Существуют разные источники для создания одноразовых паролей. Наиболее популярные:

  1. Аппаратные или программные токены, которые могут генерировать одноразовые пароли на основании секретного ключа, введенного в них, и текущего времени. Секретные ключи пользователей, являющиеся фактором владения, также хранятся на сервере, что позволяет выполнить проверку введенных одноразовых паролей. Пример аппаратной реализаций токенов — RSA SecurID; программной — приложение Google Authenticator.
  2. Случайно генерируемые коды, передаваемые пользователю через SMS или другой канал связи. В этой ситуации фактор владения — телефон пользователя (точнее — SIM-карта, привязанная к определенному номеру).
  3. Распечатка или scratch card со списком заранее сформированных одноразовых паролей. Для каждого нового входа в систему требуется ввести новый одноразовый пароль с указанным номером.


Аппаратный токен RSA SecurID генерирует новый код каждые 30 секунд.

В веб-приложениях такой механизм аутентификации часто реализуется посредством расширения forms authentication: после первичной аутентификации по паролю, создается сессия пользователя, однако в контексте этой сессии пользователь не имеет доступа к приложению до тех пор, пока он не выполнит дополнительную аутентификацию по одноразовому паролю.

Аутентификация по ключам доступа

Этот способ чаще всего используется для аутентификации устройств, сервисов или других приложений при обращении к веб-сервисам. Здесь в качестве секрета применяются ключи доступа (access key, API key) — длинные уникальные строки, содержащие произвольный набор символов, по сути заменяющие собой комбинацию username/password.

В большинстве случаев, сервер генерирует ключи доступа по запросу пользователей, которые далее сохраняют эти ключи в клиентских приложениях. При создании ключа также возможно ограничить срок действия и уровень доступа, который получит клиентское приложение при аутентификации с помощью этого ключа.

Хороший пример применения аутентификации по ключу — облако Amazon Web Services. Предположим, у пользователя есть веб-приложение, позволяющее загружать и просматривать фотографии, и он хочет использовать сервис Amazon S3 для хранения файлов. В таком случае, пользователь через консоль AWS может создать ключ, имеющий ограниченный доступ к облаку: только чтение/запись его файлов в Amazon S3. Этот ключ в результате можно применить для аутентификации веб-приложения в облаке AWS.


Пример применения аутентификации по ключу.

Использование ключей позволяет избежать передачи пароля пользователя сторонним приложениям (в примере выше пользователь сохранил в веб-приложении не свой пароль, а ключ доступа). Ключи обладают значительно большей энтропией по сравнению с паролями, поэтому их практически невозможно подобрать. Кроме того, если ключ был раскрыт, это не приводит к компрометации основной учетной записи пользователя — достаточно лишь аннулировать этот ключ и создать новый.

С технической точки зрения, здесь не существует единого протокола: ключи могут передаваться в разных частях HTTP-запроса: URL query, request body или HTTP header. Как и в случае аутентификации по паролю, наиболее оптимальный вариант — использование HTTP header. В некоторых случаях используют HTTP-схему Bearer для передачи токена в заголовке (Authorization: Bearer [token]). Чтобы избежать перехвата ключей, соединение с сервером должно быть обязательно защищено протоколом SSL/TLS.


Пример аутентификации по ключу доступа, переданного в HTTP заголовке.

Кроме того, существуют более сложные схемы аутентификации по ключам для незащищенных соединений. В этом случае, ключ обычно состоит их двух частей: публичной и секретной. Публичная часть используется для идентификации клиента, а секретная часть позволяет сгенерировать подпись. Например, по аналогии с digest authentication схемой, сервер может послать клиенту уникальное значение nonce или timestamp, а клиент — возвратить хэш или HMAC этого значения, вычисленный с использованием секретной части ключа. Это позволяет избежать передачи всего ключа в оригинальном виде и защищает от replay attacks.

Аутентификация по токенам

Такой способ аутентификации чаще всего применяется при построении распределенных систем Single Sign-On (SSO), где одно приложение (service provider или relying party) делегирует функцию аутентификации пользователей другому приложению (identity provider или authentication service). Типичный пример этого способа — вход в приложение через учетную запись в социальных сетях. Здесь социальные сети являются сервисами аутентификации, а приложение доверяет функцию аутентификации пользователей социальным сетям.

Реализация этого способа заключается в том, что identity provider (IP) предоставляет достоверные сведения о пользователе в виде токена, а service provider (SP) приложение использует этот токен для идентификации, аутентификации и авторизации пользователя.
На общем уровне, весь процесс выглядит следующим образом:

  1. Клиент аутентифицируется в identity provider одним из способов, специфичным для него (пароль, ключ доступа, сертификат, Kerberos, итд.).
  2. Клиент просит identity provider предоставить ему токен для конкретного SP-приложения. Identity provider генерирует токен и отправляет его клиенту.
  3. Клиент аутентифицируется в SP-приложении при помощи этого токена.


Пример аутентификации «активного» клиента при помощи токена, переданного посредством Bearer схемы.

Процесс, описанный выше, отражает механизм аутентификации активного клиента, т. е. такого, который может выполнять запрограммированную последовательность действий (например, iOS/Android приложения). Браузер же — пассивный клиент в том смысле, что он только может отображать страницы, запрошенные пользователем. В этом случае аутентификация достигается посредством автоматического перенаправления браузера между веб-приложениями identity provider и service provider.


Пример аутентификации «пассивного» клиента посредством перенаправления запросов.

Существует несколько стандартов, в точности определяющих протокол взаимодействия между клиентами (активными и пассивными) и IP/SP-приложениями и формат поддерживаемых токенов. Среди наиболее популярных стандартов — OAuth, OpenID Connect, SAML, и WS-Federation. Некоторая информация об этих протоколах — ниже в статье.

Сам токен обычно представляет собой структуру данных, которая содержит информацию, кто сгенерировал токен, кто может быть получателем токена, срок действия, набор сведений о самом пользователе (claims). Кроме того, токен дополнительно подписывается для предотвращения несанкционированных изменений и гарантий подлинности.

При аутентификации с помощью токена SP-приложение должно выполнить следующие проверки:

  1. Токен был выдан доверенным identity provider приложением (проверка поля issuer).
  2. Токен предназначается текущему SP-приложению (проверка поля audience).
  3. Срок действия токена еще не истек (проверка поля expiration date).
  4. Токен подлинный и не был изменен (проверка подписи).

В случае успешной проверки SP-приложение выполняет авторизацию запроса на основании данных о пользователе, содержащихся в токене.

Форматы токенов

Существует несколько распространенных форматов токенов для веб-приложений:
  1. Simple Web Token (SWT) — наиболее простой формат, представляющий собой набор произвольных пар имя/значение в формате кодирования HTML form. Стандарт определяет несколько зарезервированных имен: Issuer, Audience, ExpiresOn и HMACSHA256. Токен подписывается с помощью симметричного ключа, таким образом оба IP- и SP-приложения должны иметь этот ключ для возможности создания/проверки токена.

    Пример SWT токена (после декодирования).

    Issuer=http://auth.myservice.com&
    Audience=http://myservice.com&
    ExpiresOn=1435937883&
    UserName=John Smith&
    UserRole=Admin&
    HMACSHA256=KOUQRPSpy64rvT2KnYyQKtFFXUIggnesSpE7ADA4o9w

  2. JSON Web Token (JWT) — содержит три блока, разделенных точками: заголовок, набор полей (claims) и подпись. Первые два блока представлены в JSON-формате и дополнительно закодированы в формат base64. Набор полей содержит произвольные пары имя/значения, притом стандарт JWT определяет несколько зарезервированных имен (iss, aud, exp и другие). Подпись может генерироваться при помощи и симметричных алгоритмов шифрования, и асимметричных. Кроме того, существует отдельный стандарт, отписывающий формат зашифрованного JWT-токена.

    Пример подписанного JWT токена (после декодирования 1 и 2 блоков).

    { «alg»: «HS256», «typ»: «JWT» }.
    { «iss»: «auth.myservice.com», «aud»: «myservice.com», «exp»: «1435937883», «userName»: «John Smith», «userRole»: «Admin» }.
    S9Zs/8/uEGGTVVtLggFTizCsMtwOJnRhjaQ2BMUQhcY
  3. Security Assertion Markup Language (SAML) — определяет токены (SAML assertions) в XML-формате, включающем информацию об эмитенте, о субъекте, необходимые условия для проверки токена, набор дополнительных утверждений (statements) о пользователе. Подпись SAML-токенов осуществляется при помощи ассиметричной криптографии. Кроме того, в отличие от предыдущих форматов, SAML-токены содержат механизм для подтверждения владения токеном, что позволяет предотвратить перехват токенов через man-in-the-middle-атаки при использовании незащищенных соединений.
Стандарт SAML

Стандарт Security Assertion Markup Language (SAML) описывает способы взаимодействия и протоколы между identity provider и service provider для обмена данными аутентификации и авторизации посредством токенов. Изначально версии 1.0 и 1.1 были выпущены в 2002 – 2003 гг., в то время как версия 2.0, значительно расширяющая стандарт и обратно несовместимая, опубликована в 2005 г.

Этот основополагающий стандарт — достаточно сложный и поддерживает много различных сценариев интеграции систем. Основные «строительные блоки» стандарта:

  1. Assertions — собственный формат SAML токенов в XML формате.
  2. Protocols — набор поддерживаемых сообщений между участниками, среди которых — запрос на создание нового токена, получение существующих токенов, выход из системы (logout), управление идентификаторами пользователей, и другие.
  3. Bindings — механизмы передачи сообщений через различные транспортные протоколы. Поддерживаются такие способы, как HTTP Redirect, HTTP POST, HTTP Artifact (ссылка на сообщения), SAML SOAP, SAML URI (адрес получения сообщения) и другие.
  4. Profiles — типичные сценарии использования стандарта, определяющие набор assertions, protocols и bindings необходимых для их реализации, что позволяет достичь лучшей совместимости. Web Browser SSO — один из примеров таких профилей.

Кроме того, стандарт определяет формат обмена метаинформацией между участниками, которая включает список поддерживаемых ролей, протоколов, атрибутов, ключи шифрования и т. п.

Рассмотрим краткий пример использования SAML для сценария Single Sign-On. Пользователь хочет получить доступ на защищенный ресурс сервис-провайдера (шаг № 1 на диаграмме аутентификации пассивных клиентов). Т. к. пользователь не был аутентифицирован, SP отправляет его на сайт identity provider’а для создания токена (шаг № 2). Ниже приведен пример ответа SP, где последний использует SAML HTTP Redirect binding для отправки сообщения с запросом токена:

В случае такого запроса, identity provider аутентифицирует пользователя (шаги №3-4), после чего генерирует токен. Ниже приведен пример ответа IP с использованием HTTP POST binding (шаг № 5):

После того как браузер автоматически отправит эту форму на сайт service provider’а (шаг № 6), последний декодирует токен и аутентифицирует пользователя. По результатам успешной авторизации запроса пользователь получает доступ к запрошенному ресурсу (шаг № 7).

Стандарты WS-Trust и WS-Federation

WS-Trust и WS-Federation входят в группу стандартов WS-*, описывающих SOAP/XML-веб сервисы. Эти стандарты разрабатываются группой компаний, куда входят Microsoft, IBM, VeriSign и другие. Наряду с SAML, эти стандарты достаточно сложные, используются преимущественно в корпоративных сценариях.

Стандарт WS-Trust описывает интерфейс сервиса авторизации, именуемого Secure Token Service (STS). Этот сервис работает по протоколу SOAP и поддерживает создание, обновление и аннулирование токенов. При этом стандарт допускает использование токенов различного формата, однако на практике в основном используются SAML-токены.

Стандарт WS-Federation касается механизмов взаимодействия сервисов между компаниями, в частности, протоколов обмена токенов. При этом WS-Federation расширяет функции и интерфейс сервиса STS, описанного в стандарте WS-Trust. Среди прочего, стандарт WS-Federation определяет:

  • Формат и способы обмена метаданными о сервисах.
  • Функцию единого выхода из всех систем (single sign-out).
  • Сервис атрибутов, предоставляющий дополнительную информацию о пользователе.
  • Сервис псевдонимов, позволяющий создавать альтернативные имена пользователей.
  • Поддержку пассивных клиентов (браузеров) посредством перенаправления.

Можно сказать, что WS-Federation позволяет решить те же задачи, что и SAML, однако их подходы и реализация в некоторой степени отличаются.

Стандарты OAuth и OpenID Connect

В отличие от SAML и WS-Federation, стандарт OAuth (Open Authorization) не описывает протокол аутентификации пользователя. Вместо этого он определяет механизм получения доступа одного приложения к другому от имени пользователя. Однако существуют схемы, позволяющие осуществить аутентификацию пользователя на базе этого стандарта (об этом — ниже).

Первая версия стандарта разрабатывалась в 2007 – 2010 гг., а текущая версия 2.0 опубликована в 2012 г. Версия 2.0 значительно расширяет и в то же время упрощает стандарт, но обратно несовместима с версией 1.0. Сейчас OAuth 2.0 очень популярен и используется повсеместно для предоставления делегированного доступа и третье-сторонней аутентификации пользователей.

Чтобы лучше понять сам стандарт, рассмотрим пример веб-приложения, которое помогает пользователям планировать путешествия. Как часть функциональности оно умеет анализировать почту пользователей на наличие писем с подтверждениями бронирований и автоматически включать их в планируемый маршрут. Возникает вопрос, как это веб-приложение может безопасно получить доступ к почте пользователей, например, к Gmail?

> Попросить пользователя указать данные своей учетной записи? — плохой вариант.
> Попросить пользователя создать ключ доступа? — возможно, но весьма сложно.

Как раз эту проблему и позволяет решить стандарт OAuth: он описывает, как приложение путешествий (client) может получить доступ к почте пользователя (resource server) с разрешения пользователя (resource owner). В общем виде весь процесс состоит из нескольких шагов:

  1. Пользователь (resource owner) дает разрешение приложению (client) на доступ к определенному ресурсу в виде гранта. Что такое грант, рассмотрим чуть ниже.
  2. Приложение обращается к серверу авторизации и получает токен доступа к ресурсу в обмен на свой грант. В нашем примере сервер авторизации — Google. При вызове приложение дополнительно аутентифицируется при помощи ключа доступа, выданным ему при предварительной регистрации.
  3. Приложение использует этот токен для получения требуемых данных от сервера ресурсов (в нашем случае — сервис Gmail).


Взаимодействие компонентов в стандарте OAuth.

Стандарт описывает четыре вида грантов, которые определяют возможные сценарии применения:

  1. Authorization Code — этот грант пользователь может получить от сервера авторизации после успешной аутентификации и подтверждения согласия на предоставление доступа. Такой способ наиболее часто используется в веб-приложениях. Процесс получения гранта очень похож на механизм аутентификации пассивных клиентов в SAML и WS-Federation.
  2. Implicit — применяется, когда у приложения нет возможности безопасно получить токен от сервера авторизации (например, JavaScript-приложение в браузере). В этом случае грант представляет собой токен, полученный от сервера авторизации, а шаг № 2 исключается из сценария выше.
  3. Resource Owner Password Credentials — грант представляет собой пару username/password пользователя. Может применяться, если приложение является «интерфейсом» для сервера ресурсов (например, приложение — мобильный клиент для Gmail).
  4. Client Credentials — в этом случае нет никакого пользователя, а приложение получает доступ к своим ресурсам при помощи своих ключей доступа (исключается шаг № 1).

Стандарт не определяет формат токена, который получает приложение: в сценариях, адресуемых стандартом, приложению нет необходимости анализировать токен, т. к. он лишь используется для получения доступа к ресурсам. Поэтому ни токен, ни грант сами по себе не могут быть использованы для аутентификации пользователя. Однако если приложению необходимо получить достоверную информацию о пользователе, существуют несколько способов это сделать:

  1. Зачастую API сервера ресурсов включает операцию, предоставляющую информацию о самом пользователе (например, /me в Facebook API). Приложение может выполнять эту операцию каждый раз после получения токена для идентификации клиента. Такой метод иногда называют псевдо-аутентификацией.
  2. Использовать стандарт OpenID Connect, разработанный как слой учетных данных поверх OAuth (опубликован в 2014 г.). В соответствии с этим стандартом, сервер авторизации предоставляет дополнительный identity token на шаге № 2. Этот токен в формате JWT будет содержать набор определенных полей (claims) с информацией о пользователе.

Стоит заметить, что OpenID Connect, заменивший предыдущие версии стандарта OpenID 1.0 и 2.0, также содержит набор необязательных дополнений для поиска серверов авторизации, динамической регистрации клиентов и управления сессией пользователя.

Заключение

В этой статье мы рассмотрели различные методы аутентификации в веб-приложениях. Ниже — таблица, которая резюмирует описанные способы и протоколы:

Способ


Основное применение


Протоколы


По паролю


Аутентификация пользователей


HTTP, Forms


По сертификатам


Аутентификация пользователей в безопасных приложениях; аутентификация сервисов


SSL/TLS


По одноразовым паролям


Дополнительная аутентификация пользователей (для достижения two-factor authentication)


Forms


По ключам доступа


Аутентификация сервисов и приложений



По токенам


Делегированная аутентификация пользователей; делегированная авторизация приложений


SAML, WS-Federation, OAuth, OpenID Connect


Надеюсь, что информация оказалась полезна, и вы сможете применить ее при дизайне и разработке новых приложений. До новых встреч!

Автор: Дмитрий Выростков, Solutions Architect в DataArt.

Анализатор протокола WS2812b для Saleae LLC / Блог им. kisoft / Сообщество EasyElectronics.ru

После приобретения клона анализатора Saleae, стало интересно, можно ли реализовать свой анализатор сигнала (плагин), что и было сделано на примере реализации анализатора протокола WS2812b. Остальное под катом.

Сначала было…

Купил себе клон анализатора Saleae USB Logic Analyzer 100M max sample rate,16Channels,10B samples, MCU,ARM,FPGA debug tool. ПО Saleae Logic v1.2.5 Beta работает с этим аппаратом нормально, проблем не возникло.
Итак, поискал, можно ли реализовать свой анализатор, нашлось всё что нужно, SDK и подробное описание. Попутно нашел в пожеланиях протокол WS2812b, однако желающих оказалось мало (Всего 24 голоса за реализацию этого протокола), короче в списке протоколов его, во всяком случае пока нет. В интернете есть реализации анализатора этого протокола, но меня больше интересовало разобраться самому. В общем интерес скорей академический, нежели практический. Впрочем, некоторый практический интерес тоже есть, потому что, неспешно, отлаживается вывод световых эффектов на светильник (Статья из личного блога «Новогодний светильник»). Мало поможет, но для оценки временных параметров сигнала, возможно пригодится.
Еще хочу сказать, что пока мало знаком, что с анализатором, что с программой, потому, возможно, где то не совсем точен.

Сначала посмотрим что получилось

  • Анализ сигнала и отображение информации в разном виде
  • Распознавание сигналов сброса и данных
  • Отображение данных в табличном виде (в окне Decoded Protocols)
  • Экспорт в файл (text/csv)
  • Режим AutoBaud для определения временных характеристик сигнала

Скриншоты

На первом отображаются результаты анализа на сигнале (формируемом плагином), содержащем 4 тестовых фрейма (один фрейм — это один LED). Анализатор распознает сигнал сброса и параметры фреймов (с выделением цветовых составляющих в каждом фрейме). Начало каждого фрейма отмечается зеленой точкой, а конец всей посылки оранжевой точкой. Это всё видно на скриншоте:

На следующем скриншоте несколько увеличен масштаб, чтобы разглядеть форму сигнала более подробно:

На скриншотах также можно увидеть в окне Decoded Protocols (справа внизу) информацию, отображаемую в табличном виде.

Окно установки параметров анализатора протокола

Что я использовал для создания такого плагина?

Используемое ПО

  • Windows 10 x64 (или Windows 7 Pro x64)
  • Microsoft Visual C/C++ 2013 Professional. Если использовать Express, то придется решать проблему со сборкой x64 версии плагина
  • Saleae Logic v1.2.5 Beta
  • Saleae Protocol Analyzer SDK 1.1.32
  • Python 3.5.1 (v3.5.1:37a07cee5969, Dec 6 2015, 01:38:48) [MSC v.1900 32 bit (Intel)] on win32. Нужен только для переименования имени протокола и файлов в исходниках шаблона. Это в доках есть, там будет понятней.

Предварительная настройка

Будем считать, что компилятор уже установлен и настроен. Предварительно настроим среду, подготовим всё к реализации своего анализатора:

  1. Качаем Saleae Protocol Analyzer SDK, я брал здесь, устанавливаем и открываем документацию (она лежит в подкаталоге documentation), там подробно описан процесс создания анализатора (на английском), а также есть пример. Я это всё установил в каталог: «F:\Devel\Saleae\SaleaeAnalyzerSdk-1.1.32». Кстати, на той же странице можно скачать исходники других анализаторов, это может помочь в написании своего анализатора.
  2. Тупо читаем доку («F:\Devel\Saleae\SaleaeAnalyzerSdk-1.1.32\documentation\Saleae Analyzer SDK.pdf») и делаем то, что там написано, по шагам, после чего получаем работающий анализатор Simple Serial Analyzer (это пример, который уже лежит в SDK) но уже со своим именем, WS2812bAnalyzer.
  3. Конечно же сразу его пробуем и проверяем, всё ли хорошо собралось. Для этого запускаем Saleae Logic в Options/Preferences/Developer указываем путь к dll, которую мы собрали и будем использовать (я задал «F:\Devel\Saleae\SaleaeAnalyzerSdk-1.1.32\WS2812bAnalyzer\x64\Debug»). Это позволяет использовать собранную dll из каталога сборки.
  4. Перезапускаем Saleae Logic, открываем список протоколов и видим наш анализатор в списке. Если при старте произошла ошибка, возможно версии Saleae Logic и плагина разные (32 vs x64, такое бывает). В этом случае собираем нужную версию (32 или 64) плагина (у меня x64)
  5. Выбираем из списка нужный нам анализатор WS2812b (см. ниже скриншот). Задаем параметры (как минимум задаем номер канала) и жмем «Save».
  6. Выбираем «Start Simulation» и видим результат работы анализатора.
  7. Главное сделано, среда настроена, анализатор видит и использует нашу dll. Собственно всё готово к реализации своего анализатора.

Исходники достаточно простые, их немного, плюс в доке достаточно тупо описано как и что нужно реализовать, а также за что отвечает каждый класс. Если в двух словах, то плагин анализатора содержит 4 основных модуля:

  • {MyAnanlyzerName}Analyzer — собственно анализатор. Анализирует данные, формирует отображение (добавляет метки на сигнал).
  • {MyAnanlyzerName}AnalyzerResults — формирователь информации для отображения на сигнале, в окне Decoded Protocols и для экспорта данных в файл.
  • {MyAnanlyzerName}AnalyzerSettings — установки для анализатора. Правда функционала маловато, можно использовать только простые конструкции. Впрочем, это не так уж и важно.
  • {MyAnanlyzerName}SimulationDataGenerator — это генератор тестового сигнала. Собственно он выдает сигнал при нажатии кнопки «Start Simulation».
Если вдруг что-то будет непонятно, лучше спросить, расписывать всё это не вижу смысла.

Немного о данных

Как выполняется анализ данных? Saleae Logic передает оцифрованные данные анализатору (плагину), если он указан для канала. Плагин формирует данные и добавляет метки на график сигнала. Для использования оцифрованных данных в своем плагине, необходимо в рабочем цикле (WorkerThread) сохранять полученные данные в массив/список. Далее, если требуется перезапуск анализатора (например для реализации режима AutoBaud), в методе NeedsRerun, можно выполнить анализ, сохранить какие то параметры анализа и сообщить о необходимости перезапустить анализатор для распознавания уже с другими параметрами анализа. Данные, которыми может оперировать анализатор, это уровень сигнала (высокий/низкий), время, когда этот сигнал изменил уровень (т.е. вычитая время одного события из времени следующего события мы получаем длительность события). Для анализа я сохранял только длительность уровня, соответственно один список — длительности высокого уровня, другой список — длительности низкого уровня.
На всякий случай, напомню форму сигнала для WS2812b:

Стандартные (для 800 КГц) параметры сигнала следующие:

  • T0H = 400 нс
  • T0L = 850 нс
  • T1H = 800 нс
  • T1L = 450 нс
  • TReset = не менее 50000 нс
Допустимое отклонение для TH & TL +-150 нс.
Допустимое отклонение для периода (TH + TL) +-600 нс.
Если посмотреть мой диалог настройки, то там нет числа 600, это потому, что я не анализировал период сигнала. Для анализа я использовал только длительности высокого уровня. Для поиска сигнала сброса — только длительности низкого уровня.

Что такое AutoBaud?

AutoBaud — это возможность проанализировать времянки сигнала (длительности «0», «1», «Сброса»), если они отличаются от стандартных (т.е. в этом случае анализатор может не определить данные). Для чего это нужно? У меня, например, для вывода сигнала на STM32F050F4P6 (STM32F031F4P6) используется DMA + SPI и времянки несколько отличаются от того, что указано в ДШ на WS2812b (пока это в стадии неспешного развития, потому не привожу цифры). Однако в железе это всё работает. Но в этом случае для нормального анализа данных, приходится изменять длительности сигнала в установках анализатора, потому, для лентяев (для меня), был реализован автоматический расчет длительностей с дальнейшим применением их для повторного анализа сигнала уже с корректными значениями длительностей. (Как бы это написать по-русски..). Думаю, что если где то используется другая частота (не 800 КГц), этот режим может пригодиться.

Автоматическое определение временных характеристик сигнала

Для определения длительностей интервалов использовался алгоритм DBSCAN (например, есть такая статья: «Определение параметров сигнала методом непараметрической кластеризации DBSCAN»)
Анализ производится раздельно, для длительностей высокого уровня и длительностей низкого уровня. Для чего это нужно? Выделить полезные времена для уровня (400 или 800 для высокого и 450 или 850 для низкого) проще, нежели выгребать потом разницу в кластерах между 400 и 450. Т.е., субъективно, разделение анализа для разных уровней более надежно и проще.
Входные данные — это список длительностей в попугаях (попугаи в секунды и обратно легко пересчитываются через частоту дискретизации). Алгоритм примерно следующий:

  • Сжатие сигнала. Т.е. длительности в списке делаем уникальными, добавляя в структуру еще количество повторяющихся значений. Т.е. две одинаковых длительности превращаются в один элемент списка, но со счетчиком равным 2. (Наверное я тут как КО выступаю, не знаю)
  • Выделение кластеров (см. описание алгоритма DBSCAN) и отбрасывание шума.
  • Расчет среднего значения длительности по кластерам. Эти средние значения и есть нужный нам результат.

Не обошлось и без особенностей. При анализе длительностей низкого уровня было запрещено удаление сигнала сброса (иначе он мог попадать в шум, в случае если он один на весь сигнал. Т.е. для сигнала сброса, размер кластера игнорировался). Хотя по большому счету, можно было это отбросить совсем, потому что, в конечном итоге, длительность сброса не анализируется и нигде не используется.

Резюме:

Saleae — интересный инструмент (как железо, так и софт), достаточно простая возможность добавления своих плагинов для анализа сигнала.
WS2812b — прилагаю ссылку на проект на github и прилагаю архив WS2812bAnalyzer_bin.zip с готовыми dll (32 и 64 битные версии под Windows).

Сразу скажу, что за оптимальностью не гнался, потому использование std::list скорей всего избыточно для хранения анализируемых данных.

PS Кажется слово «анализ» я больше не смогу слышать и читать еще долго. 🙂 Sorry, косноязычен.

websockets / ws: простой в использовании, невероятно быстрый и тщательно протестированный клиент и сервер WebSocket для Node.js

перейти к содержанию Зарегистрироваться
  • Почему именно GitHub? Особенности →
    • Обзор кода
    • Управление проектами
    • Интеграции
    • Действия
    • Пакеты
    • Безопасность
    • Управление командой
    • Хостинг
    • мобильный
    • Истории клиентов →
    • Безопасность →
  • Команда
  • Предприятие
  • Проводить исследования
    • Изучите GitHub →
    Учитесь и вносите свой вклад
    • Темы
    • Коллекции
    • В тренде
    • Учебная лаборатория
    • Руководства с открытым исходным кодом
    Общайтесь с другими
    • События
    • Форум сообщества
    • GitHub Education
.

Как работают веб-сокеты? — Кевин Сукочев,

WebSocket — это постоянное соединение между клиентом и сервером. WebSockets обеспечивает двунаправленный полнодуплексный канал связи. который работает через HTTP через одно соединение сокета TCP / IP. На своем core протокол WebSocket упрощает передачу сообщений между клиентом и сервер. Эта статья представляет собой введение в WebSocket. протокол, в том числе проблемы, которые решают WebSockets, и обзор того, как WebSockets описываются на уровне протокола.

Почему именно WebSocket?

Идея WebSockets была основана на ограничениях HTTP-технологий. технология. При использовании HTTP клиент запрашивает ресурс, а сервер отвечает запрошенными данными. HTTP — строго однонаправленный протокол — любые данные, отправленные с сервера клиенту, должны быть первыми по запросу клиента. Долгое голосование традиционно действовало как обходной путь для этого ограничения. При длительном опросе клиент делает HTTP-запрос с длительным периодом ожидания, и сервер использует это время. тайм-аут для отправки данных клиенту.Длинный опрос работает, но есть недостаток — ресурсы на сервере связаны на всем протяжении длинный опрос, даже если нет данных для отправки.

WebSockets, с другой стороны, позволяют отправлять данные на основе сообщений, аналогично UDP, но с надежностью TCP. WebSocket использует HTTP как начальный транспортный механизм, но поддерживает соединение TCP после получен ответ HTTP, чтобы его можно было использовать для отправки сообщений между клиентом и сервером. WebSockets позволяют нам создавать «в реальном времени» приложения без использования длительного опроса.

Обзор протокола

Протокол состоит из открывающего рукопожатия, за которым следует базовое сообщение. кадрирование, наложенное на TCP.

RFC 6455 — Протокол WebSocket

WebSockets начинают свою жизнь как стандартный HTTP-запрос и ответ. Внутри этого цепочка ответов на запросы, клиент просит открыть соединение WebSocket, и сервер отвечает (если может). Если это первоначальное рукопожатие успешно, клиент и сервер согласились использовать существующий TCP / IP соединение, которое было установлено для HTTP-запроса как WebSocket подключение.Теперь данные могут передаваться через это соединение с использованием базового фреймового протокол сообщений. Как только обе стороны признают, что WebSocket соединение должно быть закрыто, TCP соединение разорвано.

Установление соединения WebSocket — Открытое рукопожатие WebSocket

WebSockets не используют схему http: // или https: // (потому что они не следовать протоколу HTTP). Скорее, URI WebSocket используют новую схему ws: (или wss: для безопасного WebSocket).Остальная часть URI — это то же, что и HTTP URI: хост, порт, путь и любые параметры запроса.

  "ws:" "//" хост [":" порт] путь ["?" запрос]
"wss:" "//" хост [":" порт] путь ["?" запрос]
  

Соединения WebSocket могут быть установлены только для URI, следующих за этим схема. То есть, если вы видите URI со схемой ws: // (или wss: // ), тогда и клиент, и сервер ДОЛЖНЫ следовать соединению WebSocket. протокол в соответствии со спецификацией WebSocket.

подключений WebSocket установлено при обновлении пары HTTP-запрос / ответ. Клиент, который поддерживает WebSockets и хочет установить соединение, отправит HTTP запрос, который включает несколько обязательных заголовков:

  • Подключение: обновление
    • Заголовок Connection обычно определяет, сетевое соединение остается открытым после текущей транзакции отделка. Обычное значение для этого заголовка — keep-alive , чтобы убедитесь, что соединение является постоянным, чтобы разрешить последующие запросы на тот же сервер.Во время рукопожатия открытия WebSocket мы установили заголовок до Обновите , сигнализируя, что мы хотим сохранить соединение жив, и использовать его для запросов, отличных от HTTP.
  • Обновление: websocket
    • Заголовок Upgrade используется клиентами, чтобы попросить сервер переключиться к одному из перечисленных протоколов в порядке убывания предпочтения. Мы укажите здесь websocket , чтобы указать, что клиент хочет установить соединение WebSocket.
  • Sec-WebSocket-ключ: q4xkcO32u266gldTuKaSOw ==
    • Sec-WebSocket-Key — одноразовое случайное значение (nonce) генерируется клиентом.Значение — это случайно выбранное 16-байтовое значение, имеющее были закодированы base64.
  • Sec-WebSocket-Версия: 13
    • Единственная допустимая версия протокола WebSocket — 13. Любая другая версия, указанная в этом заголовке, недействительна.

Вместе эти заголовки приведут к HTTP-запросу GET от клиент к URI ws: // , как в следующем примере:

  ПОЛУЧИТЬ ws: //example.com: 8181 / HTTP / 1.1
Хост: localhost: 8181
Подключение: Обновление
Прагма: без кеширования
Cache-Control: без кеша
Обновление: websocket
Sec-WebSocket-Версия: 13
Sec-WebSocket-ключ: q4xkcO32u266gldTuKaSOw ==
  

Когда клиент отправляет начальный запрос на открытие соединения WebSocket, он ждет ответа от сервера. Ответ должен иметь код ответа HTTP 101 Switching Protocols . Ответ HTTP 101 Switching Protocols указывает, что сервер переключается на протокол, который клиент запрошен в заголовке запроса Upgrade .Кроме того, сервер должен включить заголовки HTTP, подтверждающие, что соединение было успешно улучшено:

  HTTP / 1.1 101 Протоколы коммутации
Обновление: websocket
Подключение: Обновление
Sec-WebSocket-Accept: fA9dggdnMPU79lJgAE3W4TRnyDM =
  
  • Подключение: обновление
    • Подтверждает, что соединение было обновлено.
  • Обновление: websocket
    • Подтверждает, что соединение было обновлено.
  • Sec-WebSocket-Accept : fA9dggdnMPU79lJgAE3W4TRnyDM = `
    • Sec-WebSocket-Accept закодирован в base64, хешированное значение SHA-1. Вы сгенерировать это значение путем объединения клиентов Sec-WebSocket-Key nonce и статическое значение 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 определено в RFC 6455. Хотя Sec-WebSocket-Key и Sec-WebSocket-Accept кажутся сложными, они существуют, так что оба клиент и сервер могут знать, что их коллега поддерживает WebSockets.Поскольку WebSocket повторно использует HTTP-соединение, там являются потенциальными проблемами безопасности, если любая из сторон интерпретирует WebSocket данные как HTTP-запрос.

После того, как клиент получит ответ сервера, соединение WebSocket открыть, чтобы начать передачу данных.

Протокол WebSocket

WebSocket — это протокол с фреймами , что означает, что фрагмент данных (сообщение) делится на несколько дискретных частей, размер которых закодировано в кадре.Кадр включает тип кадра, длину полезной нагрузки, и часть данных. Обзор кадра приведен в RFC. 6455 и воспроизведено Вот.

  0 1 2 3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ - + - + - + - + ------- + - + ------------- + ----------------- -------------- +
| F | R | R | R | код операции | M | Длина полезной нагрузки | Увеличенная длина полезной нагрузки |
| I | S | S | S | (4) | A | (7) | (16/64) |
| N | V | V | V | | S | | (если полезная нагрузка len == 126/127) |
| | 1 | 2 | 3 | | K | | |
+ - + - + - + - + ------- + - + ------------- + - - - - - - - - - - - - - - - +
| Увеличенная длина полезной нагрузки продолжается, если полезная нагрузка len == 127 |
+ - - - - - - - - - - - - - - - + ------------------------------- +
| | Маскирующий ключ, если МАСКА установлена ​​в 1 |
+ ------------------------------- + ----------------- -------------- +
| Маскирующий ключ (продолжение) | Данные полезной нагрузки |
+ -------------------------------- - - - - - - - - - - - - - - - - +
: Данные полезной нагрузки продолжение...:
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Данные полезной нагрузки продолжение ... |
+ ------------------------------------------------- -------------- +
  

Я не буду здесь останавливаться на всех деталях протокола кадров. Обратитесь к RFC 6455 для получения полной информации. Скорее, Я расскажу о самых важных моментах, чтобы мы могли понять протокола WebSocket.

Ребро-бит

Первый бит заголовка WebSocket — это бит Fin.Этот бит устанавливается, если этот фрейм — последние данные для завершения этого сообщения.

Биты RSV1, RSV2, RSV3

Эти биты зарезервированы для использования в будущем.

код операции

У каждого кадра есть код операции, который определяет, как интерпретировать данные полезной нагрузки.

Значение кода операции Описание
0x00 Этот кадр продолжает полезную нагрузку из предыдущего кадра.
0x01 Обозначает текстовый фрейм.Текстовые фреймы декодируются сервером в кодировке UTF-8.
0x02 Обозначает двоичный фрейм. Двоичные фреймы доставляются сервером без изменений.
0x03-0x07 Зарезервировано для использования в будущем.
0x08 Обозначает, что клиент хочет закрыть соединение.
0x09 Фрейм ping. Служит механизмом подтверждения связи, гарантирующим, что соединение все еще живо. Получатель должен ответить понгом.
0x0a Рама для понга. Служит механизмом подтверждения связи, гарантирующим, что соединение все еще живо. Получатель должен ответить фреймом ping.
0x0b-0x0f Зарезервировано для использования в будущем.
Маска

Установка этого бита в 1 включает маскирование . WebSockets требует, чтобы все полезная нагрузка обфусцирована с использованием случайного ключа (маски), выбранного клиентом. Ключ маскировки объединяется с данными полезной нагрузки с помощью операции XOR. перед отправкой данных в полезную нагрузку.Эта маскировка предотвращает появление кешей. неверная интерпретация фреймов WebSocket как кэшируемых данных. Почему мы должны предотвращать кеширование данных WebSocket? Безопасность.

При разработке протокола WebSocket было показано, что если развертывается скомпрометированный сервер, и клиенты подключаются к этому серверу, он можно иметь промежуточные прокси или кеш инфраструктуры ответы скомпрометированного сервера, чтобы будущие клиенты, запрашивающие data получают неверный ответ. Эта атака называется cache. отравление , и является результатом того факта, что мы не можем контролировать, как прокси-серверы ведут себя в дикой природе.Это особенно проблематично при введении нового протокола, такого как WebSocket, который должен взаимодействовать с существующая инфраструктура интернета.

Длина полезной нагрузки

Поле Payload len и Extended payload length поле используются для кодировать общую длину данных полезной нагрузки для этого кадра. Если полезная нагрузка данные небольшие (менее 126 байт), длина кодируется в поле Payload len . По мере роста данных полезной нагрузки мы используем дополнительные поля для закодировать длину полезной нагрузки.

Маскирующий ключ

Как обсуждалось с битом MASK , все кадры, отправленные от клиента к серверы маскируются 32-битным значением, содержащимся в кадре. Это поле присутствует, если бит маски установлен в 1, и отсутствует, если бит маски установлен на 0.

Данные полезной нагрузки

Данные полезной нагрузки включают произвольные данные приложения и любые расширения данные, согласованные между клиентом и сервером. Расширения согласовываются во время первоначального рукопожатия и позволяют расширить протокол WebSocket для дополнительных целей.

Закрытие соединения WebSocket — рукопожатие закрытия WebSocket

Чтобы закрыть соединение WebSocket, отправляется закрывающий кадр (код операции 0x08 ). Помимо кода операции, закрывающий кадр может содержать тело, которое указывает причину закрытия. Если одна из сторон соединения получает закрывающий кадр, он должен отправить закрывающий кадр в ответ, и больше никаких данных должны быть отправлены через соединение. После получения кадра закрытия обе стороны разрывают TCP-соединение.Сервер всегда инициирует закрытие TCP-соединения.

Дополнительные ссылки

Эта статья представляет собой введение в протокол WebSocket и покрывает много земли. Однако полный протокол содержит больше деталей, чем что я мог бы вписать в этот пост в блоге. Если хотите узнать больше, там есть несколько отличных ресурсов на выбор:

См. Также
.

sta / websocket-sharp: реализация C # клиента протокола WebSocket и сервера

. перейти к содержанию Зарегистрироваться
  • Почему именно GitHub? Особенности →
    • Обзор кода
    • Управление проектами
    • Интеграции
    • Действия
    • Пакеты
    • Безопасность
    • Управление командой
    • Хостинг
    • мобильный
    • Истории клиентов →
    • Безопасность →
  • Команда
  • Предприятие
  • Проводить исследования
    • Изучите GitHub →
    Учитесь и вносите свой вклад
    • Темы
    • Коллекции
    • В тренде
    • Учебная лаборатория
    • Руководства с открытым исходным кодом
    Общайтесь с другими
    • События
    • Форум сообщества
    • GitHub Education
.

Начало работы — документация по websockets 8.1

Требования

веб-сокетов требует Python ≥ 3.6.1.

По возможности следует использовать последнюю версию Python. Если вы используете старая версия, имейте в виду, что для каждой дополнительной версии (3.x) только последняя Выпуск bugfix (3.x.y) официально поддерживается.

Установка

Установите веб-сокетов с:

Базовый пример

Вот пример сервера WebSocket.

Считывает имя клиента, отправляет приветствие и закрывает соединение.

 #! / Usr / bin / env python

# Пример сервера WS

импортировать asyncio
импортировать веб-сокеты

async def привет (веб-сокет, путь):
    name = ждать websocket.recv ()
    print (f "<{имя}")

    приветствие = f "Здравствуйте, {имя}!"

    ждать websocket.send (приветствие)
    print (f "> {приветствие}")

start_server = websockets.serve (привет, "localhost", 8765)

asyncio.get_event_loop (). run_until_complete (начальный_сервер)
asyncio.get_event_loop (). run_forever ()
 

На стороне сервера websockets выполняет сопрограмму обработчика hello один раз для каждого соединения WebSocket. Он закрывает соединение, когда обработчик сопрограмма возвращается.

Вот соответствующий пример клиента WebSocket.

 #! / Usr / bin / env python

# Пример клиента WS

импортировать asyncio
импортировать веб-сокеты

async def hello ():
    uri = "ws: // localhost: 8765"
    async с websockets.connect (uri) как websocket:
        name = input ("Как вас зовут?")

        жду веб-сокета.отправить (имя)
        print (f "> {имя}")

        приветствие = ждать websocket.recv ()
        print (f "<{приветствие}")

asyncio.get_event_loop (). run_until_complete (привет ())
 

Использование connect () в качестве асинхронного диспетчера контекста обеспечивает соединение закрывается перед выходом из сопрограммы hello .

Безопасный пример

Secure WebSocket-соединения повышают конфиденциальность, а также надежность потому что они снижают риск вмешательства плохих прокси.

Протокол WSS для WS то же, что HTTPS для HTTP: соединение зашифровано с безопасностью транспортного уровня (TLS), который часто называют безопасным Уровень сокетов (SSL). WSS требует сертификатов TLS, таких как HTTPS.

Вот как можно адаптировать пример сервера для обеспечения безопасных соединений. Увидеть документация модуля ssl для безопасной настройки контекста.

 #! / Usr / bin / env python

# Пример сервера WSS (WS over TLS) с самоподписанным сертификатом

импортировать asyncio
импортировать pathlib
импортировать ssl
импортировать веб-сокеты

async def привет (веб-сокет, путь):
    name = ждать веб-сокета.recv ()
    print (f "<{имя}")

    приветствие = f "Здравствуйте, {имя}!"

    ждать websocket.send (приветствие)
    print (f "> {приветствие}")

ssl_context = ssl.SSLContext (ssl.PROTOCOL_TLS_SERVER)
localhost_pem = pathlib.Path (__ файл __). with_name ("localhost.pem")
ssl_context.load_cert_chain (localhost_pem)

start_server = websockets.serve (
    привет, "localhost", 8765, ssl = ssl_context
)

asyncio.get_event_loop (). run_until_complete (начальный_сервер)
asyncio.get_event_loop (). run_forever ()
 

Вот как адаптировать клиента.

 #! / Usr / bin / env python

# Пример клиента WSS (WS over TLS) с самоподписанным сертификатом

импортировать asyncio
импортировать pathlib
импортировать ssl
импортировать веб-сокеты

ssl_context = ssl.SSLContext (ssl.PROTOCOL_TLS_CLIENT)
localhost_pem = pathlib.Path (__ файл __). with_name ("localhost.pem")
ssl_context.load_verify_locations (localhost_pem)

async def hello ():
    uri = "wss: // localhost: 8765"
    асинхронный с websockets.connect (
        uri, ssl = ssl_context
    ) как веб-сокет:
        name = input ("Как вас зовут?")

        жду веб-сокета.отправить (имя)
        print (f "> {имя}")

        приветствие = ждать websocket.recv ()
        print (f "<{приветствие}")

asyncio.get_event_loop (). run_until_complete (привет ())
 

Этому клиенту нужен контекст, потому что сервер использует самозаверяющий сертификат.

Клиент подключается к защищенному серверу WebSocket с действующим сертификатом. (т. е. подписанный центром сертификации, которому доверяет ваша установка Python), можно просто передать ssl = True - connect () вместо построения контекста.

Пример на основе браузера

Вот пример того, как запустить сервер WebSocket и подключиться из браузера.

Запустите этот скрипт в консоли:

 #! / Usr / bin / env python

# WS-сервер, который отправляет сообщения через случайные промежутки времени

импортировать asyncio
дата и время импорта
случайный импорт
импортировать веб-сокеты

async def time (веб-сокет, путь):
    в то время как True:
        сейчас = datetime.datetime.utcnow (). 
.