Ссылки с токеном: как сервис школы узнаёт ученика

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

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

Токен подтверждает личность, а не право на продукт

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

Где включить передачу токена

Пункт меню кабинета ученика. В настройках школы откройте раздел «Кабинет ученика» → «Настройки кабинета», перейдите на вкладку «Пункты меню» и в списке пунктов (отдельно для Web и для приложения) выберите пункт со ссылкой на ваш сервис. Включите «Добавить токен доступа» и задайте:

  • Способ передачи токена — «В ссылке после # (GET)» или «Формой (POST)» (см. ниже). У нового пункта по умолчанию выбран GET, пункты, настроенные раньше, продолжают работать через POST.
  • Аудитория (aud) — необязательно. Идентификатор вашего сервиса, до 200 символов. Попадёт в поле aud токена.
  • Данные (data) — необязательно. Любая строка до 2000 символов, например тариф или идентификатор раздела. Попадёт в поле data токена.

Разным пунктам можно задать разные aud и data: каждый сервис получит только свои значения. aud и data попадают в токен, только когда ученик открыл именно этот пункт меню и токен отправляется на адрес, указанный в пункте. Если адрес не совпадает, токен выдаётся без aud и data. Адреса сравниваются без учёта регистра в схеме и имени хоста, порта по умолчанию и части после #; путь и параметры после ? должны совпадать точно.

Ссылка со страницы домена. В разделе «Кабинет ученика» → «Домен приложения» есть генератор ссылки «Перенаправление с токеном авторизации». Введите адрес сервиса (начинается с https://), выберите способ передачи токена (по умолчанию GET) и скопируйте готовую ссылку. Такую ссылку можно разместить где угодно — например, в письме или на странице курса. aud и data здесь не задаются, в токене их не будет.

Страница «Домен приложения» с генератором ссылки и выбором способа передачи токена
Кадр external-token-domain-link: генератор ссылки с выбором способа передачи токена.

Два способа передачи

GET. Ученик попадает на адрес вашего сервиса, к которому добавлен токен после #:

https://service.example.com/auth#jwt=<токен>

Часть адреса после # браузер не отправляет на сервер, поэтому токен не попадает в журналы веб-сервера и прокси. Его читает скрипт на странице сервиса и передаёт на ваш сервер. Если в адресе пункта уже была часть после #, она заменяется на jwt=<токен>.

Сразу после чтения уберите #jwt= из адреса через history.replaceState, как в примере ниже. Иначе токен останется в адресной строке и истории браузера, и его смогут прочитать сторонние скрипты на странице.

POST. На адрес сервиса отправляется форма в формате application/x-www-form-urlencoded, токен — в поле token. Сервер принимает его как обычную отправку формы.

GETPOST
Где токенВ адресе после #, читает скрипт страницыВ теле запроса, читает сервер
Когда выбиратьПо умолчанию; подходит сервисам, которые проверяют заголовок OriginЕсли сервис уже принимает токен формой на сервере
Что нужно на стороне сервисаСкрипт, который прочитает #jwt= и отправит токен на серверОбработчик формы с полем token

Что внутри токена

Токен — это JWT, подписанный алгоритмом ES256. В заголовке: alg = ES256, kid = external-auth.

ПолеТипЕсть всегдаЧто означает
issстрокадаКто выпустил токен — всегда Soho.LMS
subстрокадаИдентификатор ученика в SOHO.LMS, один для всех школ
iatчислодаВремя выпуска, секунды Unix
expчислодаСрок действия: iat + 30 секунд
jtiстрокадаУникальный идентификатор токена (UUID), новый для каждого токена
nameстрокада*Имя ученика
audстроканетАудитория из настроек пункта меню
orgIdчислонетШкола, из кабинета которой открыта ссылка
masterClientIdчислонетИдентификатор ученика как клиента этой школы
telegramIdстроканетTelegram ID ученика из первой привязки Telegram в этой школе
phoneстроканетТелефон ученика: основной или подтверждённый, если такого нет — первый указанный
emailстроканетEmail ученика: основной или подтверждённый, если такого нет — первый указанный
dataстроканетДанные из настроек пункта меню

* Если имя ученика не заполнено, поля name нет.

Что важно учесть при разборе:

  • Необязательного поля без значения в токене нет совсем — оно не приходит как null или пустая строка.
  • telegramId передаётся строкой, чтобы длинный идентификатор не терял точность в JavaScript.
  • orgId, masterClientId и telegramId есть в токене, только если ученик — действующий клиент школы, из кабинета которой открыта ссылка (не удалён из её клиентов); иначе этих полей нет.
  • aud и data требуют того же и, кроме того, приходят, только если ученик открыл пункт меню с этими настройками и адрес перехода совпадает с адресом пункта.
  • aud и data приходят только из актуальных версий приложения: в старых версиях и в ссылках со страницы домена этих полей нет.

Как проверить токен на стороне сервиса

  1. Подпись. Проверьте подпись ES256 открытым ключом с kid = external-auth из набора ключей https://api.soholms.com/.well-known/jwks.json. Набор можно кэшировать; если ключа с нужным kid в кэше нет или подпись не сходится, перечитайте набор.
  2. Издатель. iss должен быть равен Soho.LMS.
  3. Срок. exp ещё не наступил. Токен живёт 30 секунд, поэтому допуск на расхождение часов держите небольшим — несколько секунд.
  4. Аудитория. Если в настройках пункта задана аудитория, требуйте, чтобы aud был равен ей. Отклоняйте другое или отсутствующее значение aud. Например, при несовпадении адреса выбранного пункта aud и data не включаются. Учтите, что aud нет и в токенах из старых версий приложения и из ссылок со страницы домена.
  5. Школа. Для закрытого сервиса одной школы требуйте её ожидаемый orgId и отклоняйте токен без этого поля или с другим значением. aud — произвольная строка из настроек: другая школа может задать такую же. Аудитория не заменяет проверку школы. Для сервиса нескольких школ используйте явно настроенное сопоставление разрешённых orgId.
  6. Одноразовость. jti не должен встречаться раньше. Атомарно отметьте токен использованным и отклоняйте повтор. Храните отметку до окончания срока действия с учётом допуска часов.
  7. Данные. data — строка, которую задала школа. Ученик не может изменить её, не сломав подпись.
Не кладите секреты в data и aud

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

Пример проверки

Ниже — минимальный пример проверки на Node.js с библиотекой jose, а не готовая интеграция сайта. Разработчику нужно реализовать одноразовое использование токена, собственную сессию и проверку доступа к материалам.

import { createRemoteJWKSet, jwtVerify } from 'jose';

const jwks = createRemoteJWKSet(new URL('https://api.soholms.com/.well-known/jwks.json'));

export async function verifyStudentToken(token, usedTokenIds, expectedSchoolId) {
  if (!Number.isInteger(expectedSchoolId) || expectedSchoolId <= 0) {
    throw new Error('Настройте разрешённую школу на сервере');
  }
  const { payload, protectedHeader } = await jwtVerify(token, jwks, {
    algorithms: ['ES256'],
    issuer: 'Soho.LMS',
    audience: 'my-service', // только если вы задали аудиторию в настройках пункта
    clockTolerance: 5,
  });
  if (protectedHeader.kid !== 'external-auth') throw new Error('Неверный ключ токена');
  if (!Number.isFinite(payload.iat) || !Number.isFinite(payload.exp) ||
      typeof payload.sub !== 'string' || !payload.sub.trim() ||
      typeof payload.jti !== 'string' || !payload.jti.trim()) {
    throw new Error('В токене отсутствуют обязательные данные');
  }
  if (payload.orgId !== expectedSchoolId) throw new Error('Токен другой школы');
  // consume атомарно сохраняет jti, только если его ещё нет, до exp + допуска часов.
  if (!await usedTokenIds.consume(payload.jti, payload.exp + 5)) {
    throw new Error('Токен уже использован');
  }
  return payload; // payload.sub — ученик, payload.orgId — школа
}

Для способа GET скрипт на странице сервиса забирает токен из адреса и передаёт его на ваш сервер:

const token = new URLSearchParams(location.hash.slice(1)).get('jwt');
if (token) {
  // Сразу убрать токен из адресной строки и истории браузера
  history.replaceState(null, '', location.pathname + location.search);
  await fetch('/api/soho-login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) });
}

Для способа POST сервер читает поле token из формы и передаёт его в ту же функцию проверки.

expectedSchoolId задаётся серверной конфигурацией, а не запросом ученика. Пример рассчитан на закрытый сервис одной школы; для нескольких школ нужна явно настроенная проверка разрешённых школ. Токен без orgId не подтверждает принадлежность к нужной школе.

usedTokenIds.consume — интерфейс к вашему хранилищу, его реализации в примере нет. Для нескольких серверов используйте общее хранилище с атомарной операцией «сохранить, если отсутствует» и сроком хранения. Обычный набор в памяти одного процесса не защищает от повторного входа на другом сервере и теряет отметки при перезапуске. Простое декодирование JWT без проверки подписи также не подходит.

Если настроили aud, замените my-service своим значением и требуйте его точного совпадения: отсутствующую аудиторию тоже нужно отклонять. Для ссылки со страницы домена aud не передаётся; это отдельный вариант интеграции, а не повод отключать проверку аудитории у настроенного пункта меню.

Как проверять доступ и отзывать его

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

Для этого разработчик может использовать публичный API школы для проверки покупок или обучения, либо настроить HTTP-вызов внешнего сервиса из процесса SOHO.LMS, например при изменении состояния оплаты. Такие вызовы нужно настроить и обработать на стороне сервиса: готового автоматического отзыва его сессии эта ссылка не создаёт. Ключ API школы храните только на сервере; JWT ученика не заменяет этот ключ. При сопоставлении данных учитывайте, что masterClientId относится к клиенту конкретной школы, а sub — к ученику во всей SOHO.LMS.

Токен живёт 30 секунд, но сессия, которую создал ваш сайт после проверки, может жить дольше. Истечение токена само по себе не завершает такую сессию. Сервису нужно ограничить её срок, повторно проверять доступ или обрабатывать настроенные уведомления и закрывать доступ по своим правилам. Прямые открытия сайта должны проходить ту же проверку.

Если сервис проверяет заголовок Origin

При передаче токена формой из приложения заголовок Origin может отсутствовать или иметь значение null. Не используйте его как подтверждение входа: проверяйте JWT. Если сервис не принимает такой POST-запрос, выберите для пункта способ GET и передайте прочитанный фрагмент на сервер сайта.

Коротко о безопасности

  • Токен действует 30 секунд и подходит для одного входа, если сервис отклоняет повтор по jti.
  • aud и data берутся только из настроек школы и попадают только в токен для перехода на адрес этого пункта меню. Подставить свои значения из браузера или приложения ученик не может.
  • При способе GET убирайте #jwt= из адреса сразу после чтения.
  • Подпись проверяется открытым ключом SOHO.LMS; закрытый ключ сервису не нужен.