Русский
Русский
English
Статистика
Реклама

Как реализовать интеграцию с ЕСИА на Java без лишних проблем

Долгое время основным способом идентификации граждан был обычный паспорт. Ситуация изменилась, когда в 2011 году по заказу Минкомсвязи была внедрена Единая система идентификации и аутентификации (ЕСИА), она позволила распознавать личность человека и получать о ней данные в режиме онлайн.

Благодаря внедрению ЕСИА государственные и коммерческие организации, разработчики и владельцы онлайн-сервисов получили возможность ускорить и сделать более безопасными операции, связанные с вводом и верификацией пользовательских данных. Русфинанс Банк также решил использовать потенциал системы и при доработке сервиса по оформлению кредита онлайн (банк специализируется на автокредитовании) реализовал интеграцию с платформой.

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

В данной статье мы постараемся рассказать про основные моменты и методические указания, которые важно знать тем, кто хочет самостоятельно реализовать интеграцию с ЕСИА, а также приведем фрагменты кода на языке Java, которые помогут преодолеть трудности при разработке (часть реализации опущена, но общая последовательность действий ясна).

Надеемся, наш опыт поможет Java-разработчикам (и не только) сэкономить массу времени при разработке и ознакомлении с методическими рекомендациями Минкомсвязи.



Зачем нам нужна интеграция с ЕСИА?


В связи с пандемией коронавируса количество офлайн сделок во многих направлениях кредитования начало сокращаться. Клиенты стали уходить в онлайн, и для нас было жизненно важно укрепить своё онлайн-присутствие на рынке автокредитования. В процессе доработки сервиса Автокредит (на Хабре уже есть статья про его разработку) мы решили сделать интерфейс заведения кредитных заявок на сайте банка максимально удобным и простым. Интеграция с ЕСИА стала ключевым моментом в решении этой задачи, поскольку позволила автоматически получить персональные данные клиента.



Для клиента такое решение также оказалось удобным, поскольку дало возможность использовать единый логин и пароль для регистрации и входа в сервис онлайн-одобрения заявок на приобретение автомобиля в кредит.

Кроме того, интеграция с ЕСИА позволила Русфинанс Банку:

  • сократить время заполнения онлайн-анкет;
  • уменьшить количество отказов пользователей при попытке заполнить большое количество полей вручную;
  • обеспечить поток более качественных, верифицированных клиентов.

Несмотря на то, что мы рассказываем об опыте нашего банка, информация может быть полезна не только финансовым организациям. Правительство рекомендует использовать платформу ЕСИА и для других видов онлайн-услуг (подробнее тут).

Что делать и как?


Сначала нам показалось, что в интеграции с ЕСИА нет ничего особенного с технической точки зрения стандартная задача, связанная с получением данных посредством REST API. Однако, при ближайшем рассмотрении стало понятно, что не всё так просто. Например, выяснилось, что у нас нет представления о том, как работать с сертификатами, необходимыми для подписи нескольких параметров. Пришлось тратить время и разбираться. Но обо всем по порядку.

Для начала важно было наметить план действий. Наш план включал следующие основные шаги:

  1. зарегистрироваться на технологическом портале ЕСИА;
  2. подать заявки на использование программных интерфейсов ЕСИА в тестовой и промышленной среде;
  3. самостоятельно разработать механизм взаимодействия с ЕСИА (в соответствии с действующим документом Методические рекомендации по использованию ЕСИА);
  4. протестировать работу механизма в тестовой и промышленной среде ЕСИА.

Обычно мы разрабатываем наши проекты на Java. Поэтому для программной реализации выбрали:

  • IntelliJ IDEA;
  • КриптоПро JCP (или КриптоПро Java CSP);
  • Java 8;
  • Apache HttpClient;
  • Lombok;
  • FasterXML/Jackson.

Получение URL для переадресации


Первый шаг это получение авторизационного кода. В нашем случае это делает отдельный сервис с переадресацией на страницу авторизации портала Госуслуг (расскажем об этом немного подробнее).

Сначала мы инициализируем переменные ESIA_AUTH_URL (адрес ЕСИА) и API_URL (адрес, на который происходит редирект в случае успешной авторизации). После этого создаем объект EsiaRequestParams, который содержит в своих полях параметры запроса к ЕСИА, и сформируем ссылку esiaAuthUri.

public Response loginByEsia() throws Exception {  final String ESIA_AUTH_URL = dao.getEsiaAuthUrl(); // Адрес ЕСИА  final String API_URL = dao.getApiUrl(); // Адрес, на который произойдет редирект с случае успешной авторизации  EsiaRequestParams requestDto = new EsiaRequestParams(API_URL);  URI esiaAuthUri = new URIBuilder(ESIA_AUTH_URL)          .addParameters(Arrays.asList(            new BasicNameValuePair(RequestEnum.CLIENT_ID.getParam(), requestDto.getClientId()),            new BasicNameValuePair(RequestEnum.SCOPE.getParam(), requestDto.getScope()),            new BasicNameValuePair(RequestEnum.RESPONSE_TYPE.getParam(), requestDto.getResponseType()),            new BasicNameValuePair(RequestEnum.STATE.getParam(), requestDto.getState()),            new BasicNameValuePair(RequestEnum.TIMESTAMP.getParam(), requestDto.getTimestamp()),            new BasicNameValuePair(RequestEnum.ACCESS_TYPE.getParam(), requestDto.getAccessType()),            new BasicNameValuePair(RequestEnum.REDIRECT_URI.getParam(), requestDto.getRedirectUri()),            new BasicNameValuePair(RequestEnum.CLIENT_SECRET.getParam(), requestDto.getClientSecret())          ))          .build();  return Response.temporaryRedirect(esiaAuthUri).build();}

Для наглядности покажем, как может выглядеть класс EsiaRequestParams:

public class EsiaRequestParams {  String clientId;  String scope;  String responseType;  String state;  String timestamp;  String accessType;  String redirectUri;  String clientSecret;  String code;  String error;  String grantType;  String tokenType;  public EsiaRequestParams(String apiUrl) throws Exception {    this.clientId = CLIENT_ID;    this.scope = Arrays.stream(ScopeEnum.values())            .map(ScopeEnum::getName)            .collect(Collectors.joining(" "));    responseType = RESPONSE_TYPE;    state = EsiaUtil.getState();    timestamp = EsiaUtil.getUrlTimestamp();    accessType = ACCESS_TYPE;    redirectUri = apiUrl + RESOURCE_URL + "/" + AUTH_REQUEST_ESIA;    clientSecret = EsiaUtil.generateClientSecret(String.join("", scope, timestamp, clientId, state));    grantType = GRANT_TYPE;    tokenType = TOKEN_TYPE;  }}

После этого нужно перенаправить пользователя на сервис аутентификации ЕСИА. Пользователь вводит свой логин-пароль, подтверждает доступ к данным для нашей системы. Далее ЕСИА отправляет онлайн-сервису ответ, в котором содержится код авторизации. Этот код понадобится для дальнейших запросов в ЕСИА.

Каждый запрос к ЕСИА имеет параметр client_secret, который представляет собой откреплённую электронную подпись в формате PKCS7 (Public Key Cryptography Standard). В нашем случае для подписи используется сертификат, который был получен удостоверяющим центром перед началом работ по интеграции с ЕСИА. Как работать с хранилищем ключей хорошо описано в этом цикле статей.

Для примера покажем, как выглядит хранилище ключей, предоставляемое компанией КриптоПро:



Вызов приватного и публичного ключей в этом случае будет выглядеть так:

KeyStore keyStore = KeyStore.getInstance("HDImageStore"); // Создание экземпляра хранилищаkeyStore.load(null, null);PrivateKey privateKey = (PrivateKey) keyStore.getKey(esiaKeyStoreParams.getName(), esiaKeyStoreParams.getValue().toCharArray()); // Получение приватного ключаX509Certificate certificate = (X509Certificate) keyStore.getCertificate(esiaKeyStoreParams.getName()); // Получение сертификата, он же  открытый ключ.

Где JCP.HD_STORE_NAME имя хранилища в КриптоПро, esiaKeyStoreParams.getName() имя контейнера и esiaKeyStoreParams.getValue().toCharArray() пароль контейнера.
В нашем случае не нужно загружать данные в хранилище методом load(), так как ключи уже будут там при указании имени этого хранилища.

Здесь важно помнить, что получения подписи в виде

final Signature signature = Signature.getInstance(SIGN_ALGORITHM, PROVIDER_NAME);signature.initSign(privateKey);signature.update(data);final byte[] sign = signature.sign();

нам недостаточно, так как ЕСИА требует откреплённую подпись формата PKCS7. Поэтому следует создать подпись формата PKCS7.

Пример нашего метода, возвращающего откреплённую подпись, выглядит следующим образом:

public String generateClientSecret(String rawClientSecret) throws Exception {    if (this.localCertificate == null || this.esiaCertificate == null) throw new RuntimeException("Signature creation is unavailable");    return CMS.cmsSign(rawClientSecret.getBytes(), localPrivateKey, localCertificate, true);  }

Здесь мы проверяем наличие нашего открытого ключа и открытого ключа ЕСИА. Так как метод cmsSign() может содержать конфиденциальную информацию, мы не будем его раскрывать.

Укажем лишь некоторые детали:

  • rawClientSecret.getBytes() байтовый массив scope, timestamp, clientId и state;
  • localPrivateKey приватный ключ из контейнера;
  • localCertificate публичный ключ из контейнера;
  • true булево значение параметра подписи открепленная или нет.

Пример создания подписи можно найти в java-библиотеке КриптоПро, там стандарт PKCS7 называется CMS. А также в руководстве программиста, которое лежит вместе с исходниками скаченной версии КриптоПро.

Получение токена


Следующий шаг получение маркера доступа (он же токен) в обмен на авторизационный код, который был получен в качестве параметра при успешной авторизации пользователя на портале Госуслуг.

Для получения каких-либо данных в ЕСИА нужно получить токен доступа. Для этого формируем запрос в ЕСИА. Основные поля запроса тут формируются аналогичным образом, в коде получается примерно следующее:

URI getTokenUri = new URIBuilder(ESIA_TOKEN_API_URL)        .addParameters(Arrays.asList(          new BasicNameValuePair(RequestEnum.CLIENT_ID.getParam(), requestDto.getClientId()),          new BasicNameValuePair(RequestEnum.CODE.getParam(), code),          new BasicNameValuePair(RequestEnum.GRANT_TYPE.getParam(), requestDto.getGrantType()),          new BasicNameValuePair(RequestEnum.CLIENT_SECRET.getParam(), requestDto.getClientSecret()),          new BasicNameValuePair(RequestEnum.STATE.getParam(), requestDto.getState()),          new BasicNameValuePair(RequestEnum.REDIRECT_URI.getParam(), requestDto.getRedirectUri()),          new BasicNameValuePair(RequestEnum.SCOPE.getParam(), requestDto.getScope()),          new BasicNameValuePair(RequestEnum.TIMESTAMP.getParam(), requestDto.getTimestamp()),          new BasicNameValuePair(RequestEnum.TOKEN_TYPE.getParam(), requestDto.getTokenType())        ))        .build();HttpUriRequest getTokenPostRequest = RequestBuilder.post()        .setUri(getTokenUri)        .setHeader(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded")        .build();

Получив ответ, распарсим его и получим токен:

try (CloseableHttpResponse response = httpClient.execute(getTokenPostRequest)) {  HttpEntity tokenEntity = response.getEntity();  String tokenEntityString = EntityUtils.toString(tokenEntity);  tokenResponseDto = extractEsiaGetResponseTokenDto(tokenEntityString);}

Токен представляет собой строку, состоящую из трёх частей и разделённых точками: HEADER.PAYLOAD.SIGNATURE, где:

  • HEADER это заголовок, имеющий в себе свойства токена, в том числе алгоритм подписи;
  • PAYLOAD это информация о токене и субъекте, которую запрашиваем у Госуслуг;
  • Signature это подпись HEADER.PAYLOAD.

Валидация токена


Для того, чтобы убедиться, что мы получили ответ именно от Госуслуг, необходимо провалидировать токен, указав путь к сертификату (открытому ключу), который можно скачать с сайта Госуслуг. Передав в метод isEsiaSignatureValid() полученную строку (data) и подпись (dataSignature), можно получить результат валидации в виде булева значения.

public static boolean isEsiaSignatureValid(String data, String dataSignature) throws Exception {  InputStream inputStream = EsiaUtil.class.getClassLoader().getResourceAsStream(CERTIFICATE); // Публичный ключ ЕСИА, представленный как поток  CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); // Создание объекта фабрики с указанием стандарта открытого ключа X.509  X509Certificate certificate = (X509Certificate) certFactory.generateCertificate(inputStream);  Signature signature = Signature.getInstance(certificate.getSigAlgName(), new JCP()); // Создание экземпляра класса Signature с указанием алгоритма подписи и провайдера JCP от КриптоПро  signature.initVerify(certificate.getPublicKey()); // Инициализация открытого ключа для верификации  signature.update(data.getBytes()); // Загрузка байтового массива строки, которую нужно верифицировать   return signature.verify(Base64.getUrlDecoder().decode(dataSignature));}

В соответствии с методическими указаниями необходимо проверять срок действия маркера. Если срок действия истек, то нужно создать новую ссылку с дополнительными параметрами и сделать запрос с помощью http-клиента:

URI refreshTokenUri = new URIBuilder(ESIA_TOKEN_API_URL)        .addParameters(Arrays.asList(                new BasicNameValuePair(RequestEnum.CLIENT_ID.getParam(), requestDto.getClientId()),                new BasicNameValuePair(RequestEnum.REFRESH_TOKEN.getParam(), tokenResponseDto.getRefreshToken()),                new BasicNameValuePair(RequestEnum.CODE.getParam(), code),                new BasicNameValuePair(RequestEnum.GRANT_TYPE.getParam(), EsiaConstants.REFRESH_GRANT_TYPE),                new BasicNameValuePair(RequestEnum.STATE.getParam(), requestDto.getState()),                new BasicNameValuePair(RequestEnum.SCOPE.getParam(), requestDto.getScope()),                new BasicNameValuePair(RequestEnum.TIMESTAMP.getParam(), requestDto.getTimestamp()),                new BasicNameValuePair(RequestEnum.TOKEN_TYPE.getParam(), requestDto.getTokenType()),                new BasicNameValuePair(RequestEnum.CLIENT_SECRET.getParam(), requestDto.getClientSecret()),                new BasicNameValuePair(RequestEnum.REDIRECT_URI.getParam(), requestDto.getRedirectUri())        ))        .build();

Получение данных пользователя


В нашем случае необходимо получить ФИО, дату рождения, паспортные данные и контакты.
Используем функциональный интерфейс, который поможет получать данные пользователя:

Function<String, String> esiaPersonDataFetcher = (fetchingUri) -> {  try {    URI getDataUri = new URIBuilder(fetchingUri).build();    HttpGet dataHttpGet = new HttpGet(getDataUri);       dataHttpGet.addHeader("Authorization", requestDto.getTokenType() + " " + tokenResponseDto.getAccessToken());    try (CloseableHttpResponse dataResponse = httpClient.execute(dataHttpGet)) {      HttpEntity dataEntity = dataResponse.getEntity();      return EntityUtils.toString(dataEntity);    }  } catch (Exception e) {    throw new UndeclaredThrowableException(e);  }};

Получение данных пользователя:

String personDataEntityString = esiaPersonDataFetcher.apply(ESIA_REST_API_URL + "/prns/" + esiaAccountId);

Получение контактов выглядит уже не таким очевидным, как получение данных пользователя. Для начала следует получить список ссылок на контакты:

String contactsListEntityString = esiaPersonDataFetcher.apply(ESIA_REST_API_URL + "/prns/" + esiaAccountId + "/ctts");EsiaListDto esiaListDto = objectMapper.readValue(contactsListEntityString, EsiaListDto.class);

Десериализуем этот список и получим объект esiaListDto. Поля из методички ЕСИА могут различаться, поэтому стоит проверить опытным путем.

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

for (String contactUrl : esiaListDto.getElementUrls()) {  String contactEntityString = esiaPersonDataFetcher.apply(contactUrl);  EsiaContactDto esiaContactDto = objectMapper.readValue(contactEntityString, EsiaContactDto.class);}

С получением списка документов та же ситуация. Вначале получаем список ссылок на документы:

String documentsListEntityString = esiaPersonDataFetcher.apply(ESIA_REST_API_URL + "/prns/" + esiaAccountId + "/docs");

Затем десериализуем его:

EsiaListDto esiaDocumentsListDto = objectMapper.readValue(documentsListEntityString, EsiaListDto.class);Переходим по каждой ссылке и получаем документы:for (String documentUrl : esiaDocumentsListDto.getElementUrls()) {  String documentEntityString = esiaPersonDataFetcher.apply(documentUrl);  EsiaDocumentDto esiaDocumentDto = objectMapper.readValue(documentEntityString, EsiaDocumentDto.class);}

Что теперь делать со всеми этими данными?


Мы можем сделать парсинг данных и получать объекты с требуемыми полями. Здесь каждый разработчик может оформлять классы как ему необходимо, в соответствии с техническим заданием.

Пример получения объекта с необходимыми полями:

final ObjectMapper objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);String personDataEntityString = esiaPersonDataFetcher.apply(ESIA_REST_API_URL + "/prns/" + esiaAccountId);EsiaPersonDto esiaPersonDto = objectMapper.readValue(personDataEntityString, EsiaPersonDto.class);

Заполняем объект esiaPersonDto необходимыми данными, например, контактами:

for (String contactUrl : esiaListDto.getElementUrls()) {  String contactEntityString = esiaPersonDataFetcher.apply(contactUrl);  EsiaContactDto esiaContactDto = objectMapper.readValue(contactEntityString, EsiaContactDto.class); // Десериализация контакта  if (esiaContactDto.getType() == null) continue;  switch (esiaContactDto.getType().toUpperCase()) {    case EsiaContactDto.MBT: // Если это номер мобильного телефона, то заполним поле mobilePhone      esiaPersonDto.setMobilePhone(esiaContactDto.getValue());      break;    case EsiaContactDto.EML: // Если это адрес электронной почты, то заполним поле email      esiaPersonDto.setEmail(esiaContactDto.getValue());  }}

Класс EsiaPersonDto выглядит следующим образом:

@Data@FieldNameConstants(prefix = "")public class EsiaPersonDto {  private String firstName;  private String lastName;  private String middleName;  private String birthDate;  private String birthPlace;  private Boolean trusted;  // тип учетной записи - подтверждена (true) / не подтверждена (false)  private String status;    // статус УЗ - Registered (зарегистрирована) /Deleted (удалена)  // Назначение полей непонятно, но они есть при запросе /prns/{oid}  private List<String> stateFacts;  private String citizenship;  private Long updatedOn;  private Boolean verifying;  @JsonProperty("rIdDoc")  private Integer documentId;  private Boolean containsUpCfmCode;  @JsonProperty("eTag")  private String tag;  // ----------------------------------------  private String mobilePhone;  private String email;  @javax.validation.constraints.Pattern(regexp = "(\\d{2})\\s(\\d{2})")  private String docSerial;  @javax.validation.constraints.Pattern(regexp = "(\\d{6})")  private String docNumber;  private String docIssueDate;  @javax.validation.constraints.Pattern(regexp = "([0-9]{3})\\-([0-9]{3})")  private String docDepartmentCode;  private String docDepartment;  @javax.validation.constraints.Pattern(regexp = "\\d{14}")  @JsonProperty("snils")  private String pensionFundCertificateNumber;  @javax.validation.constraints.Pattern(regexp = "\\d{12}")  @JsonProperty("inn")  private String taxPayerNumber;  @JsonIgnore  @javax.validation.constraints.Pattern(regexp = "\\d{2}")  private String taxPayerCertificateSeries;  @JsonIgnore  @javax.validation.constraints.Pattern(regexp = "\\d{10}")  private String taxPayerCertificateNumber;}

Работа по усовершенствованию сервиса будет продолжаться, ведь ЕСИА не стоит на месте.
Источник: habr.com
К списку статей
Опубликовано: 04.12.2020 10:17:32
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании русфинанс банк

Java

It-инфраструктура

Tutorial

Есиа

Госуслуги

Интеграция с есиа

Разработка на java

Криптопро

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru