JWT-авторизация в HTTP-сервисе 1С: выдача и проверка токена доступа

Если у вас есть HTTP-сервис 1С, который отдаёт данные наружу, рано или поздно приходит вопрос: как его закрыть. Basic-авторизация с логином и паролем в каждом запросе — прошлый век: пароль летает по сети постоянно, отозвать доступ одному клиенту нельзя, срок жизни у него бесконечный. Нормальный ответ здесь — JWT-авторизация в 1С: клиент один раз меняет учётку на подписанный токен доступа с ограниченным сроком жизни и дальше носит его в заголовке Authorization. Хорошая новость: с версии платформы 8.3.21 для этого не нужны внешние библиотеки — 1С умеет выпускать и подписывать JWT штатным объектом ТокенДоступа. Разберу на реальном коде, как в 1С сделать и выдачу токена, и — то, что обычно забывают показать, — его проверку на входящем запросе.

Статья собрана из учебных конфигураций: базовой на режиме совместимости 8.3.21 и пары баз «JWT Аутентификация», где два независимых сервиса 1С доверяют друг другу по общему ключу подписи.

Что такое JWT и почему он удобен для 1С

JWT (JSON Web Token) — это строка из трёх частей, разделённых точками: заголовок.нагрузка.подпись. Первые две части — это обычный JSON, закодированный в Base64URL (то есть их может прочитать кто угодно, это не шифрование). Третья часть — подпись: результат HMAC-SHA256 от «заголовок.нагрузка» с секретным ключом, который знает только сервер.

Смысл в том, что сервер не хранит выданные токены. Он их не помнит — он их проверяет. Пришёл запрос с токеном, сервер пересчитал подпись своим ключом, сравнил с той, что в токене: сошлось — токен наш, не сошлось или просрочен — 401. Никакой таблицы сессий, никаких обращений к базе на каждый запрос. Для HTTP-сервиса 1С, который может обслуживать интернет-магазин или мобильное приложение, это заметно проще и дешевле, чем сессионная авторизация.

Что кладут в нагрузку (стандартные поля):

  • iss (issuer) — кто выпустил токен;
  • sub (subject) — для кого он, обычно имя пользователя;
  • aud (audience) — какие сервисы имеют право его принять;
  • iat / exp — время выпуска и время жизни (в секундах от 1 января 1970);
  • jti — уникальный идентификатор токена.

Выдача токена: нативный объект ТокенДоступа

До 8.3.21 JWT в 1С собирали руками: кодировали заголовок и нагрузку в Base64URL, считали HMAC функцией HMAC, склеивали через точку. Работало, но кода много и легко ошибиться. Начиная с 8.3.21 всё это делает платформа. Вот минимальный обработчик HTTP-сервиса, который выпускает подписанный токен:

Функция TestGET(Запрос)
	Ответ = Новый HTTPСервисОтвет(200);

	АлгоритмПодписи = АлгоритмПодписиТокенаДоступа.HS256;

	ТокенДоступа = Новый ТокенДоступа;
	ТокенДоступа.Заголовки.Вставить("alg", Строка(АлгоритмПодписи));
	ТокенДоступа.Эмитент = "Test1s";
	ТокенДоступа.Получатели.Добавить("ДО8");
	ТокенДоступа.КлючСопоставленияПользователя = "admin";
	ТокенДоступа.ВремяСоздания = ТекущаяУниверсальнаяДата() - Дата(1970,1,1,0,0,0);
	ТокенДоступа.ВремяЖизни = 3600;
	ТокенДоступа.Идентификатор = Новый УникальныйИдентификатор;
	ТокенДоступа.Подписать(АлгоритмПодписи, Константы.КлючПодписи.Получить());

	ТекстТокена = Строка(ТокенДоступа); // готовая JWT-строка header.payload.signature

	Ответ.УстановитьТелоИзСтроки(ТекстТокена, КодировкаТекста.UTF8, ИспользованиеByteOrderMark.НеИспользовать);
	Возврат Ответ;
КонецФункции

Что тут важно понять по шагам:

  • Новый ТокенДоступа создаёт пустой токен. АлгоритмПодписиТокенаДоступа.HS256 — симметричная подпись одним общим ключом (кроме HS256 платформа поддерживает и асимметричные RS256/ES256, но для «сервер сам себе выдаёт и сам проверяет» HS256 достаточно).
  • ВремяСоздания и ВремяЖизни задаются в секундах от эпохи Unix. Разницу ТекущаяУниверсальнаяДата() - Дата(1970,1,1,0,0,0) берём именно от универсальной (UTC) даты — иначе токен «поедет» на величину часового пояса, и клиент увидит, что он то ещё не начал действовать, то уже просрочен.
  • Подписать(...) считает подпись, Строка(ТокенДоступа) отдаёт готовую компактную JWT-строку. Приводить к Base64URL руками не нужно — это делает сама платформа.

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

Функция ПолучитьТокен(Запрос) Экспорт
	УстановитьПривилегированныйРежим(Истина);

	ПользовательИБ = ПользователиИнформационнойБазы.ТекущийПользователь();
	Если ПользовательИБ = Неопределено Тогда
		Возврат HTTPОбщегоНазначения.СформироватьОтвет(, "Пользователь не определен", 400);
	КонецЕсли;

	ДанныеВозврата = Новый Соответствие;
	ДанныеВозврата.Вставить("access", СформироватьJWTТокен(ПользовательИБ));

	Возврат HTTPОбщегоНазначения.СформироватьОтвет(ДанныеВозврата, , 200, Истина);
КонецФункции

Функция СформироватьJWTТокен(Знач ПользовательИБ)
	АлгоритмПодписи = АлгоритмПодписиТокенаДоступа.HS256;

	Получатели = Новый Массив;
	Получатели.Добавить("jwt1");
	Получатели.Добавить("jwt2");

	ТокенДоступа = Новый ТокенДоступа;
	ТокенДоступа.Заголовки.Вставить("alg", Строка(АлгоритмПодписи));
	ТокенДоступа.Эмитент = "jwttest";
	ТокенДоступа.КлючСопоставленияПользователя = ПользовательИБ.Имя;
	ТокенДоступа.ВремяСоздания = ТекущаяУниверсальнаяДата() - Дата(1970,1,1,0,0,0);
	ТокенДоступа.ВремяЖизни = Константы.ВремяЖизниТокена.Получить();
	ТокенДоступа.Идентификатор = Новый УникальныйИдентификатор;
	ТокенДоступа.ПолезнаяНагрузка.Вставить("token_type", "access");
	ТокенДоступа.Получатели = Получатели;
	ТокенДоступа.Подписать(АлгоритмПодписи, Константы.КлючПодписи.Получить());

	Возврат Строка(ТокенДоступа);
КонецФункции

Здесь КлючСопоставленияПользователя (это поле sub) — имя пользователя ИБ, чтобы при проверке токена понять, от чьего имени пришёл запрос. В ПолезнаяНагрузка можно положить любые свои поля — например, token_type, чтобы отличать access-токен от refresh. Получатели (aud) — список сервисов, которым токен адресован; к нему ещё вернёмся в разделе про доверие двух баз.

Маршруты HTTP-сервиса: /token и /refresh

Сама точка входа — HTTP-сервис с корневым URL /test и шаблонами под выдачу и обновление:

Функция TokenGET(Запрос)
	Возврат JWT.ПолучитьТокен(Запрос);
КонецФункции

Функция TokenPOST(Запрос)
	Возврат JWT.ПолучитьТокен(Запрос);
КонецФункции

Функция TokenRefreshPOST(Запрос)
	Возврат JWT.ПолучитьТокен(Запрос);
КонецФункции

Честно про refresh: в учебной базе /token/refresh просто выпускает новый access-токен тем же кодом — отдельной refresh-логики там нет, это заглушка. В реальном сервисе схема такая: при логине выдают два токена — короткоживущий access (минуты) и долгоживущий refresh (дни). Access носят в каждом запросе, а когда он протух — меняют refresh на новый access, не заставляя пользователя логиниться заново. Отличить их удобно по тому самому token_type в нагрузке, а refresh дополнительно стоит хранить на сервере (в регистре), чтобы иметь возможность его отозвать.

Ответы сервиса стоит завернуть в единый конверт — так клиенту проще разбирать и успех, и ошибку:

Функция СформироватьОтвет(Данные = Неопределено, Ошибка = Неопределено, КодОтвета = 200, ЭтоТокен = Ложь) Экспорт
	Ответ = Новый HTTPСервисОтвет(КодОтвета);
	Ответ.Заголовки.Вставить("Content-Type", "application/json; charset=utf-8");

	Если ЭтоТокен = Ложь Тогда
		ДанныеОтвета = Новый Соответствие;
		ДанныеОтвета.Вставить("response", Данные);
		ДанныеОтвета.Вставить("error", Ошибка);
	Иначе
		ДанныеОтвета = Данные;
	КонецЕсли;

	ДанныеJSON = HTTPОбщегоНазначения.ЗаписатьДанныеВJSON(ДанныеОтвета);
	Ответ.УстановитьТелоИзСтроки(ДанныеJSON, КодировкаТекста.UTF8, ИспользованиеByteOrderMark.НеИспользовать);
	Возврат Ответ;
КонецФункции

Проверка входящего токена: чего обычно не показывают

Выдачу токена показывают все. А вот стороны проверки в большинстве примеров нет — и это самое интересное, потому что без неё JWT бесполезен. Платформа отдельного метода «проверь мне этот токен» не даёт, поэтому проверку HS256 собираем вручную — благо это несколько строк. Алгоритм ровно обратный выпуску: разбить токен на три части, пересчитать подпись от «заголовок.нагрузка» своим ключом и сравнить с той, что пришла.

Функция ПроверитьТокен(СтрокаТокена, Ключ) Экспорт
	Части = СтрРазделить(СтрокаТокена, ".");
	Если Части.Количество() <> 3 Тогда
		Возврат Ложь; // это не JWT
	КонецЕсли;

	ПодписанныйФрагмент = Части[0] + "." + Части[1];      // заголовок.нагрузка
	ПодписьИзТокена     = Части[2];                        // Base64URL-подпись из токена

	// пересчитываем HMAC-SHA256 от фрагмента нашим секретным ключом
	ДвоичныйКлюч     = ПолучитьДвоичныеДанныеИзСтроки(Ключ);
	ДвоичныйФрагмент = ПолучитьДвоичныеДанныеИзСтроки(ПодписанныйФрагмент);
	ХэшHMAC          = HMAC(ДвоичныйКлюч, ДвоичныйФрагмент, ХешФункция.SHA256, 64);

	НашаПодпись = ВBase64URL(ХэшHMAC); // приводим к тому же формату, что в токене

	Возврат НашаПодпись = ПодписьИзТокена;
КонецФункции

Подпись — это HMAC-SHA256, а не просто SHA256: у неё есть ключ, и штатная функция HMAC(Ключ, Данные, ХешФункция.SHA256, 64) принимает и ключ, и данные как двоичные, четвёртый параметр — размер блока хеш-функции. Результат — двоичные данные, которые нужно закодировать в Base64URL и сравнить со строкой из токена. Base64URL отличается от обычного Base64 тремя мелочами (символы +/ меняются на -_, хвостовые = отбрасываются), это одна вспомогательная функция.

Прочитать нагрузку и проверить срок жизни и получателя после сверки подписи — уже тривиально:

	// нагрузка — это Base64URL-JSON во второй части
	ДвоичнаяНагрузка = Base64Значение(ДополнитьBase64(Части[1]));
	СтрокаНагрузки   = ПолучитьСтрокуИзДвоичныхДанных(ДвоичнаяНагрузка, "UTF-8");
	Нагрузка         = HTTPОбщегоНазначения.ПрочитатьСтрокуJSON(СтрокаНагрузки);

	// exp — секунды от эпохи; сравниваем с текущим UTC
	СейчасUnix = ТекущаяУниверсальнаяДата() - Дата(1970,1,1,0,0,0);
	Если Нагрузка["iat"] + Нагрузка["exp"] < СейчасUnix Тогда
		Возврат Ложь; // токен просрочен
	КонецЕсли;

И только теперь HTTP-сервис читает Authorization: Bearer <токен> из заголовков запроса, вызывает ПроверитьТокен и либо пускает дальше, либо отвечает 401. Важный момент безопасности: сначала обязательно проверяем подпись и только потом доверяем содержимому нагрузки. Наоборот нельзя — заголовок и нагрузку любой может прочитать и подменить, честность гарантирует только подпись.

Кросс-сервисное доверие: две базы 1С по общему ключу

JWT удобен ещё и тем, что токен, выпущенный одной базой, может принять другая — если у них общий секретный ключ и совпадает список получателей aud. В учебной паре это две почти одинаковые базы, отличающиеся только эмитентом: у первой Эмитент = "jwttest", у второй Эмитент = "ssl". Обе кладут в Получатели один и тот же массив ["jwt1", "jwt2"] и подписывают одним ключом.

Что это даёт на практике. Есть базовый сервис (условно — учётная система) и есть смежный (например, сервис отчётности или обмена). Пользователь логинится в первом, получает токен — и ходит с ним во второй, ничего там повторно не вводя. Второй сервис не спрашивает первый «а этот токен настоящий?» — он просто проверяет подпись своим экземпляром общего ключа. По iss в нагрузке при этом видно, какая база выпустила токен, а по aud — что этот сервис имеет право его принять.

Два условия, без которых доверие не работает:

  • ключ подписи должен совпадать в обеих базах — иначе токен, выпущенный первой, не пройдёт проверку во второй. 1С это автоматически не синхронизирует, ключ вы раскладываете сами;
  • сервис должен быть в списке aud — при проверке добавьте сверку, что имя вашего сервиса есть в получателях токена, иначе чужой токен «на предъявителя» пройдёт куда не надо.

Где хранить секрет (и чего не делать)

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

Как правильно:

  • храните ключ так, чтобы его нельзя было прочитать из пользовательского режима — в защищённом хранилище (ХранилищеОбщихНастроек в привилегированном коде), в отдельной константе с чтением только через привилегированный модуль, либо вообще вне базы (переменная окружения, читаемая при публикации);
  • ключ должен быть длинным и случайным — не «12345» и не название фирмы, а хотя бы 32 случайных байта;
  • при компрометации ключ меняют, и все ранее выданные токены разом становятся невалидными — это, кстати, штатный способ «разлогинить всех».

Что в итоге

Собранный минимум JWT-авторизации в 1С — это три вещи: выпуск токена нативным ТокенДоступа с HS256, конечная точка /token (и по-хорошему /refresh), и ручная проверка подписи на входящем запросе через HMAC. Платформа с 8.3.21 закрывает самую скучную часть — кодирование и подпись, — оставляя вам ровно ту логику, которую и надо продумать: срок жизни, хранение ключа и проверку aud. Дальше этот же токен ложится в любой интеграционный сценарий: обмен с сайтом, мобильное приложение, связка микросервисов.

JWT — это способ авторизации поверх интеграции, а не сама интеграция. Если вы делаете полноценный обмен 1С с внешним сервисом на HTTP — как связать 1С и Python-микросервис, где OAuth 2 и токены разбираются на сквозном примере, смотрите на курсе «Python + 1С: интеграция OAuth 2». А как устроен сам HTTP-сервис 1С «под капотом», с приёмом и отдачей JSON, — в разборе микросервиса на FastAPI для 1С.

Частые вопросы