Перейти к содержанию

Права участника обращения

Оглавление серии

Комнаты уже разделяют потоки сообщений, однако выбор комнаты ещё открыт любому учебному участнику. Добавим авторизацию: сервер проверит, входит ли идентификатор обращения в разрешённый список участника. Алиса сможет читать и изменять только ticket-17, Борис — только ticket-18, оператор — обе комнаты. Проверка выполняется сервером и не зависит от того, какие значения показывает select.

Авторизация отвечает на вопрос о праве выполнить действие над ресурсом. Аутентификация отвечала бы на вопрос о личности человека, однако в «Линии» настоящая проверка личности не реализована. Мы по-прежнему выбираем вымышленного персонажа в локальной сессии. Седьмой снимок показывает применение прав к установленному сервером контексту; он не превращает учебную кнопку выбора оператора в защищённый вход. Пример not_run.

Право проверяется возле операции

Функция canEnter(actor, roomId) определена в identity.js: она сравнивает комнату со списком actor.rooms. Простая модель подходит для конечного набора учебных обращений. В реальном сервисе разрешение обычно следует из связи пользователя с обращением, назначенного оператора, роли организации и состояния заявки. Важно, чтобы решение получал сервер из доверенных данных, а не из поля, которое пользователь свободно прислал вместе с фразой.

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

import { randomUUID } from 'node:crypto';
import { actors } from './contract.js';

const sessions = new Map();
const lifetime = 60 * 60 * 1000;
export const origin = 'http://127.0.0.1:4310';

export function createDemoSession(actorId) {
  const actor = actors[actorId];
  if (!actor) return null;
  const now = Date.now();
  for (const [key, value] of sessions) {
    if (value.expiresAt <= now) sessions.delete(key);
  }
  if (sessions.size >= 100) return null;
  const id = randomUUID();
  sessions.set(id, { actor, expiresAt: now + lifetime });
  return id;
}

export function readSession(request) {
  const cookies = String(request.headers.cookie || '').split(';');
  const cookie = cookies.map(value => value.trim())
    .find(value => value.startsWith('line_demo='));
  const id = cookie ? cookie.slice('line_demo='.length) : '';
  const session = sessions.get(id);
  if (!session || session.expiresAt <= Date.now()) return null;
  return session;
}

export function canEnter(actor, roomId) {
  return actor.rooms.includes(roomId);
}

В socket.js после разбора команды выполняется следующий фрагмент. Это фрагмент обработчика события message, а не самостоятельная программа: client.actor задаётся при upgrade, command возвращается parseCommand(), а функция canEnter импортирована из показанного файла.

if (!canEnter(client.actor, command.roomId)) {
  throw new Error('room_forbidden');
}

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

Интерфейс следует серверному разрешению

HTTP-маршрут /api/rooms теперь возвращает session.actor.rooms, а не все известные обращения. Алиса видит одну запись, оператор — две. Такое сокращение списка помогает человеку выбрать допустимое действие, но серверная проверка остаётся обязательной. Клиент можно заменить, обработчик select можно изменить, а команда может прийти от программы, которая вообще не использует наш HTML.

Ожидаемый ответ для Алисы имеет форму ниже. Это результат договора, не вывод фактического выполнения.

{"rooms":["ticket-17"]}

Если та же сессия отправляет join для ticket-18, сервер должен вернуть room_forbidden. Подписка при этом не меняется. Если сокет ранее вошёл в первую комнату, он продолжает относиться к первой; отказ другой операции не должен разрушать уже допустимый контекст. Такое правило удобно пользователю: ошибочный выбор не означает внезапный выход из разговора, хотя интерфейс должен явно показать причину отказа.

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

Сессия и изменение прав

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

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

Значение Origin остаётся дополнительной проверкой браузерного источника. Оно не участвует в списке разрешённых обращений и не может заменить canEnter. Аналогично случайная cookie позволяет найти запись сессии, но не является разрешением на все ресурсы. Полезно мысленно удалить каждую проверку по очереди и спросить, какую именно ошибку она предотвращает: чужой источник, отсутствующий контекст или недопустимую комнату.

Представим сторонний клиент, который прислал actorId: "agent" в команде Алисы. Сервер не использует это поле и сохраняет автора alice, потому что берёт его из client.actor. Даже правильный идентификатор чужой комнаты не даёт права на неё. Сочетание этих двух правил важнее запрета какого-то одного «опасного» значения: произвольные дополнительные поля не должны становиться источником доверенной роли или маршрута.

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

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

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