Аутентификация для WebSocket и SSE: до сих пор нет стандарта?

Kate

Administrator
Команда форума

Почему аутентификация​

Под аутентификацией мы обычно привыкли понимать процесс проверки личности (т. н. identity) субъекта, такого как пользователь, система или какая‑либо сущность, для обеспечения уверенности, что этот субъект является тем, кем себя называет.

Однако здесь затронем другую сторону данного термина, другой его аспект. Есть также концепт «аутентификации запроса», который направлен на проверку конкретного запроса, нежели чем личности субъекта.

13236d5df05a9a72803bfac3e2cbbf66.png

Когда субъект, например, пользователь, предоставляет свои «изначальные credentials», такие как логин и пароль, система проверяет их и в ответ, как правило, выпускаются некие «временные credentials» — будем называть их «токен». Он и служит доказательством пройденной ранее аутентификации и используется в том числе для аутентификации и авторизации дальнейших запросов уже без необходимости передачи тех самых «изначальных credentials». Временные учетные данные — это, как видно из названия, учетные данные, имеющие ограниченный по времени период валидности. Выпускаемый токен как раз ими и является и может представлять собой access token (в случае OAuth 2.0) или просто некий Session ID. Таким образом мы привыкли повышать безопасность за счет минимизация передачи долгоживущих изначальных учетных данных.

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

Проблематика​

Начнем с WebSocket. Протокол был придумал еще в 00-х и как стандарт зафиксирован в RFC 6455 в 2011 году. Однако тот же RFC явно так и говорит: протокол не предписывает, как именно должна выполняться аутентификация WebSocket‑клиентов.

3ac34990ff7c074c4de7bd9a0d308102.png

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

  • Для мобильных, десктоп и серверных клиентов все проще: варианты есть, они работают
  • А вот если клиентская часть у нас находится в браузере, то есть особенности и нюансы
В статье поговорим не просто про то, «как надо» или «как не надо» делать, а рассмотрим основные особенности и варианты реализаций, которые из них вытекают, чтобы понять, какие и где могут быть применимы.

Как устроен WebSocket​

Вкратце вспомним, как вообще работает WebSocket. Работа протокола WebSocket начинается с HTTP‑запроса, так называемой стадии «Handshake». Клиент отправляет специальный HTTP Upgrade‑запрос, сервер отвечает ему специальным HTTP Upgrade‑ответом с кодом 101 и после имеющееся HTTP‑соединение «трансформируется» в WebSocket‑соединение. А далее уже внутри самого WebSocket‑соединения как клиент, так и сервер могут отправлять друг другу сообщения.

129b1505789896d6c1158e6e197ac4a5.png

Важное отличие здесь в том, что отправляемые в рамках соединения сообщения не содержат в себе HTTP-заголовков. Таким образом мы снижаем overhead, так как не передаем лишние данные, но этот момент может породить ряд вопросов и неоднозначностей там, где мы привыкли на заголовки полагаться.

Аутентификация в WebSocket: основные части​

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

6feaa6ba5c34e49f7903c0f17c06eddd.jpg

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

Особенности для веб-клиентов​

Здесь начинается первый нюанс. WebSocket-клиент мы можем реализовать по-разному и в разных местах. Если говорить конкретно про браузер, то в нем WebSocket-интерфейс нам предоставляет JavaScript. Есть важное ограничение: JavaScript не позволяет нам отправлять свои заголовки с первым HTTP-запросом. И в отличие от других платформ в данном случае у нас нет возможности опуститься на уровень ниже и определить самим, как и что будет отправляться в первом запросе: мы используем только верхнеуровневый интерфейс. Как видно на скриншоте ниже, у нас есть только обязательный параметр url и необязательный protocols. И все.

755e19e232aaf6861bbd10fd9e4ba270.png

Но тем не менее, аутентифицировать запрос все же как-то нужно.

Установка WebSocket-соединения​

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

Тогда можем выделить несколько способов, где это вообще теоретически возможно сделать:

18ff05d9e441d44dc9e8edd222819e22.png

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

  • Аутентифицируем и авторизуем каждое сообщение
  • Аутентифицируем и авторизуем само соединение
В рамках второй группы также разделим способы на основе того, должен ли быть токен доступен с клиентской части, иными словами должен ли клиентский JavaScript быть способен токен увидеть, пощупать его, что-то с ним сделать.

Для подгруппы, где токен должен быть доступен с клиента, список опций на самом деле сразу можно сократить еще:

  • Заголовок Sec-WebSocket-Protocol. В WebSocket есть такое понятие как «subprotocol». Поверх WebSocket в рамках сообщений могут быть использованы другие протоколы, т. н. subprotocols, такие как STOMP, AMQP, MQTT, SOAP или какие‑либо кастомные. Заголовок Sec‑WebSocket‑Protocol определяет протоколы, которые клиент намеревается использовать. Сервер может проверить этот заголовок и убедиться, что он такие протоколы готов поддержать.
    Cуть в том, что если мы хотим передавать в этом заголовке токен, то надо понимать, что это определенный «хак» протокола и стандарта. И если для своего пет‑проекта это может быть подходяще, то когда мы делаем надежное production‑ready решение, то обычно в таких случаях задумываемся: а точно ли мы хотим сделать так, точно ли задача должна решаться именно так, нужна ли точно это нам? Вследствие этого именно как одну из хороших практик такой подход рекомендовать не могу.
  • Часть userinfo в URL.
    Что это вообще такое
    65cbdc389ade31ffe30d2b296dec80a3.png

    Если лет 10 назад такой подход еще мог работать, то сейчас современные браузеры не обеспечивают стабильной работы для URL с «embedded credentials» и такой подход считается в целом deprecated[1][2], поэтому полагаться на него уже нельзя.
Так получаем сокращенный перечень вариантов к рассмотрению:

e79e80753e4c59fa1f67bd58c07224ab.png

Где передавать токен вы (скорее всего) не будете​

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

В заголовке Sec-WebSocket-Key​

Заголовок служит для передачи серверу информации о том, что клиент собирается выполнить upgrade до WebSocket. Данное значение вычисляется и отправляется вебсокет-клиентом, который в случае браузера уже реализован. В ответ сервер посылает заголовок Sec-WebSocket-Accept, который вычисляется на основе значения Sec-WebSocket-Key и говорит о том, что и сервер готов поддержать upgrade. Также это помогает предостеречь случайное кэширование на прокси-серверах: чтобы в ответ на следующие запросы прокси-сервер не отдавал бесполезный кэшированный ответ с кодом 101.

4a91b6d843a22c2835dec6e37092fdb1.png

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

В отдельном HTTP-заголовке​

Как было отмечено выше, если вашими WebSocket-клиентами могут быть только мобильные, десктоп- или серверные клиенты, то передача токена в условном заголовке Authorization в HTTP Upgrade-запросе решит вопрос аутентификации запроса для вас. Однако, если клиентами могут быть и браузерные приложения, то не стоит полагаться на то, что этот заголовок будет передан. Не будет. И реализовав решение, где будет поддержан только такой способ аутентификации, то на моменте подключения клиентов из Web можно неожиданно получить серьезные проблемы.

С использованием TLS-сертификата​

Известно, что обязать пользователей продукта устанавливать к себе специальные TLS‑сертификаты — увы, нерешаемая в текущих реалиях задача. И даже если мы имеем конкретную группу внутренних пользователей, то да, можно обеспечить установку сертификатов на рабочие места, но использоваться они будут скорее не для того чтобы выполнить аутентификацию запроса, а для реализации подходов вроде Mutual-TLS Client Authentication and Certificate-Bound Access Tokens

1. Передача токена в каждом сообщении​

Возвращаемся к анализу способов, которые как раз могут быть применимы.

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

Когда мы начинаем проверять каждое сообщение, latency у нас закономерно идет вверх. Мы получаем дополнительные издержки: нужно будет либо обращаться куда‑то за проверкой токена при каждом получении сообщения, что увеличит в том числе нагрузку на сервис, проверяющий токен, либо, если нам покажется хорошей идеей передавать токен в формате JWT с клиента (дискуссию оставим за рамками статьи), то проверка подписи публичным ключом тоже может стать узким местом при большой нагрузке. Пример с алгоритмом ECDSA на основе эллиптических кривых.

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

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

2. Передача токена в первом сообщении​

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

a9358be67b4f1632fe566b76fef18873.png

Достоинства:

  • Простота исполнения
Недостатки:

  • Необходимость явно разделять на стороне сервера соединения с аутентифицированным состоянием и состоянием ожидающим аутентификации
  • Безусловное открытие неаутентифицированного соединения внутрь, полагаясь только на дальнейшую проверку в приложении за рамками протокола. В ряде систем это может противоречить требованиям ИБ
  • Из-за безусловного открытия соединения есть риск эксплуатации отказа в обслуживании (denial of service, DOS), например, если злоумышленник займет все доступные сокеты на сервере

3. Использование cookies​

В данном подходе воспользуемся той особенностью, что WebSocket начинается с HTTP-запроса, а значит можем использовать все те возможности, что протокол HTTP дает нам.
Хоть мы с помощью JavaScript и не можем добавлять свои заголовки, зато браузер по-прежнему может это сделать, в частности добавив заголовок Cookie, в котором и передаст cookie из браузера серверу.

c6b26ddb5c7e563dbbdeee51ca21d61e.png

Рассмотрим пример детальнее. На рисунке ниже в блоке [1] после успешного прохождения аутентификации мы с сервера хотим в ответе вернуть браузеру cookie с токеном, используя стандартный заголовок Set-Cookie. У cookie при этом есть определенные атрибуты, внимание здесь уделим атрибуту Domain: если в ответе от сервера у cookie данный атрибут никак явно задан не будет, то такая cookie будет считаться host‑only cookie, то есть она будет отправляться затем браузером только в запросах к точно тому же самому домену, с которого была установлена, не включая (!) его поддомены.

13edb57f0157b277037747f866801198.jpg

В блоке [2] показано, что в случае отправки запроса на установку WebSocket‑соединения с site.com на site.com браузер передаст в запросе установленные ранее cookie. Однако в случае отправки запроса к ws.site.com cookie уже отправлены не будут. В то же время встречаются случаи, когда сервера для WebSocket‑соединений мы намеренно выносим на другие поддомены или разделяем по инфраструктуре.

Если нам важно иметь возможность отправлять cookie в запросах именно к поддоменам, то можно внести следующие изменения в схему. В этот раз мы явно задаем значение атрибута Domain у cookie в блоке [1] равное «.site.com» или просто «site.com» — лидирующая точка необязательна — тем самым делаю нашу cookie уже не host‑only, а wildcard. Теперь cookie будет отправляться в запросах не только к самому site.com, но и ко всем его поддоменам.

9ceaaa3265b4e8fe2441ec1e3a2f6295.jpg

Во [2] блоке рисунка видно, что при отправке запроса с site.com к ws.site.com cookie будет успешно отправлена браузером.

Однако при этом мы идем на определенный компромисс с информационной безопасностью. Поскольку в данном случае мы расширяем область «видимости» для cookie с токеном и делаем ее доступной в том числе и при отправке запросов к поддоменам, повышается риск утечки токена, например при обнаружении уязвимости на одном из поддоменов или при атаке subdomain takeover, о чем уже упоминал в статье «Все еще работаете с access token на клиенте? Тогда мы идем к вам». Снизить этот риск может помочь, например, регулярный аудит поддоменов, однако применяется такая практика, увы, не везде.

Также если необходима отправка запроса вообще на другой домен того же самого уровня, то cookie в современных браузерах нам такого безопасного механизма не дают. Тогда для решения подобной задачи возможно использование некоего Edge‑proxy — компонента, который мы размещаем «с краю» и задачей которого будет на том же самом домене или его поддомене принимать запрос с cookie, удовлетворяя требованиям всех защитных механизмов браузера, и далее перенаправлять его вверх (upstream) на другой хост, что также показано в блоке [2].

Cross-Site WebSocket Hijacking (CSWSH)​

Когда мы произносим вместе слова «cookie» и «аутентификация» или «токен», то помним, что услышав подобную комбинацию слов, полезно подумать про защиту от CSRF. Для WebSocket существует аналогичная уязвимость, называемая Cross‑Site WebSocket Hijacking или же CSWSH.

Чтобы можно было точнее понять суть ее работы, сперва вспомним следующие понятия:

  • Site - совокупность eTLD+1 и схемы*
  • Origin - совокупность схемы, хоста порта
0aac2f1674492520ce7a868dad4776dc.jpg
*Исключение с ws:// и wss:// схемами
Исключение составляют запросы со схемами ws:// и wss://. На самом деле, это и позволило в примерах выше отправлять cookie в запросах на установку соединения. В таком случае следующие запросы будут считаться Same-Site:
  • wss:// соединение, устанавливаемое с https:// страницы
  • ws:// соединение, устанавливаемое с http:// страницы
The request's URL when establishing a WebSockets connection has scheme “http” or “https”, rather than “ws” or “wss”. FETCH maps schemes when constructing the request. This mapping allows same‑site cookies to be sent with WebSockets.
(c) Incrementally Better Cookies
А вот с тем как данная уязвимость может быть проэксплуатирована, есть есть нюанс. Дело в том, что большинство статей по этой теме не до конца раскрывают возможности воспроизведения данной уязвимости, и если попробовать воспроизвести ее «как есть», то скорее всего работать это не будет.
Подают описание обычно следующим образом. У нас есть легитимный сервис chat.com, который использует WebSocket. Пользователь заходит на сайт, проходит аутентификацию, у него в браузере появляется cookie с неким токеном на домене chat.com. Теперь в дело вступает злоумышленник, который создает свой зловредный ресурс malicious.com и заманивает туда пользователя (например, отправив ссылку с предложением посмотреть на милых котят). Пользователь в том же сеансе браузера открывает malicious.com, после чего на странице выполняется скрипт злоумышленника, который «от лица пользователя» отправляет запрос на установку WebSocket‑соединения с malicious.com на chat.com, в котором якобы будет передана существующая cookie, поскольку она уже есть в браузере для домена chat.com. Соединение тогда будет установлено, и злоумышленник якобы сможет в рамках него получить какие‑то данные с сервера или же наоборот отправить их.
Объясняется это, как правило, тем, что существующий в браузерах защитный механизм Same-Origin Policy (SOP), который в том числе не позволяет на большинстве современных сайтов просто так проэксплуатировать CSRF вышеописанным образом, не работает для WebSocket. И это является правдой, так уж исторически сложилось.
Подвох в том, что на самом деле воспроизвести уязвимость несколько сложнее. Для реальной эксплуатации требуется следующее одновременное сочетание условий:
  1. Наличие атрибута SameSite=None у cookie или обход (bypass) SameSite
  2. Отсутствие проверки Origin или защиты от CSRF
Только имея оба этих условия выполненными вы сможете воспроизвести уязвимость, поскольку политика SameSite не даст просто так браузеру отправить любую cookie на другой домен.
Задавать SameSite в None для cookie с токеном и раньше было (простите, за каламбур) нонсенсом: в таком случае cookie отправлялась бы в запросах с любых других Sites. Также наличию таких cookie не способствует применяемая в Chrome политика по трактовке пустого значения SameSite как Lax по умолчанию (правда, пока не все браузеры реализовали подобное). В ближайшем будущем крышку в гроб подобного подхода должно вбить планируемое у того же Chrome изменение по прекращению поддержки third‑party cookies, то есть как раз cookie с подобным значением атрибута SameSite.
Интересно, что даже лабораторная у PortSwigger по CSWSH сделана в тех же тепличных условиях — с сессионной cookie, у которой выставлен SameSite=None. Причем явно об этом не пишут.
72dd67eb006a9b6233772deb0e31f3cb.png

Тем не менее, хоть и пишу, что уязвимость так легко не воспроизводится, защита от нее все равно нужна. Доказывает это, например, прошлогодняя история с раскруткой CSWSH до RCE в Gitpod. SameSite исследователи обошли как раз тем, что все сервисы находились на поддоменах gitpod.io:
Earlier, we observed that the workspace was exposed via a subdomain on gitpod.io. In the context of SameSite, the workspace URL is considered to be the same site as gitpod.io. So, it should be possible for one workspace to issue a cross-domain SameSite request to a *.gitpod.io domain with the original user’s cookies attached.
Один из самых простых способов защиты здесь — это проверка Origin. Как минимум проверяя Origin входящих запросов по белому списку разрешенных, по аналогии с тем, как мы делаем для CORS, можно снизить риск эксплуатации подобной уязвимости в вашем сервисе. Таким образом вы сможете продолжать обрабатывать запросы с разрешенных Origin, а запросы с тех, которые не являются разрешенными, сразу «отбивать» с ошибкой.
Возвращаемся к резюме для способа использования cookies.
Достоинства:
  • Обычно не требует изменений существующих схем работы для HTTP-API
  • Отдаем работу с токеном браузеру и протоколу HTTP
  • Токен не требуется делать доступным с клиента
Недостатки:
  • Может требовать установки wildcard-cookie, что имеет дополнительные риски
  • Сложности в реализации при кросс-доменных взаимодействиях

4. URL. Query-параметр​

Также мы можем подумать о том, чтобы передать токен в качестве одного из query-параметров запроса.
a99f4cb9b8a3fd9b0bff8d0e7d6fe694.png

Логично подумать, что при использовании схемы wss:// мы имеем WebSocket over TLS. TLS, как известно, шифрует все, кроме хоста и порта, а значит и использование query‑параметра нам подойдет.
У такого подхода есть ряд недостатков, наиболее существенный из которых в том, что query-параметры обычно логируются на промежуточных серверах как части запроса, и если риск попадания токена в логи недопустим, то с таким вариантом возникают сложности. Для решения данной проблемы существует следующая схема, которая основывается на разделении сессионного токена и отдельного токена для установки WebSocket-соединения, который здесь назовем Ticket.
Идея в том, что сперва мы выполняем обыкновенный HTTP‑запрос, в котором передаем любым образом изначальный токен. В ответ от сервера при этом получаем Ticket прямо в теле ответа, что позволяет извлечь его на клиенте и отправить в последующем запросе на установку WebSocket‑соединения в качестве query‑параметра.
bc031e81b300b326559e2885021bce6d.png

Ticket в данном случае имеет смысл сделать одноразовым, короткоживущим и даже привязанным к IP: при получении HTTP Upgrade запроса мы можем сверить IP‑адрес, с которого был получен запрос, с IP‑адресом, с которого был получен сам Ticket, тем самым добавив еще некоторую защиту от использования из другой сети при его утечке. Все эти меры направлены на снижение рисков при утечке Ticket, затрудняя таким образом его использование в нелегитимных случаях. Формат Ticket в данном случае зависит от конкретных требований, в том числе и требований к информационной безопасности, и может быть различным. Это может быть JWT, который будет self‑contained, тогда необходимость в его хранении отпадает. Либо это может быть stateful‑вариант, при котором Ticket мы сохраняем где‑либо на сервере в быстром хранилище на короткое время, и WebSocket‑сервер делает а‑ля introspect‑запрос с целью проверить валидность данного Ticket.
К примеру, в случае с использованием authorization code grant flow для получения токена и последующим получением stateful-Ticket это может выглядеть как-то так:
2e658987b2761a92adb93c5c56a47c0f.jpg

Достоинства:
  • Возможно любое кросс‑доменное взаимодействие за счет использования query‑параметра
  • WS‑соединение можно установить только по одноразовому короткоживущему Ticket
Недостатки:
  • Решение получается сложным и требующим доработок в нескольких местах системы, что усложняет решение и может сделать его применение нецелесообразным
Таким образом, комплексно рассмотрели различные способы, с помощью которых можно аутентифицировать запрос на установку соединения, и их ключевые особенности. Важно помнить, что единого подхода на все случаи жизни нет, и выбор способа под конкретное решение - одна из задач, которую стоит решить при проектировании.

Про Server-Sent Events (SSE)​

Надо сказать, что аналогичную историю с ограничением возможности передачи токена из браузера имеет и технология SSE. Server‑Sent Events предназначена для возможности односторонней отправки сообщений с сервера на клиент. Технология работает поверх HTTP и по факту представляет собой долгоживущий HTTP‑запрос со специальным Content‑Type: text/event-stream . Серверная сторона при этом может отправлять сообщения в рамках создаваемого «стрима»,а клиент получать их, реализовав на своей стороны обработчик событий (EventListener.)
Но с передачей заголовков в JS‑интерфейсе есть проблема, аналогичная вебсокетам: предоставляемый интерфейс не позволяет управлять заголовками отправляемого запроса. «Хорошая» новость в том, что в случае SSE для задачи установки соединения могут быть применены все те же самые подходы, которые были рассмотрены выше для WebSocket, за исключением вариантов с передачей в сообщении с клиента.

Казалось бы, WebSocket и SSE существуют не первый год десяток лет, почему бы не добавить в браузере возможность управления заголовками при отправке запроса, тем более что технически со стороны стандартов к этому нет ограничений?
Один из возможных ответов на этот вопрос можно найти в обсуждении одного интересного Issue на GitHub:
2de4da7b2c796b336602e29f8e35738c.png

Здесь некто, представляющийся членом команды, отвечающей за интерфейс EventSource для Chrome, еще в 2017 писал, что развития данного интерфейса ожидать не стоит. В целом посыл ряда участников в том, что API для WebSocket и SSE еще более 5 назад они считали «legacy»и устаревшими.
Также упоминается, что, возможно, целевое будущее решение задачи для асинхронного обмена данными между браузером и веб-сервером уже не в них, а в другом, например, в стриминге через Fetch API или развивающемся WebTransport API, основанном на HTTP/3.

Контроль валидности соединения и его терминация​

Как мы помним, выше была рассмотрена только часть вопроса, касающаяся установки соединения. Следующий блок так же применим и для использования SSE, но рассмотрен будет на единственном на примере для WebSocket.
Вернемся к верхнеуровневой схеме: мы получаем HTTP Upgrade‑запрос, производим его аутентификацию, авторизуем установку соединения и наконец устанавливаем его, здесь все понятно. После этого проходит 5 минут. И мы внезапно задаемся вопросом: а что теперь с соединением? Проверка токена была 5 минут назад, а валиден ли он до сих пор? Имеем ли мы еще право принимать сообщения от клиента и отправлять их ему?
35913c945b796edab33b5479258e547c.png

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

Shared Signals Framework (SSF)​

Интересно отметить, что среди спецификаций OpenID Foundation, которые продолжают пополняться, есть одна, которая как будто хорошо подходит для решения вышеописанной задачи: Shared Signals and Events Framework.
Это разрабатываемый стандарт, который нацелен на унификацию формата интеграций для обмена сообщениями о событиях, связанных с сессией субъекта или самим субъектом в контексте безопасности. По задумке авторов он должен помочь реализовывать комплексные решения, упрощая зоопарк интеграций, где каждый вендор может предоставлять (или не предоставлять) свои API для отправки/получения сообщений о подобных событиях.
И идея действительно неплохая, но стоит подумать, насколько на текущий момент она может быть применима. Сейчас этот стандарт еще находится в стадии Implementer’s Draft. Вендоры и решения его не пока поддерживают. Имплементаций в интернете - кот наплакал, в основном не боевые, а уровня PoC. Следовательно, если его и реализовывать, то придется это делать самим (дорабатывая свои решения и стараясь быть standard-compliant). По сути это будет иметь смысл, когда популярные вендоры и решения станут его поддерживать: как, например, сейчас поддерживают OIDC. Но сейчас пока это может оказаться инвестицией в еще туманное будущее. Хотя те же Okta, например, пишут, что уже начали применять в действии.
Но вот что выглядит куда интереснее - так это изучить концепцию, которую предлагают авторы. Подумать, в чем цель, какие события там есть и почему, какие данные предполагается в них передавать и зачем. Это может помочь как сделать вам более качественное решение, так и шагнуть в сторону поддержки стандарта в будущем, потом разработав вокруг него нужные правильные контракты, например. Так что пока можно почитать саму спецификацию или поиграться с событиями на демо-стенде.

Что еще учесть при реализации сообщений о событиях​

  • Связь соединения с событием.
    Когда WebSocket‑сервер получает сообщение о событии, он должен понимать, какие соединения ему нужно разорвать. Здесь, с одной стороны, можно положиться на идентификатор пользователя, но часто один пользователь может иметь несколько параллельных сессий, например, на разных устройствах. Одним из возможных решений вижу вариант опереться на идентификатор токена (не сам токен), по которому соединение было установлено. Например, в случае OAuth в ходе интроспекции мы получаем от Authorization server набор данных, среди которых будет и идентификатор access token. Его мы сохраняем вместе с другими метаданными соединения. Сообщение о событии, которое мы ожидаем получить, должно также содержать данный идентификатор, что позволяет найти все соединения, которые были по данному токену установлены (а их может быть тоже несколько) и разорвать их.
  • Требования к доставке и гарантии.
    Независимо от способа отправки сообщений важно определить, какие требования к доставке должны быть применимы и какие вам важны гарантии в данном случае. То есть подумать о механизме повторных отправок, возможной необходимости outbox для таких сообщений, и других способах не терять неконтролируемо сообщения на всем тракте передачи.
  • Обработка non‑retriable ошибок.
    Может быть полезно подумать, нужно ли вам обрабатывать недоставленные сообщения, которые не были доставлены из‑за ошибок, которые не решаются повторной отправкой. Если нужно, то как вы будете на них реагировать, нужен ли мониторинг и возможность переотправки таких сообщений после исправления ошибки (пусть хотя бы ручная).
  • Ограничение по максимальному времени жизни WebSocket‑соединения.
    Также вне зависимости от способов реализации остальных задач, очень важно иметь конкретный верхний порог по времени жизни для соединения, по достижении которого оно должно быть терминировано безусловно. Во‑первых, таким образом у нас появляется последняя гарантия, что даже если по какой‑то причине мы не определим, что соединение нужно разорвать, то по крайней мере по таймауту соединение будет терминировано, и клиент будет вынужден произвести повторную аутентификацию. Во‑вторых, эта величина влияет на работу механизма повторной отправки и срок хранения недоставленных сообщений: если мы знаем, что любое соединение может жить, например, максимум 30 минут, то дольше этого времени хранить недоставленные сообщения смысла не будет: даже если сообщение будет доставлено после, соответствующее ему соединение уже будет разорвано.

Заключение​

Комплексная задача аутентификации для WebSocket и/или SSE может быть рассмотрена как набор трех блоков, которые можно проработать по-отдельности:
  1. Аутентификация пользователя и получение токена
  2. Установка соединения
  3. Контроль валидности соединения и его терминация
Учитывая при проектировании все три блока можно создать комплексное решение, покрывающее задачу end-to-end, не потеряв при этом частей процесса и не оставив белых пятен.
Важно помнить, что возможности клиентских частей на разных платформах отличаются, и порой, чтобы решение действительно было рабочим, нужно учитывать и их, поскольку на разрабатываемые решения влияют особенности не только серверных, но и клиентских частей.

 
Сверху