Cómo implementar la integración con ESIA en Java sin problemas innecesarios

Durante mucho tiempo, la principal forma de identificar a los ciudadanos fue un pasaporte ordinario. La situación cambió cuando, en 2011, por orden del Ministerio de Telecomunicaciones y Comunicaciones Masivas, se introdujo el Sistema Unificado de Identificación y Autenticación (ESIA), que permitió reconocer la identidad de una persona y recibir datos sobre ella en línea.



Gracias a la implementación de la ESIA, las organizaciones gubernamentales y comerciales, los desarrolladores y propietarios de servicios en línea pudieron acelerar y hacer más seguras las operaciones relacionadas con la entrada y verificación de los datos de los usuarios. Rusfinance Bank también decidió aprovechar el potencial del sistema y, al finalizar el servicio de procesamiento de préstamos en línea (el banco se especializa en préstamos para automóviles), implementó la integración con la plataforma.



Esto no fue tan fácil de hacer. Fue necesario cumplir con una serie de requisitos y procedimientos para resolver dificultades técnicas.



En este artículo, intentaremos informarle sobre los puntos principales y las pautas metodológicas que es importante conocer para aquellos que desean implementar de forma independiente la integración con la ESIA, así como proporcionar fragmentos de código en Java que ayudarán a superar las dificultades durante el desarrollo (se omite parte de la implementación, pero la secuencia general de acciones es claro).



Esperamos que nuestra experiencia ayude a los desarrolladores de Java (y no solo) a ahorrar mucho tiempo al desarrollar y familiarizarse con las recomendaciones metodológicas del Ministerio de Telecomunicaciones y Comunicaciones Masivas.







¿Por qué necesitamos la integración con ESIA?



En relación con la pandemia de coronavirus, la cantidad de transacciones fuera de línea en muchas áreas de préstamos comenzó a disminuir. Los clientes comenzaron a "conectarse" y era vital para nosotros fortalecer nuestra presencia en línea en el mercado de préstamos para automóviles. En el proceso de finalizar el servicio de Autocredit (Habré ya tiene un artículo sobre su desarrollo ), decidimos hacer que la interfaz para colocar solicitudes de préstamos en el sitio web del banco sea lo más conveniente y simple posible. La integración con ESIA se ha convertido en un momento clave para resolver este problema, ya que permitió obtener automáticamente los datos personales del cliente.







Para el cliente, esta solución también resultó conveniente, ya que permitía utilizar un único login y contraseña para registrarse e ingresar al servicio de aprobación en línea de solicitudes para la compra de un automóvil a crédito.



Además, la integración con ESIA permitió a Rusfinance Bank:



  • reducir el tiempo para completar cuestionarios en línea;
  • reduzca la cantidad de rebotes de usuario cuando intente completar una gran cantidad de campos manualmente;
  • para proporcionar un flujo de clientes verificados de "calidad".


A pesar de que contamos la experiencia de nuestro banco, la información puede ser útil no solo para las instituciones financieras. El gobierno recomienda utilizar la plataforma ESIA para otros tipos de servicios en línea (más detalles aquí ).



Que hacer y como



Al principio, nos pareció que no había nada especial en la integración con la ESIA desde un punto de vista técnico, una tarea estándar asociada con la obtención de datos a través de la API REST. Sin embargo, tras un examen más detenido, quedó claro que no todo es tan simple. Por ejemplo, resultó que no tenemos idea de cómo trabajar con los certificados necesarios para firmar múltiples parámetros. Tuve que perder el tiempo y resolverlo. Pero lo primero es lo primero.



Para empezar, era importante esbozar un plan de acción. Nuestro plan incluyó los siguientes pasos principales:



  1. registrarse en el portal de tecnología ESIA;
  2. enviar solicitudes para el uso de las interfaces de software ESIA en un entorno industrial y de prueba;
  3. desarrollar de forma independiente un mecanismo de interacción con la EIAS (de acuerdo con el documento actual "Recomendaciones metodológicas para el uso de la EIAS");
  4. probar el funcionamiento del mecanismo en el entorno industrial y de prueba de la ESIA.


Normalmente desarrollamos nuestros proyectos en Java. Por lo tanto, para la implementación del software elegimos:



  • IntelliJ IDEA;
  • CryptoPro JCP (o CryptoPro Java CSP);
  • Java 8;
  • Apache HttpClient;
  • Lombok;
  • FasterXML / Jackson.


Obtener la URL de redireccionamiento



El primer paso es obtener un código de autorización. En nuestro caso, esto se realiza mediante un servicio separado con un redireccionamiento a la página de autorización del portal de Servicios del Estado (le informaremos sobre esto con un poco más de detalle).



Primero, inicializamos las variables ESIA_AUTH_URL (la dirección ESIA) y API_URL (la dirección a la que se produce el redireccionamiento en caso de autorización exitosa). Después de eso, creamos el objeto EsiaRequestParams, que contiene los parámetros de la solicitud al ESIA en sus campos, y formamos el enlace 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();
}
      
      





Para mayor claridad, mostremos cómo se vería la clase 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;
  }
}
      
      





Después de eso, debe redirigir al usuario al servicio de autenticación ESIA. El usuario ingresa su nombre de usuario-contraseña, confirma el acceso a los datos de nuestro sistema. Luego, ESIA envía una respuesta al servicio en línea, que contiene un código de autorización. Este código será necesario para futuras consultas a la ESIA.



Cada solicitud a la ESIA tiene un parámetro client_secret, que es una firma electrónica separada en el formato PKCS7 (Estándar de criptografía de clave pública). En nuestro caso, se utiliza un certificado para la firma, que fue recibido por el centro de certificación antes de comenzar a trabajar en la integración con la ESIA. En esta serie de artículos se describe detalladamente cómo trabajar con una tienda de claves .



Como ejemplo, mostremos cómo se ve el almacén de claves proporcionado por CryptoPro:







En este caso, llamar a las claves pública y privada se verá así:



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()); //  ,   –  .
      
      





Donde JCP.HD_STORE_NAME es el nombre de almacenamiento en CryptoPro, esiaKeyStoreParams.getName () es el nombre del contenedor y esiaKeyStoreParams.getValue (). ToCharArray () es la contraseña del contenedor.

En nuestro caso, no es necesario cargar datos en el almacenamiento utilizando el método load (), ya que las claves ya estarán allí al especificar el nombre de este almacenamiento.



Es importante recordar aquí que obtener una firma en el formulario



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





no es suficiente para nosotros, ya que la ESIA requiere una firma separada del formato PKCS7. Por lo tanto, se debe generar una firma en formato PKCS7.



Un ejemplo de nuestro método que devuelve una firma separada se ve así:



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);
  }
      
      





Aquí verificamos nuestra clave pública y la clave pública ESIA. Dado que el método cmsSign () puede contener información confidencial, no la divulgaremos.



Estos son solo algunos detalles:



  • rawClientSecret.getBytes () - matriz de bytes de alcance, marca de tiempo, clientId y estado;
  • localPrivateKey: clave privada del contenedor;
  • localCertificate: clave pública del contenedor;
  • verdadero: valor booleano del parámetro de firma: pago o no.


Se puede encontrar un ejemplo de creación de una firma en la biblioteca java de CryptoPro, donde el estándar PKCS7 se llama CMS. Y también en el manual del programador, que se incluye con el código fuente de la versión descargada de CryptoPro.



Conseguir una ficha



El siguiente paso es recibir un token de acceso (también conocido como token) a cambio de un código de autorización que se recibió como parámetro tras la autorización exitosa del usuario en el portal de Servicios del Estado.



Para recibir cualquier dato en el sistema de identificación unificado, necesita obtener un token de acceso. Para ello, formamos una solicitud a la ESIA. Los campos de solicitud principales aquí están formados de la misma manera, el código tiene el siguiente aspecto:



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();

      
      





Habiendo recibido la respuesta, analícela y obtenga el token:



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

      
      





El token es una cadena de tres partes separadas por puntos: HEADER.PAYLOAD.SIGNATURE, donde:



  • HEADER es un encabezado que tiene las propiedades de un token, incluido un algoritmo de firma;
  • PAYLOAD es información sobre el token y el tema, que solicitamos a los Servicios del Estado;
  • La firma es la firma de HEADER.PAYLOAD.


Validación de tokens



Para asegurarnos de que recibimos una respuesta de los Servicios del Estado, es necesario validar el token especificando la ruta al certificado (clave pública), que se puede descargar del sitio web de Servicios del Estado. Al pasar la cadena recibida (datos) y la firma (dataSignature) al método isEsiaSignatureValid (), puede obtener el resultado de la validación como un valor booleano.



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));
}
      
      





De acuerdo con las pautas, es necesario verificar el período de validez del token. Si el período de validez expiró, debe crear un nuevo enlace con parámetros adicionales y realizar una solicitud utilizando el cliente 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();
      
      





Recuperando datos de usuario



En nuestro caso, debe obtener su nombre completo, fecha de nacimiento, detalles del pasaporte y contactos.

Usamos una interfaz funcional que ayudará a recibir los datos del usuario:



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);
  }
};
      
      





Obteniendo datos de usuario:



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





Obtener contactos ya no es tan obvio como obtener datos de usuario. Primero, debe obtener una lista de enlaces a contactos:



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





Deserialice esta lista y obtenga el objeto esiaListDto. Los campos del manual de la EIAS pueden diferir, por lo que vale la pena verificarlos empíricamente.



A continuación, debe seguir cada enlace de la lista para obtener el contacto de cada usuario. Se verá así:



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





La situación es la misma con la obtención de una lista de documentos. Primero, obtenemos una lista de enlaces a documentos:



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

      
      





Luego deserializarlo:



EsiaListDto esiaDocumentsListDto = objectMapper.readValue(documentsListEntityString, EsiaListDto.class);
      :
for (String documentUrl : esiaDocumentsListDto.getElementUrls()) {
  String documentEntityString = esiaPersonDataFetcher.apply(documentUrl);
  EsiaDocumentDto esiaDocumentDto = objectMapper.readValue(documentEntityString, EsiaDocumentDto.class);
}

      
      





Ahora, ¿qué hacer con todos estos datos?



Podemos analizar los datos y obtener objetos con los campos obligatorios. Aquí, cada desarrollador puede diseñar las clases que necesite, de acuerdo con los términos de referencia.



Un ejemplo de cómo obtener un objeto con los campos obligatorios:



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);

      
      





Rellenamos el objeto esiaPersonDto con los datos necesarios, por ejemplo, contactos:



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());
  }
}

      
      





La clase EsiaPersonDto se ve así:



@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;
}
      
      





Continuará el trabajo de mejora del servicio, porque la EIAS no se detiene.



All Articles