Содержание урока · 9 разделов
Наш хаб принимает всех: любой человек, знающий адрес, открывает соединение и получает рассылку, а после прошлого урока может ещё и писать личные сообщения от своего имени — вернее, от имени пустой строки в userID. Открытый socket — редкий случай, когда дыру видно сразу, поэтому закрываем её до того, как строить что-то поверх. Хорошая новость: рукопожатие websocket — это обычный HTTP-запрос со специальными заголовками, и к нему применимы все привычные инструменты — заголовки, cookie, middleware и статус-коды. Значит, проверять клиента нужно там же, где вы проверяли обычные HTTP-хендлеры, — до апгрейда. Плохая новость одна, но существенная: браузерный клиент websocket не умеет задавать кастомные заголовки, и привычный Authorization Bearer в рукопожатие не положишь. Отсюда вся специфичная для ws механика этого урока: токен в query, одноразовые тикеты и аккуратная обёртка, которая не даёт соединению открыться до проверки.
Авторизация websocket: проверка на рукопожатии#
Порядок действий в хендлере становится жёстким: вытащить токен из query, проверить подпись и срок, вытащить userID из claims, и только потом вызвать Upgrade. Авторизация websocket до апгрейда означает, что невалидный клиент получает обычный HTTP-ответ с кодом 401 и никогда не становится соединением: ни одного кадра, ни одной записи в карте хаба, ни одной горутины насосов. Проверка после апгрейда создаёт уязвимое окно: соединение уже открыто, клиент успевает подписаться на события или отправить кадр, а затем его отключают — вся информация, которую он успел вытащить, уже утекла. Идентичность, полученную из claims, записываем в поле Client.userID — прошлое задание уже построило на нём маршрутизацию личных сообщений. Саму подпись проверяет jwt.Parse со своим секретом и keyfunc; детали проверки алгоритма и структуры claims мы разбирали в курсе про JWT, здесь возьмём минимальный рабочий вариант.
Обёртка над Upgrade с проверкой токена
GOfunc upgradeAuthorized(w http.ResponseWriter, r *http.Request) (*websocket.Conn, string) {
tokenString := r.URL.Query().Get("token")
claims := jwt.MapClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
return secret, nil
})
if err != nil || !token.Valid {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, ""
}
sub, _ := claims["sub"].(string)
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return nil, ""
}
return conn, sub
}Токен достаётся из query одним вызовом r.URL.Query().Get, проверяется ParseWithClaims, и только после token.Valid выполняется апгрейд. Невалидный токен — обычный http.Error с 401: браузер покажет ошибку рукопожатия, а не загадочный обрыв после подключения. Для краткости опущена проверка алгоритма подписи — она обязательна и разобрана в курсе про JWT; sub из claims становится userID клиента.
Почему токен в query, а не в заголовке#
Браузерный конструктор WebSocket принимает только URL и список подпротоколов — заголовки через него не передаются, и это ограничение API, а не Go. Рабочих обхода три, у каждого своя цена. Первый — токен в query: просто, работает везде, но URL попадает в access-логи серверов и прокси, в историю браузера и в логи мониторинга; утечка из лога делает токен добычей любого, кто читает эти логи. Второй — cookie: браузер прикладывает их к рукопожатию автоматически, токен в URL не светится, но открывает дверь к междоменным атакам, о которых подробно в уроке про Origin. Третий — для небраузерных клиентов ничего не мешает послать обычный заголовок Authorization, потому что wscat и мобильные SDK заголовки умеют. Практическое правило: браузер получает токен через query или cookie, всё остальное — через заголовок, и сервер готов принять его из двух мест.
Кейс: токен в access-логах nginx#
Типичный инцидент: чат работает, а служба безопасности поднимает вопрос — в access-логах прокси полным текстом лежат адреса вида wss://chat.example/ws?token=eyJhbG. Логи уезжают в хранилище на девяносто дней, доступ к ним у половины команды, и токен, который живёт пятнадцать минут, выглядит уже не так безобидно, как в коде. Классический ответ — одноразовый тикет: клиент сначала делает обычный авторизованный POST на эндпоинт, который проверяет настоящий JWT и выдаёт короткую случайную строку с жизнью в тридцать секунд, а затем открывает websocket с тикетом в query. В логи попадает тикет, бесполезный через тридцать секунд и одноразовый по построению. Это два запроса вместо одного и немного состояния на сервере — цена разумная, когда токены долгоживущие и логи чужие. Если же токен живёт минуты и логи изолированы, токен в query остаётся приемлемым компромиссом: идеал не стоит своей цены на каждом проекте.
Соблазн велик: сначала Upgrade, потом проверка и Close — код получается на строку короче. Это окно уязвимости: соединение уже живо, клиент успел зарегистрироваться в хабе и получить первые кадры рассылки, прежде чем его отключат. Проверка всегда первая, апгрейд — последняя строка обёртки; и никакой defer с закрытием не компенсирует кадры, которые уже улетели.
serveWs с userID из claims
GOfunc serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, userID := upgradeAuthorized(w, r)
if conn == nil {
return
}
client := &Client{
hub: hub,
conn: conn,
userID: userID,
send: make(chan *Envelope, 256),
}
hub.register <- client
go client.writePump()
client.readPump()
}Обёртка вернула соединение и userID — или нулевой указатель, если рукопожатие отклонено, и хендлер просто выходит. Дальше всё как в девятом уроке, с одним отличием: Client рождается уже с userID, и хаб с первой минуты знает, чьи кадры читает и кому направлять личные сообщения. Анонимные подключения в эту схему не входят — для них нужен отдельный эндпоинт с гостевыми идентификаторами.
Соединение живёт дольше токена#
Подключённый в 12:59 клиент с токеном до 13:15 спокойно держит соединение в 15:00 — и это правильно, а не дыра. Проверка подписи — операция на рукопожатии; после неё сессия живёт своей жизнью, и сервер не обязан перечитывать exp на каждом кадре. У этого правила есть граница, и она стоит того, чтобы её помнить: если токен отзывают — пользователь сменил пароль, ключ скомпрометирован, — соединение продолжит жить до первого разрыва, потому что сервер больше не сверяется со списком отозванных. Для чатов это обычно приемлемо: сессия закончится вместе с соединением, а новые будут проверены заново. Для чувствительных систем в ветке проверок добавляют периодическую ревалидацию или короткий pongWait, после которого клиент переподключается с свежим токеном. Механика переподключения с backoff — тема семнадцатого урока, а отзыв токенов целиком разбирается в материале про JWT.
Обёртка в конвейере: middleware вместо дублирования#
Как только ws-эндпоинтов становится два, копипаста проверки начинает пахнуть. Решение то же, что для обычного HTTP: middleware, который достаёт токен, проверяет его и передаёт userID дальше через контекст запроса. В net/http это обёртка-функция: она возвращает 401 при провале, а при успехе кладёт идентификатор в контекст и вызывает следующий хендлер — тот достаёт userID оттуда и не знает про jwt ничего. В роутерах типа chi мидлварь вешается на группу маршрутов одной строкой, и ws-эндпоинт наследует проверку автоматически, как и обычные JSON-эндпоинты. Один нюанс стоит знать заранее: Upgrade занимает хендлер надолго — соединение живёт в теле хендлера, поэтому мидлварь обязана завершиться до вызова readPump. Всё, что middleware делает после вызова next — логирование статуса, доступ к ResponseWriter, — с ws-соединением уже не работает: рукопожатие съедено.
Не всякий socket — человек в браузере: свои соединения открывают сервисы-подписчики, мобильные SDK и внутренние воркеры. Для них та же проверка, но токен другой: сервисный, с собственным claim о роли и сроке, который выпускает ваша система конфигурации. Отличать гостей от людей удобно прямо в claims: поле роли едет вместе с идентификатором, и Client получает третье поле kind, по которому хаб решает, что этому типу можно. Сервисному соединению можно читать события всех комнат, гостевому — только свою; человек получает полные права по своей роли. Важная гигиена: не заводите для внутренних подписчиков отдельный незащищённый порт с пометкой «только внутри» — внутренние сети давно не считаются безопасными, и рано или поздно порт найдёт сканер. Один эндпоинт, один механизм проверки, различие в claims — меньше движущихся частей, чем отдельные правила на каждый тип клиента.
Куда положить токен для рукопожатия#
| Способ | Браузер | Небраузерный клиент | Главная цена |
|---|---|---|---|
| Query-параметр | да, без вариантов | да | токен попадает в логи и историю |
| Cookie | да, автоматически | нужно вручную | междоменное рукопожатие, урок про Origin |
| Заголовок Authorization | нет: API не умеет | да, естественный путь | не годится как единственный способ |
| Одноразовый тикет | да, два запроса | да | лишний эндпоинт и состояние |
Проверка: заголовок или query
Почему токен для браузерного websocket кладут в query, а не в Authorization?
Проверка: сессия против exp
Токен был выдан с exp через пятнадцать минут, клиент держит соединение три часа. Что корректно?
Что увидит браузер при 401
Прочитайте код и выберите, что он выведет:
// сервер: upgradeAuthorized вернул 401,
// соединение не открыто
const ws = new WebSocket("wss://chat.example/ws?token=bad")Соберите обёртку и запустите сервер на локальном Go: вкладка с валидным токеном в query должна подключиться, а вкладка с испорченным — получить ошибку рукопожатия в консоли браузера. Затем уберите проверку из обёртки и посмотрите, как тот же испорченный токен открывает соединение и начинает получать рассылку.
Соберите начало обёртки
Впишите четыре пропуска: путь к query, имя параметра, проверка валидности токена и HTTP-статус отказа.
Соберите порядок проверки
Расставьте строки обёртки в правильном порядке: извлечение токена, проверка подписи, отказ и только потом успех.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: авторизация ws
Теперь у хаба есть идентичность: Client рождается с userID, и маршрутизация из прошлого урока обрела источник правды. Оставшийся зазор в защите рукопожатия — источник запроса: токен может быть валидным, а запрос — чужим, поэтому следующий урок — Origin и междоменные соединения. Для проверки подписи, структуры claims и отзыва мы использовали минимум; полный набор приёмов — в курсе JWT в Go, особенно материал про отзыв токенов. Напоминание про клиентскую часть и то, как выглядит неудачное рукопожатие, — в пятом уроке про браузерный клиент, а пары насосов — в девятом. Проверяйте оба сценария локально — валидный и испорченный токен — и держите проверку первой строкой обёртки. План курса целиком — на странице курса WebSocket.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Авторизация websocket живёт на рукопожатии: токен проверяется до Upgrade, иначе появляется окно утечки кадров.
- Токен достаётся из query: r.URL.Query().Get("token") — потому что браузерный API не задаёт заголовки.
- Невалидный токен — обычный http.Error с 401: браузер видит ошибку рукопожатия, соединение не открывается.
- UserID из claims записывается в Client и питает маршрутизацию личных сообщений из прошлого урока.
- Соединение живёт дольше exp: проверка одноразовая, граница правила — отзыв токена.
- Токен в query виден в логах: долгоживущие токены — повод для одноразовых тикетов.
Домашнее задание
Реализуйте upgradeAuthorized с проверкой токена и запретом апгрейда при ошибке, затем одноразовый тикет: POST-эндпоинт принимает валидный JWT, выдаёт строку с жизнью в тридцать секунд и хранит её в карте с TTL, а обёртка принимает только тикет. Проверьте локально: тикет работает один раз, второй раз даёт 401, а в логах вместо JWT видна короткая строка.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли авторизовать соединение первым сообщением после подключения?
Технически да, но до проверки клиент участвует в рассылке: он получает кадры, занимает место в карте и может слать мусор. Проверка на рукопожатии дешевле и строже: чужой отклоняется HTTP-статусом и не создаёт вообще ничего.
Что делать с анонимными посетителями?
Отдельный эндпоинт или гостевой тикет: сервер выдаёт временный идентификатор и помечает соединение как гостевое, а хаб решает, что гостям можно — обычно только чтение своей комнаты. Главное, чтобы это было осознанное решение, а не совпадение открытого эндпоинта.
Насколько страшен токен в query на самом деле?
Зависит от двух вещей: срока жизни токена и того, кто читает логи. Минутный токен в изолированных логах — приемлемый компромисс; суточный в логах, доступных половине компании, — повод внедрить одноразовые тикеты. Решение принимает цена утечки, а не эстетика.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами