#Авторизация, ошибки и повторная доставка
В интеграции два секрета, и это главный источник путаницы. Начнём с них.
#Два секрета, а не один
Запросы идут в обе стороны, и у каждого направления свой секрет.
| Направление | Кто хранит | Схема | Как называется |
|---|---|---|---|
| Вы → Smartica | Вы | Authorization: Bearer <ключ> |
API-ключ клиники |
| Smartica → вы | Smartica | Bearer-токен или HTTP Basic, всегда поверх HTTPS | Учётные данные вашего API |
API-ключ клиники мы выдаём вам. Им вы подписываете запрос за ссылкой для входа врача и, если у вас режим pull, запросы за готовыми протоколами.
Учётные данные вашего API вы выдаёте нам. Ими мы подписываем запросы GET и PUT к вашей МИС.
Это разные строки, они не взаимозаменяемы и хранятся в разных местах. Если у вас режим pull, вторая пара может вообще не понадобиться: мы к вам не ходим.
#Вы → Smartica
Authorization: Bearer <API-ключ клиники>
В примерах curl этот ключ иногда обозначен переменной SSO_KEY — это то же самое. Название историческое: изначально ключ использовался только для SSO, а сейчас им же авторизуются запросы сценария Import и режима pull.
Ключ должен находиться только на сервере. Не помещайте его в JavaScript, в конфигурацию фронтенда, в мобильное приложение и в параметры страницы.
Полный контракт SSO, включая все коды ошибок: Launch URL и SSO.
#Ротация ключа
При выпуске нового ключа предыдущий продолжает работать 72 часа. Порядок действий:
- Запросите новый ключ.
- Пропишите его во всех окружениях, где используется старый.
- Проверьте, что запросы проходят с новым ключом.
- Дождитесь окончания окна — старый ключ отключится сам.
Если не успеть за 72 часа, SSO начнёт возвращать 401, а в режиме pull протоколы перестанут забираться.
#Smartica → вы
Здесь вы решаете, как мы будем авторизоваться при вызове ваших GET и PUT. Доступны две схемы, обе — только поверх HTTPS.
#Какую схему выбрать
| Схема | Что приходит в запросе | Когда использовать |
|---|---|---|
| HMAC-подпись | Smartica-Signature и Smartica-Timestamp |
Максимальная защита. Выбирайте, если готовы проверять подпись |
| Bearer-токен | Authorization: Bearer <токен> |
По умолчанию для новых подключений |
| HTTP Basic | Authorization: Basic <base64(логин:пароль)> |
Только если ваша МИС не может принять токен |
Самая защищённая из доступных — HMAC-подпись: секрет не передаётся по сети вообще, подпись покрывает тело запроса, а старый перехваченный запрос нельзя переиграть. Её описание — в разделе ниже.
Если реализовать проверку подписи сейчас не получается, по умолчанию мы настраиваем Bearer.
Честно о разнице: под TLS обе схемы передают статический секрет в заголовке, и криптографически они равноценны. Преимущество Bearer организационное, но от этого не менее реальное:
- Basic провоцирует завести «пользователя» и переиспользовать существующую учётную запись с широкими правами. Токен по своей природе создаётся под одну задачу.
- Логин и пароль часто вставляют прямо в адрес вида
https://user:pass@host— оттуда они утекают в логи, прокси и заголовокReferer. Для токена такой привычки нет. - Токен — одна непрозрачная строка: её просто сгенерировать с высокой энтропией и заменить целиком. Пароль для Basic нередко придумывает человек.
- base64 в Basic регулярно принимают за шифрование. Это только кодирование, читается в одну команду.
Если всё же выбираете Basic, сделайте пароль таким же, каким был бы токен: длинным и случайным.
#Требования к секрету
Одинаковы для обеих схем:
- не короче 32 случайных символов, сгенерированных программой, а не человеком;
- разные секреты для тестового и рабочего окружений — адреса контуров в статье Тестовая площадка;
- отдельная техническая учётная запись без интерактивного входа;
- права только на согласованные
GETиPUT, больше ни на что; - плановая ротация с согласованным окном переключения.
Сгенерировать токен:
openssl rand -base64 48
#HMAC-подпись запроса
Самый защищённый вариант из доступных. Мы не передаём секрет по сети: вместо этого подписываем им запрос, а вы проверяете подпись тем же секретом у себя.
Что это даёт поверх токена:
- секрет невозможно перехватить, потому что он не уходит в заголовке;
- подпись покрывает тело — подменить содержимое протокола нельзя;
- подпись покрывает метод и путь — её нельзя переиспользовать на другом endpoint;
- есть метка времени — перехваченный запрос нельзя повторить позже.
Мы добавляем к каждому запросу два заголовка:
Smartica-Timestamp: 1779805800
Smartica-Signature: v1=8f2a...c31d
Подпись считается так:
подписываемая строка = "{timestamp}.{МЕТОД}.{путь}.{тело}"
подпись = HMAC-SHA256(секрет, подписываемая строка) в hex
Где:
| Часть | Что подставляется |
|---|---|
timestamp |
Значение заголовка Smartica-Timestamp — время в секундах Unix |
МЕТОД |
GET или PUT заглавными буквами |
путь |
Путь запроса с ведущим слешем, без хоста: /api/for-smartica/encounters/12345 |
тело |
Тело запроса ровно как пришло. Для GET — пустая строка |
Разделитель — точка. Для GET строка заканчивается точкой, потому что тело пустое.
#Как проверять на своей стороне
- Прочитайте
Smartica-Timestamp. Если он отличается от текущего времени больше чем на 5 минут — отклоните запрос с401. Это защита от повтора. - Возьмите сырое тело запроса, до разбора JSON. Пересобранный из объекта JSON даст другую строку и другую подпись.
- Соберите ту же строку и посчитайте
HMAC-SHA256вашим секретом. - Сравните с присланной подписью за постоянное время. Обычное сравнение строк открывает подбор по таймингу.
Готовые примеры — переключите вкладку на свой стек:
$timestamp = $_SERVER['HTTP_SMARTICA_TIMESTAMP'] ?? '';
$received = str_replace('v1=', '', $_SERVER['HTTP_SMARTICA_SIGNATURE'] ?? '');
if ($timestamp === '' || abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit('Просроченная подпись');
}
// Сырое тело, а не $_POST и не json_decode.
$body = file_get_contents('php://input');
$payload = implode('.', [
$timestamp,
strtoupper($_SERVER['REQUEST_METHOD']),
parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH),
$body,
]);
$expected = hash_hmac('sha256', $payload, getenv('SMARTICA_SIGNING_SECRET'));
if (! hash_equals($expected, $received)) {
http_response_code(401);
exit('Неверная подпись');
}
const crypto = require('crypto');
// Важно: нужно сырое тело. В Express — express.raw() или verify-колбэк.
function verifySmarticaSignature(req, res, next) {
const timestamp = req.get('Smartica-Timestamp') || '';
const received = (req.get('Smartica-Signature') || '').replace(/^v1=/, '');
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!timestamp || age > 300) {
return res.status(401).json({ message: 'Просроченная подпись' });
}
const body = req.rawBody ? req.rawBody.toString('utf8') : '';
const payload = `${timestamp}.${req.method.toUpperCase()}.${req.path}.${body}`;
const expected = crypto
.createHmac('sha256', process.env.SMARTICA_SIGNING_SECRET)
.update(payload, 'utf8')
.digest('hex');
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ message: 'Неверная подпись' });
}
next();
}
import hashlib
import hmac
import os
import time
def verify_smartica_signature(method: str, path: str, raw_body: bytes, headers) -> bool:
timestamp = headers.get("Smartica-Timestamp", "")
received = headers.get("Smartica-Signature", "").removeprefix("v1=")
if not timestamp or abs(int(time.time()) - int(timestamp)) > 300:
return False
# raw_body — сырые байты запроса, до разбора JSON.
payload = f"{timestamp}.{method.upper()}.{path}.{raw_body.decode('utf-8')}"
expected = hmac.new(
os.environ["SMARTICA_SIGNING_SECRET"].encode(),
payload.encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, received)
// Сырое тело: включите EnableBuffering() до чтения.
var timestamp = Request.Headers["Smartica-Timestamp"].ToString();
var received = Request.Headers["Smartica-Signature"].ToString().Replace("v1=", "");
if (string.IsNullOrEmpty(timestamp) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp)) > 300)
{
return Results.Unauthorized();
}
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
Request.Body.Position = 0;
var payload = $"{timestamp}.{Request.Method.ToUpperInvariant()}.{Request.Path}.{body}";
var secret = Environment.GetEnvironmentVariable("SMARTICA_SIGNING_SECRET")!;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert
.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)))
.ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(received)))
{
return Results.Unauthorized();
}
// Сырое тело: оберните запрос в ContentCachingRequestWrapper.
String timestamp = request.getHeader("Smartica-Timestamp");
String received = request.getHeader("Smartica-Signature").replace("v1=", "");
if (timestamp == null
|| Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)) > 300) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Просроченная подпись");
}
String body = new String(request.getContentAsByteArray(), StandardCharsets.UTF_8);
String payload = timestamp + "." + request.getMethod().toUpperCase()
+ "." + request.getRequestURI() + "." + body;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
// HexFormat доступен с Java 17.
String expected = HexFormat.of()
.formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
if (!MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
received.getBytes(StandardCharsets.UTF_8))) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Неверная подпись");
}
#Если ваша МИС на 1С
В платформе 1С:Предприятие нет встроенной функции HMAC: объект ХешированиеДанных считает только простые хеши и не принимает секретный ключ. Реализовать HMAC можно — через функцию подписи из БСП или вручную через XOR и вложенное хеширование, — но это заметно сложнее, чем в остальных стеках, и ошибку легко не заметить.
Для интеграций на 1С мы рекомендуем Bearer-токен. Проверка сводится к сравнению строки, а нужный уровень защиты добирается IP allowlist и требованиями к секрету.
Функция SmarticaEncounterPUT(Запрос)
ОжидаемыйЗаголовок = "Bearer " + Константы.SmarticaВходящийТокен.Получить();
ПолученныйЗаголовок = "";
Для Каждого Заголовок Из Запрос.Заголовки Цикл
Если НРег(Заголовок.Ключ) = "authorization" Тогда
ПолученныйЗаголовок = Заголовок.Значение;
КонецЕсли;
КонецЦикла;
Если ПолученныйЗаголовок <> ОжидаемыйЗаголовок Тогда
Ответ = Новый HTTPСервисОтвет(401);
Ответ.УстановитьТелоИзСтроки("{""message"": ""Неверный токен""}");
Возврат Ответ;
КонецЕсли;
ТелоЗапроса = Запрос.ПолучитьТелоКакСтроку();
// Разбор JSON и сохранение протокола по encounter_id.
Ответ = Новый HTTPСервисОтвет(200);
Ответ.Заголовки.Вставить("Content-Type", "application/json; charset=utf-8");
Ответ.УстановитьТелоИзСтроки("{""encounter_id"": ""12345"", ""status"": ""saved""}");
Возврат Ответ;
КонецФункции
Сравнения за постоянное время в 1С штатно нет, поэтому для таких подключений особенно важен IP allowlist: он отсекает перебор до того, как запрос дойдёт до кода.
#Что учесть
- Секрет генерируем мы при подключении и передаём по защищённому каналу.
- При HMAC заголовок
Authorizationне отправляется: подпись и есть авторизация. Если ваш прокси требуетAuthorization, скажите об этом — подберём комбинацию. - Путь берите тот, что видит ваше приложение. Если прокси срезает префикс, подпись не сойдётся — сверьте это на тестовом прогоне.
- Проверяйте подпись до разбора тела и до любой бизнес-логики.
#Усиление защиты
Схема авторизации — только один слой. Меры ниже не требуют изменений в контракте и дают больше, чем выбор между Bearer и Basic:
| Мера | Что даёт |
|---|---|
| TLS 1.2 и выше с публично доверенным сертификатом | Обязательно. Без него любая схема бессмысленна |
| IP allowlist на наши адреса | Запрос с чужого адреса не дойдёт до приложения |
| Отдельный технический аккаунт с минимальными правами | Ограничивает ущерб при утечке секрета |
Только методы GET и PUT на этом адресе |
Убирает лишнюю поверхность атаки |
| Rate limit на стороне МИС | Защита от перебора секрета |
| Логирование неудачных попыток авторизации | Раннее обнаружение подбора |
| Регулярная ротация | Ограничивает срок жизни утёкшего секрета |
Актуальные адреса для allowlist запросите при подключении.
#Ротация секрета
У секрета для входящих запросов нет окна грации: адаптер в каждый момент отправляет ровно один действующий секрет. Если поменять его у себя раньше, чем у нас, запросы начнут получать 401.
Безопасный порядок:
- Сгенерируйте новый секрет, но пока не отключайте старый.
- Настройте свой endpoint так, чтобы он принимал оба: сначала проверяйте новым, при неудаче — старым. Для Bearer и Basic это сравнение с двумя значениями, для HMAC — проверка подписи против двух секретов.
- Передайте новый секрет нам по защищённому каналу и согласуйте время переключения.
- Мы меняем настройку у себя.
- Убедитесь по своим логам, что запросы приходят с новым секретом.
- Уберите поддержку старого.
Второй пункт — единственный способ обойтись без окна ошибок. Он требует нескольких строк кода, но окупается на первой же ротации.
Не путайте с ротацией API-ключа клиники в обратном направлении: там окно грации есть, старый ключ действует ещё 72 часа.
#Схемы, которые требуют доработки
Эти варианты в текущем адаптере не реализованы. Если что-то из них обязательно по вашим требованиям безопасности, скажите об этом до начала разработки: нужно оценить доработку на нашей стороне.
| Схема | Что даёт дополнительно |
|---|---|
| mTLS — взаимный TLS | Стороны проверяют сертификаты друг друга. Одного перехваченного заголовка для запроса недостаточно |
| OAuth 2.0 client credentials | Короткоживущие токены вместо постоянного секрета |
Не включайте их односторонне: адаптер отправит ровно то, что настроено при подключении, и все запросы начнут получать 401.
#Сетевой доступ
- Endpoint вашей МИС должен иметь валидный публично доверенный TLS-сертификат. Самоподписанный не подойдёт.
- Не просите отключить проверку TLS — мы этого не сделаем.
- Если у вас IP allowlist, запросите у нас актуальные адреса при онбординге.
- Не составляйте allowlist по догадкам: неверный список даёт timeout или
403, и это неочевидно при отладке. - VPN, прокси и DNS согласуйте до сквозного теста, а не во время.
#Каким должен быть успешный ответ вашей МИС
Для GET и PUT возвращайте:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Тело — валидный JSON-объект.
204 No Content, пустое тело и HTML считаются ошибкой интеграции, даже если код формально относится к 2xx. Частая причина — прокси или балансировщик, который подменяет ответ приложения своей страницей. Проверяйте ответ с внешней машины.
#Формат ошибки вашей МИС
Рекомендуемый ответ:
{
"message": "Приём не найден"
}
Smartica может сохранить этот текст для диагностики и показать его пользователю. Поэтому message должен быть одновременно понятным и безопасным.
Никогда не включайте в него:
- стектрейсы;
- SQL-запросы и внутренние пути;
- логины, пароли, токены;
- персональные данные пациента;
- фрагменты конфигурации сервера.
#Коды ответов вашего API
| Код | Значение | Что произойдёт |
|---|---|---|
200 |
Успех, тело содержит JSON | Smartica продолжает обработку |
400 |
Некорректный запрос | Нужно исправить контракт |
401 |
Неверные учётные данные | Проверить или ротировать секрет |
403 |
Доступ запрещён | Проверить права, VPN и allowlist |
404 |
Приём не найден | Проверить ID и окружение |
409 |
Конфликт состояния | Решается на вашей стороне |
422 |
Ошибка в полях | Исправить конкретные поля |
429 |
Ваш лимит запросов | Согласовать лимиты и Retry-After |
5xx |
Временная ошибка | Врач увидит ошибку и сможет повторить |
Любой ответ не из 2xx, сетевой сбой, timeout или невалидный JSON означают неуспех текущей операции.
#Timeout
Наш HTTP timeout для запросов к вашей МИС — 30 секунд. Это верхний предел, за которым мы прекращаем ждать, а не рекомендуемое время ответа.
Как правильно:
GETбыстро читает готовые данные приёма;PUTбыстро сохраняет присланное;- тяжёлую внутреннюю обработку запускайте после надёжного сохранения;
- не отвечайте
200, пока данные не приняты устойчиво.
Последний пункт важен: 200 для нас означает «сохранено». Если вы ответите успехом, а потом потеряете данные, повтора может не быть.
#Повторная доставка
Контракт не гарантирует автоматический повтор каждого неуспешного GET или PUT.
Реально повтор происходит так:
- после ошибки
GETврач заново открывает приём; - после ошибки
PUTврач нажимает повтор отправки в интерфейсе Smartica; - новая запись или перезаполнение в том же приёме порождает новый
PUT.
Отсюда два обязательных требования:
PUTдолжен быть идемпотентным поencounter_id.- МИС не должна рассчитывать ни на доставку ровно один раз, ни на фиксированное число попыток.
Подробнее про слияние полей и стенограммы: PUT: запись протокола.
#Как обрабатывать ошибки SSO в интерфейсе МИС
| Код | Что показать врачу и что сделать |
|---|---|
401 |
Сообщить об ошибке конфигурации. Не повторять |
403 |
Показать message, предложить обратиться в поддержку Smartica |
404 |
Проверить выданный адрес, затем обратиться в поддержку |
422 |
Показать ошибку данных из объекта errors |
429 |
Заблокировать повтор до времени из Retry-After |
5xx или сетевой сбой |
Показать временную ошибку и кнопку повторного запуска |
Автоматические повторы на 401 и 403 бесполезны и вредны: неудачные попытки авторизации ограничены 30 в минуту, и цикл повторов заблокирует клинику целиком.
Обычный вход в Smartica по логину и паролю можно предложить врачу как временный обходной путь — но только при сетевой или временной серверной ошибке. Он не исправит неверный ключ, конфликт организаций или невалидный encounter_id.
#Health check
При желании можно поднять отдельный endpoint для ручной проверки:
GET /api/for-smartica/health
{
"status": "ok"
}
Он не входит в обязательный протокол и автоматически не вызывается. Сообщите его адрес нашей команде, если хотите использовать для быстрой проверки связности при разборе инцидентов.
#Порядок диагностики
Если что-то не работает, проверяйте сверху вниз:
- То ли окружение и тот ли хост — см. Тестовая площадка.
- Совпадает ли
encounter_idв SSO, в адресе и в теле. - Доступен ли endpoint из внешней сети.
- Валиден ли TLS-сертификат.
- Возвращается ли
Content-Type: application/json. - Не приходит ли
204, HTML или страница прокси вместо JSON. - Идемпотентен ли повторный
PUT.
Разбор по конкретным симптомам — в статье Диагностика: если что-то не работает.