Введение
В эпоху цифровизации обеспечение надежной аутентификации и авторизации пользователей становится основой для безопасного доступа к различным государственным сервисам. Единая система идентификации и аутентификации (ЕСИА) предоставляет инструменты для выполнения этих задач в России. В этой статье я поделюсь опытом интеграции с ЕСИА с использованием OpenID Connect 1.0, а также предоставлю пример реализации интеграции на Java с ЕСИА для получения персональных данных пользователя.
Зачем нужна интеграция с ЕСИА?
ЕСИА — это централизованная государственная платформа, предназначенная для идентификации и авторизации граждан при доступе к государственным и коммерческим сервисам. Интеграция с ЕСИА позволяет:
Аутентифицировать пользователей через их учетные записи ЕСИА.
Получать детальную информацию о пользователях: ФИО, пол, СНИЛС, ИНН, паспорт и другие данные.
Повышать удобство использования за счет исключения повторного ввода информации.
Общая схема работы
Аутентификация и авторизация с ЕСИА состоит из нескольких этапов:
Подготовка запроса на аутентификацию:
Клиентская система направляет запрос в ЕСИА с параметрами, такими как client_id, redirect_uri, scope.Аутентификация пользователя:
ЕСИА проверяет учетные данные пользователя и перенаправляет его обратно в клиентскую систему с авторизационным кодом. Важный момент – пользователь должен подтвердить отправку скоупов персональных данных.Обмен авторизационного кода на токен:
Клиентская система обменивает код на идентификационный токен (id_token) или маркер доступа (access_token).Валидация и использование токена:
После проверки токена клиентская система может запрашивать персональные данные пользователя через REST API ЕСИА.
Простое объяснение:
Формируем запрос на получение авторизационного кода.
Перенаправляемся на сайт esia.gosuslugi.ru.
Пользователь авторизуется и подтверждает передачу данных.
Получаем токен и забираем персональные данные.
Реализация на Java
Для работы с p12-сертификатом будем использовать библиотеку BouncyCastle версии не ниже 1.66. В более ранних версиях есть проблемы с поддержкой алгоритма GOST3411-2012, который используется для подписи сообщений на Госуслугах.
Экспорт ключа КриптоПро в p12
Перед началом работы необходимо экспортировать флешку с ключом КриптоПро в p12 (PFX) контейнер. Это позволит избежать лишних затрат на лицензии КриптоПро.
Подробнее о процессе можно прочитать в моей статье:
Экспорт ключа КриптоПро в p12.
Будем считать с преобразованием ключа в p12 мы справились, идем дальше.
Получение авторизационного кода
Система-клиент должна направить пользователя на страницу предоставления прав доступа в ЕСИА:
Адрес:
https://esia.gosuslugi.ru/aas/oauth2/ac
Параметры:
client_id — идентификатор системы-клиента.
client_secret — подпись запроса в формате PKCS#7, закодированная в Base64.
redirect_uri — URL, куда будет перенаправлен пользователь после аутентификации.
scope — область доступа (например, openid fullname birthdate).
response_type — тип ответа (code).
state — случайный идентификатор запроса.
timestamp — время запроса (формат yyyy.MM.dd HH:mm:ss Z).
Если в ходе аутентификации не возникло ошибок, то ЕСИА осуществляет редирект
пользователя по ссылке, указанной в redirect_uri, а также возвращает два обязательных
параметра:
<code> – значение авторизационного кода;
<state> – значение параметра state, который был получен в запросе на аутентификацию;
система-клиент должна провести сравнение отправленного и полученного параметра state.
Пример запроса:
В случае ошибки сервис авторизации вернет в параметре error код ошибки (например,
“access_denied”) и перенаправит пользователя по адресу, указанному в redirect_uri.
Давайте реализуем на java механизм формирования URL для запроса кода авторизации:
public String getLoginUrl() throws Exception {
String state = UUID.randomUUID().toString();
String timestamp = getEsiaDateNow();
String clientMsg = scope + timestamp + clientId + state;
byte[] signMessage = signerService.signMessageGost(clientMsg.getBytes());
byte[] clientSecret = Base64.getEncoder().encode(signMessage);
String params = "?timestamp=" + URLEncoder.encode(timestamp, "UTF-8") +
"&scope=" + scope +
"&client_secret=" + URLEncoder.encode(new String(clientSecret), "UTF-8") +
"&response_type=code" +
"&redirect_uri=" + redirectUrl +
"&state=" + state +
"&client_id=" + clientId;
return esiaSiteAuthCode + params;
}
Особое внимание обратим на параметры:
redirect_uri – это адрес нашего портала, куда мы вернемся после подтверждения пользователя о предоставлении доступа к его персональным данным.
И параметр timestamp – он должен быть обязательно в формате ‘yyyy.MM.dd HH:mm:ss Z’ пример 2024.10.08 22:30:52 +0300 и к тому же закодирован URLEncoder
Этот параметр используется для подписи, если формат будет хоть в чем-то отличаться, запрос будет не валидным!
Если с подписью и формированием атрибутов все ок, после авторизации получим запрос на предоставление доступа со списком скоупов:
После предоставления доступа произойдет редирект на redirect_url с полученным кодом авторизации. Он то нам и нужен для получения токена!
Получение токена в обмен на авторизационный код
Когда авторизационный код получен, система-клиент может сформировать запрос методом POST в адрес ЕСИА для получения маркера идентификации.
Адрес:
https://esia.gosuslugi.ru/aas/oauth2/te
Один авторизационный код можно обменять на один маркер идентификации.
В тело запроса должны быть включены следующие сведения:
<client_id> – идентификатор системы-клиента (мнемоника системы в ЕСИА указанная прописными буквами) - Weblp
<code> – значение авторизационного кода, который был ранее получен от ЕСИА и который необходимо обменять на маркер идентификации;
<grant_type> – принимает значение “authorization_code”, если авторизационный код обменивается на маркер идентификации;
<client_secret> – подпись запроса в формате PKCS#7 detached signature в кодировке UTF8 от значений четырех параметров HTTP–запроса: scope, timestamp, clientId, state (без разделителей). <client_secret> должен быть закодирован в формате base64 url safe. Используемый для проверки подписи сертификат должен быть предварительно зарегистрирован в ЕСИА и привязан к учетной записи системы-клиента в ЕСИА. ЕСИА поддерживает сертификаты в формате X.509.
<state> – набор случайных символов, имеющий вид 128-битного идентификатора запроса (необходимо для защиты от перехвата), генерируется по стандарту UUID; этот набор символов должен отличаться от того, который использовался при получении авторизационного кода;
<redirect_uri> – ссылка, по которой должен быть направлен пользователь после аутентификации (то же самое значение, которое было указано в запросе на получение авторизационного кода); https://szr.rt.ru/
<scope> – область доступа, т.е. запрашиваемые права (то же самое значение, которое было указано в запросе на получение авторизационного кода); openid fullname birthdate gender snils inn id_doc
<timestamp> – время запроса маркера в формате yyyy.MM.dd HH:mm:ss Z (например, 2013.01.25 14:36:11 +0400), необходимое для фиксации начала временного промежутка, в течение которого будет валиден запрос с данным идентификатором (<state>);
<token_type> – тип запрашиваемого маркера, в настоящее время ЕСИА поддерживает только значение “Bearer”.
Пример ответа:
{“id_token”:
“eyJhbGciOiJSUz…”,
“expires_in” : 3600,
“state” : “9be638a9-0e05-42e1-b4f8-a3e30457fbdd”,
“token_type” : “Bearer”,}
При невозможности выдачи маркера доступа возвращается код ошибки
Реализуем метод получения токена на java
public ResponseEntity<String> getToken(String code) throws Exception {
logger.info("getToken start..");
String timestamp = getEsiaDateNow();
String state = UUID.randomUUID().toString();
String clientMsg = scope + timestamp + clientId + state;
byte[] signMessage = signerService.signMessageGost(clientMsg.getBytes());
byte[] clientSecret = Base64.getEncoder().encode(signMessage);
String grantPype = "authorization_code";
HttpHeaders headers = new HttpHeaders();
headers.set("Accept", MediaType.APPLICATION_JSON_VALUE);
MultiValueMap<String, String> map = new LinkedMultiValueMap<String, String>();
map.add("grant_type", grantPype);
map.add("timestamp", timestamp);
map.add("code", code);
map.add("redirect_uri", tokenResult);
map.add("client_id", clientId);
map.add("client_secret", new String(clientSecret));
map.add("state", state);
map.add("scope", scope);
logger.info("prepare get token HttpEntity..");
HttpEntity<MultiValueMap<String, String>> entity =
new HttpEntity<MultiValueMap<String, String>>(map, headers);
ResponseEntity<String> exchange = httpCore.exchange(HttpMethod.POST, esiaSite + tokenUrl, entity);
return exchange;
}
Предоставление персональных данных пользователей
Для получения персональных данных пользователей необходимо направить запрос методом GET к REST-API системы ЕСИА на соответствующий https-адрес.
В тестовой среде сервис доступен по URL:
https://esia-portal1.test.gosuslugi.ru/rs/prns
Структура ресурса в запросе
Иерархия идентификаторов ресурсов в ЕСИА выглядит следующим образом:
/prns/{oid}/{collection_name}/{collection_entity_id}
Обозначения:
prns — перечень (коллекция) пользователей, зарегистрированных в ЕСИА.
-
{oid} — внутренний идентификатор пользователя в ЕСИА.
Получается:из маркера идентификации в параметре sub или urn:esia:sbj:oid;
из маркера доступа в параметре urn:esia:sbj_id.
-
{collection_name} — ссылка на перечень (коллекцию) типов данных пользователя:
ctts — контактные данные;
addrs — адреса;
docs — документы пользователя;
orgs — организации, сотрудником которых является пользователь;
vhls — транспортные средства пользователя.
{collection_entity_id} — внутренний идентификатор элемента коллекции (например, контакта или документа).
Пример запроса
Для выполнения запроса необходимо добавить header с маркером доступа. Пример запроса в среде разработки:
GET /rs/prns/6924 HTTP/1.1
Authorization: Bearer 75b2c7cbb8da403491c224c9e431cef9
Host: esia-portal1.test.gosuslugi.ru
Accept: */*
После выполнения запроса можно извлечь необходимые данные, передавая токен в заголовке.
Пример реализации на Java
public Person getPrnsByToken(String token) {
HttpEntity<String> entity = createEntity(token);
logger.debug("getPrnsByToken..");
ResponseEntity<String> exchange = httpCore.exchange(
HttpMethod.GET,
esiaSite + personUrl + getUserIdByToken(token),
entity
);
return parsePersonFromPrns(exchange.getBody());
}
Заключение
Интеграция с ЕСИА предоставляет удобный и безопасный способ работы с государственными и коммерческими сервисами. Использование современных библиотек и OpenID Connect позволяет автоматизировать процессы и улучшить пользовательский опыт.
Экспорт сертификатов из КриптоПро в формат p12 открывает дополнительные возможности оптимизация затрат и повышение гибкости работы с ключами.
Если у вас возникли вопросы или вы хотите углубиться в тему — пишите комментарии.
Исходный код интеграции с ЕСИА
Если статья и исходники оказались полезными, не забудьте поставить ⭐️ на GitHub! ?
velon
Я ожидал что сейчас будет о том как утекают данные с ЕСИА, а по сути: "Получаем персональные данные пользователей с Госуслуг если пользователи согласны их передавать"
В общем что-то, мне кажется, не то с заголовком...
И ещё вопрос: client_id - получается сначала надо как-то зарегистрировать свою систему в Госуслугах, абы кто не может на свой сайт закинуть авторизацию ЕСИА, пока эту процедуру не пройдёт (ещё и сертификат нужно получить)?
И не описана работа с сертификатом, понятно что есть GitHub и можно в нём посмотреть
signMessageGost
, видимо, но для публикации это потеря: начали то с p12-сертификата, а потом это осталось без пояснений.Эх, раскритиковал всё, в целом то публикация норм, но недосказанность какая-то.
aborouhin
По этому поводу тоже хотел сначала автору вопрос задать, как там оно сейчас на практике. Раньше вообще не пускали всех подряд к ЕСИА, только госорганы/кредитные/страховые/коммунальщиков/связистов/т.п. Сейчас по регламенту любой может свою ИС интегрировать, но есть много всяких "но" (аттестация на определённую категорию защиты перс. данных, сертификация средств криптографии, потом всё это рассматривается на комиссии...) - в общем, не вполне очевидно, насколько этот квест реально проходим.
Но потом подумал, что вот этим всем в организации явно не бэкенд-разработчик занимался, и автору поста задавать эти вопросы бессмысленно :)