Недавно я работал над многопользовательской браузерной игрой в реальном времени и столкнулся с проблемой «одного сеанса». По сути, я хотел запретить пользователю подключаться более одного раза через веб-сокеты. Это важно, поскольку многократный вход в одну и ту же учетную запись может создать нечестные сценарии и усложнить логику сервера. Поскольку соединения через веб-сокеты долговечны, мне нужно было найти способ предотвратить это.

Список желаний

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

В этом проекте мы будем использовать Node.js, Socket.IO и Redis.

Скромное начало

Давайте настроим наш проект и поставим это шоу на гастроли. Вы можете ознакомиться с полным репозиторием GitHub здесь. Сначала мы настроим наш сервер Socket.IO для приема подключений от внешнего интерфейса.

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

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

Кроме того, нам нужно настроить некоторую очень элементарную логику для выполнения подключения / отключения и подключения наших входов состояния и токенов.

Это все, что вам нужно для настройки базового клиента и сервера веб-сокетов. В этот момент мы можем подключаться, отключаться и регистрировать статус подключения для пользователя. И все это тоже на ванильном JavaScript! 🍻 Далее: аутентификация пользователей.

Аутентификация

Разрешение пользователям подключаться, не зная, кем они являются, для нас мало полезно. Давайте добавим в соединение базовую аутентификацию по токену. Мы предполагаем, что соединение использует SSL / TLS после развертывания. Никогда не используйте незашифрованное соединение. Всегда. 😶

На этом этапе у нас есть несколько вариантов: а) добавить токен пользователя в строку запроса, когда они подключаются, или б) разрешить любому пользователю подключиться и потребовать от него отправки сообщения аутентификации после подключения. Спецификация протокола Web Socket (RFC 6455) не предписывает конкретный способ аутентификации и не допускает использование настраиваемых заголовков, а поскольку параметры запроса могут регистрироваться сервером, я выбрал вариант b) для этого примера.

Мы реализуем аутентификацию с помощью socketio-auth от Факундо Олано, модуля аутентификации для Socket.IO, который позволяет нам запрашивать у клиента токен после подключения. Если пользователь не предоставит его в течение определенного времени, мы закроем соединение с сервером.

Мы подключаем socketAuth, передавая ему наш io экземпляр и параметры конфигурации в виде трех событий: authenticate, postAuthenticate и disconnect. Во-первых, наше событие authenticate запускается после подключения клиента и генерирует последующее событие authentication с полезной нагрузкой токена пользователя. Если клиент не отправит это событие аутентификации в течение настраиваемого периода времени, socketio-auth разорвет соединение.

После того, как пользователь отправил свой токен, мы проверяем его на соответствие нашим известным пользователям в базе данных. Например, я создал асинхронный verifyUser метод, имитирующий реальную базу данных или поиск в кэше. Если пользователь найден, он будет возвращен, в противном случае обещание будет отклонено по причине USER_NOT_FOUND.

Если все идет хорошо, мы вызываем обратный вызов и помечаем сокет как аутентифицированный или возвращаем UNAUTHORIZED, если токен недействителен.

Мы должны адаптировать наш интерфейсный код, чтобы он отправлял нам токен пользователя при подключении. Мы модифицируем нашу connect функцию следующим образом:

Мы добавили две вещи: socket.emit('authentication', { token }), чтобы сообщить серверу, кто мы, и прослушиватель событий socket.on('unauthorized'), чтобы реагировать на отклонения от нашего сервера.

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

Однако это по-прежнему не мешает пользователю дважды подключиться с одним и тем же токеном. Откройте отдельное окно и попробуйте. Чтобы заставить один сеанс, наш сервер должен наладиться. 💡

Предотвращение множественных подключений

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

Используйте распределенные блокировки с помощью Redis.

Мы будем использовать Redis для блокировки и разблокировки ресурсов, в нашем случае: пользовательских сеансов. Распределенные блокировки сложны, и вы можете прочитать о них здесь. В нашем случае мы реализуем блокировку ресурсов на одном узле Redis. Давайте начнем.

Первое, что мы сделаем, это подключим Socket.IO к Redis, чтобы включить pub / sub на нескольких серверах Socket.IO. Мы будем использовать socket.io-redis адаптер, предоставленный Socket.IO.

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

Давайте создадим наш клиент Redis как отдельный модуль и обещаем методы, чтобы мы могли использовать async / await.

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

Чтобы это работало, мы должны отслеживать каждое соединение, устанавливать блокировку и прекращать другие соединения, если тот же пользователь попытается подключиться снова. Чтобы получить блокировку, мы используем SET метод Redis с NX и сроком действия (подробнее о сроке действия позже). NX позаботится о том, чтобы мы установили ключ только в том случае, если он еще не существует. Если это так, команда возвращает null. Мы можем использовать эту настройку, чтобы определить, существует ли уже сеанс, и прервать его, если он существует.

Мы модифицируем нашу authenticate функцию следующим образом:

Убедившись, что у пользователя есть действующий токен, мы пытаемся заблокировать его сеанс (строка 6). Если Redis может SET ключ, это означает, что он ранее не существовал. Мы также добавили EX 30 в команду для автоматического снятия блокировки через 30 секунд. Это важно, потому что у нашего сервера или Redis может произойти сбой, и мы не хотим навсегда блокировать наших пользователей. Причина, по которой я выбрал 30 секунд, заключается в том, что Socket.IO имеет пинг по умолчанию 25 секунд, то есть каждые 25 секунд он будет проверять подключенных пользователей, чтобы узнать, подключены ли они еще. В следующем разделе мы воспользуемся этим, чтобы возобновить блокировку.

Чтобы возобновить блокировку, мы собираемся подключиться к событию packet нашего сокет-соединения для перехвата ping пакетов. По умолчанию они отправляются каждые 25 секунд. Если к тому времени пакет не будет получен, Socket.IO разорвет соединение.

Мы используем событие postAuthenticate для регистрации нашего packet обработчика событий. Затем наш обработчик проверяет, аутентифицирован ли сокет через socket.auth и имеет ли пакет тип ping. Чтобы возобновить блокировку, мы снова будем использовать команду Redis SET, на этот раз с XX вместо NX. XX указывает, что он будет установлен только в том случае, если он уже существует. Мы используем этот механизм, чтобы обновлять время истечения срока действия ключа каждые 25 секунд.

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

Тем не менее, есть один вариант использования, который мы упустили из виду: если пользователь закрывает свой браузер с активным подключением и пытается повторно подключиться, он ошибочно получит сообщение ALREADY_LOGGED_IN. Это потому, что предыдущая блокировка все еще действует. Чтобы правильно снять блокировку, когда пользователь намеренно покидает наш сайт, мы должны снять блокировку с Redis при отключении.

В нашем событии disconnect мы проверяем, аутентифицирован ли сокет, а затем снимаем блокировку с Redis с помощью команды DEL. Это снимает блокировку сеанса пользователя и подготавливает его к следующему подключению.

Вот и все! Чтобы увидеть наш процесс подключения в действии, откройте два окна браузера и нажмите «Подключиться» в каждом из них с одним и тем же токеном; на последнем вы получите статус Disconnected: ALREADY_LOGGED_IN. Именно то, что мы хотели. Пора сесть и расслабиться. 😅

Заключение

В этой статье я описал способ аутентификации подключений к веб-сокетам и предотвращения множественных пользовательских сеансов с помощью Node.js, Socket.IO и Redis. Этот механизм не имеет состояния и работает в кластерной серверной среде.

Чтобы получить еще лучший контроль сеанса и отработку отказа, я предлагаю глубже изучить распределенные блокировки с помощью Redis и прочитать об алгоритме повторной блокировки.

Спасибо, что нашли время прочитать мою статью. Если вам понравилось, нажмите кнопку «Хлопок» несколько раз 👏! Если эта статья была для вас полезной, не стесняйтесь делиться ею!

Чтобы получить больше от меня, не забудьте подписаться на меня в Twitter, здесь, на Medium, или загляните на мой сайт!

использованная литература