Как реализовать интеграцию с ЕСИА на 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;
}

Работа по усовершенствованию сервиса будет продолжаться, ведь ЕСИА не стоит на месте.
Источник: https://habr.com/ru/company/rusfinancebank/blog/531230/


Интересные статьи

Интересные статьи

Достаточно часто случается такое, что специалист обесценивает себя. Даже несмотря на многочисленные реальные факты о его компетентности, он все равно продолжает искать недостатки Такое с...
Уже прошло почти восемь месяцев 2020 года, а технические прогнозы на этот год всё выходят и выходят. И это — несмотря на то, что очень сложно предсказать будущее в такой динамично раз...
Привет, Хаброжители! Хотите сделать отличный подарок ребёнку, желающему научиться программировать, или научить взрослого, далёкого от мира кодов? Тогда книга-героиня нашего поста Вам подойдет. Э...
За последние несколько лет в том, что называют «ценой JavaScript», наблюдаются серьёзные положительные изменения благодаря повышению скорости парсинга и компиляции скриптов браузерами. Сейчас, в ...
Добрый день! Поговорим про соблюдение ПДД и варианты их решения. Сразу замечу, что я не рекламирую готовый продукт: я сам не могу реализовать подобное, так как для этого нужно большое количест...