GrĂące Ă la mise en Ćuvre de l'EIES, les organisations gouvernementales et commerciales, les dĂ©veloppeurs et les propriĂ©taires de services en ligne ont pu accĂ©lĂ©rer et sĂ©curiser davantage les opĂ©rations liĂ©es Ă la saisie et Ă la vĂ©rification des donnĂ©es des utilisateurs. Rusfinance Bank a Ă©galement dĂ©cidĂ© d'utiliser le potentiel du systĂšme et, lors de la finalisation du service de traitement des prĂȘts en ligne (la banque est spĂ©cialisĂ©e dans les prĂȘts automobiles), a mis en Ćuvre l'intĂ©gration avec la plateforme.
Ce n'était pas si facile à faire. Il était nécessaire de satisfaire à un certain nombre d'exigences et de procédures, pour résoudre des difficultés techniques.
Dans cet article, nous essaierons de vous parler des principaux points et directives méthodologiques qu'il est important de connaßtre pour ceux qui souhaitent implémenter indépendamment l'intégration avec l'EIES, ainsi que de fournir des fragments de code en Java qui aideront à surmonter les difficultés lors du développement (une partie de l'implémentation est omise, mais la séquence générale des actions est clair).
Nous espérons que notre expérience aidera les développeurs Java (et pas seulement) à gagner beaucoup de temps lors du développement et de la familiarisation avec les recommandations méthodologiques du ministÚre des Télécoms et des Communications de masse.
Pourquoi avons-nous besoin d'une intégration avec EIES?
En raison de la pandĂ©mie de coronavirus, le nombre de transactions hors ligne dans de nombreux domaines de prĂȘt a commencĂ© Ă diminuer. Les clients ont commencĂ© à «passer en ligne» et il Ă©tait essentiel pour nous de renforcer notre prĂ©sence en ligne sur le marchĂ© du crĂ©dit automobile. Dans le cadre de la finalisation du service AutocrĂ©dit (HabrĂ© a dĂ©jĂ un article sur son dĂ©veloppement ), nous avons dĂ©cidĂ© de rendre l'interface de dĂ©pĂŽt des demandes de crĂ©dit sur le site Internet de la banque aussi simple et pratique que possible. L'intĂ©gration avec ESIA est devenue un moment clĂ© pour rĂ©soudre ce problĂšme, car elle a permis d'obtenir automatiquement les donnĂ©es personnelles du client.
Pour le client, cette solution s'est également avérée pratique, car elle permettait d'utiliser un seul identifiant et mot de passe pour s'inscrire et accéder au service d'approbation en ligne des demandes d'achat d'une voiture à crédit.
De plus, l'intégration avec ESIA a permis à Rusfinance Bank de:
- réduire le temps de remplissage des questionnaires en ligne;
- réduire le nombre de rebonds des utilisateurs lorsqu'ils tentent de remplir manuellement un grand nombre de champs;
- fournir un flux de clients vérifiés de plus «qualité».
MalgrĂ© le fait que nous parlons de l'expĂ©rience de notre banque, les informations peuvent ĂȘtre utiles non seulement pour les institutions financiĂšres. Le gouvernement recommande d'utiliser la plateforme EIES pour d'autres types de services en ligne (plus de dĂ©tails ici ).
Que faire et comment?
Au début, il nous a semblé qu'il n'y avait rien de spécial dans l'intégration avec ESIA d'un point de vue technique - une tùche standard associée à la réception de données via l'API REST. Cependant, aprÚs un examen plus approfondi, il est devenu clair que tout n'est pas si simple. Par exemple, il s'est avéré que nous ne savions pas comment travailler avec les certificats requis pour signer plusieurs paramÚtres. J'ai dû perdre du temps et le découvrir. Mais tout d'abord.
Pour commencer, il Ă©tait important de dĂ©finir un plan dâaction. Notre plan comprenait les principales Ă©tapes suivantes:
- s'inscrire sur le portail technologique EIES;
- soumettre des demandes d'utilisation des interfaces logicielles EIES dans un environnement de test et industriel;
- développer indépendamment un mécanisme d'interaction avec l'EIES (conformément au document actuel "Recommandations méthodologiques pour l'utilisation de l'EIES");
- tester le fonctionnement du mécanisme dans l'environnement de test et industriel de l'EIES.
Nous dĂ©veloppons gĂ©nĂ©ralement nos projets en Java. Par consĂ©quent, pour la mise en Ćuvre du logiciel, nous avons choisi:
- IntelliJ IDEA;
- CryptoPro JCP (ou CryptoPro Java CSP);
- Java 8;
- Apache HttpClient;
- Lombok;
- FasterXML / Jackson.
Obtenir l'URL de redirection
La premiÚre étape consiste à obtenir un code d'autorisation. Dans notre cas, cela se fait par un service séparé avec une redirection vers la page d'autorisation du portail State Services (nous vous en parlerons plus en détail).
Tout d'abord, nous initialisons les variables ESIA_AUTH_URL (l'adresse ESIA) et API_URL (l'adresse Ă laquelle la redirection se produit en cas d'autorisation rĂ©ussie). AprĂšs cela, nous crĂ©ons l'objet EsiaRequestParams, qui contient les paramĂštres de la requĂȘte Ă l'EIES dans ses champs, et nous formerons le lien 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();
}
Pour plus de clarté, montrons à quoi la classe EsiaRequestParams pourrait ressembler:
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;
}
}
AprÚs cela, vous devez rediriger l'utilisateur vers le service d'authentification ESIA. L'utilisateur entre son nom d'utilisateur-mot de passe, confirme l'accÚs aux données de notre systÚme. Ensuite, ESIA envoie une réponse au service en ligne, qui contient un code d'autorisation. Ce code sera nécessaire pour les demandes ultérieures auprÚs de l'EIES.
Chaque demande adressée à l'EIES a un paramÚtre client_secret, qui est une signature électronique détachée au format PKCS7 (Public Key Cryptography Standard). Dans notre cas, un certificat est utilisé pour la signature, qui a été reçu par le centre de certification avant de commencer les travaux d'intégration avec l'EIES. Comment travailler avec un magasin de clés est bien décrit dans cette série d'articles .
à titre d'exemple, montrons à quoi ressemble le magasin de clés fourni par CryptoPro:
Dans ce cas, l'appel des clés privées et publiques ressemblera à ceci:
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()); // , â .
OĂč JCP.HD_STORE_NAME est le nom de stockage dans CryptoPro, esiaKeyStoreParams.getName () est le nom du conteneur et esiaKeyStoreParams.getValue (). ToCharArray () est le mot de passe du conteneur.
Dans notre cas, il n'est pas nécessaire de charger des données dans le stockage à l'aide de la méthode load (), puisque les clés seront déjà présentes lors de la spécification du nom de ce stockage.
Il est important de se rappeler ici que l'obtention d'une signature sous la forme
final Signature signature = Signature.getInstance(SIGN_ALGORITHM, PROVIDER_NAME);
signature.initSign(privateKey);
signature.update(data);
final byte[] sign = signature.sign();
cela ne nous suffit pas, car l'EIES nĂ©cessite une signature dĂ©tachĂ©e du format PKCS7. Par consĂ©quent, une signature au format PKCS7 doit ĂȘtre gĂ©nĂ©rĂ©e.
Un exemple de notre méthode renvoyant une signature détachée ressemble à ceci:
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);
}
Ici, nous vérifions notre clé publique et la clé publique EIES. La méthode cmsSign () pouvant contenir des informations confidentielles, nous ne les divulguerons pas.
Voici quelques détails:
- rawClientSecret.getBytes () - tableau d'octets de portée, horodatage, clientId et état;
- localPrivateKey - clé privée du conteneur;
- localCertificate - la clé publique du conteneur;
- true - valeur booléenne du paramÚtre de signature - checkout ou non.
Un exemple de crĂ©ation d'une signature peut ĂȘtre trouvĂ© dans la bibliothĂšque java CryptoPro, oĂč le standard PKCS7 est appelĂ© CMS. Et aussi dans le manuel du programmeur, qui est inclus avec le code source de la version tĂ©lĂ©chargĂ©e de CryptoPro.
Obtenir un jeton
L'Ă©tape suivante consiste Ă recevoir un jeton d'accĂšs (ou jeton) en Ă©change d'un code d'autorisation, qui a Ă©tĂ© reçu en tant que paramĂštre lors de l'autorisation d'utilisateur rĂ©ussie sur le portail des services d'Ătat.
Pour recevoir des donnĂ©es dans le systĂšme d'identification unifiĂ©, vous devez obtenir un jeton d'accĂšs. Pour ce faire, nous formons une demande auprĂšs de l'EIES. Les principaux champs de requĂȘte ici sont formĂ©s de la mĂȘme maniĂšre, le code ressemble Ă ce qui suit:
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();
AprÚs avoir reçu la réponse, analysez-la et récupérez le jeton:
try (CloseableHttpResponse response = httpClient.execute(getTokenPostRequest)) {
HttpEntity tokenEntity = response.getEntity();
String tokenEntityString = EntityUtils.toString(tokenEntity);
tokenResponseDto = extractEsiaGetResponseTokenDto(tokenEntityString);
}
Le jeton est une chaĂźne en trois parties sĂ©parĂ©es par des points: HEADER.PAYLOAD.SIGNATURE, oĂč:
- HEADER est un en-tĂȘte qui a les propriĂ©tĂ©s d'un jeton, y compris un algorithme de signature;
- PAYLOAD est une information sur le jeton et le sujet, que nous demandons aux services de l'Ătat;
- La signature est la signature de HEADER.PAYLOAD.
Validation des jetons
Afin de nous assurer que nous avons reçu une rĂ©ponse des services de l'Ătat, il est nĂ©cessaire de valider le jeton en spĂ©cifiant le chemin d'accĂšs au certificat (clĂ© publique), qui peut ĂȘtre tĂ©lĂ©chargĂ© Ă partir du site Web des services de l'Ătat. En passant la chaĂźne (data) et la signature (dataSignature) reçues Ă la mĂ©thode isEsiaSignatureValid (), vous pouvez obtenir le rĂ©sultat de la validation sous forme de valeur boolĂ©enne.
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));
}
Conformément aux directives, il est nécessaire de vérifier la période de validité du token. Si la période de validité a expiré, vous devez créer un nouveau lien avec des paramÚtres supplémentaires et faire une demande à l'aide du client 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();
Récupération des données utilisateur
Dans notre cas, vous devez obtenir votre nom complet, votre date de naissance, les détails de votre passeport et vos contacts.
Nous utilisons une interface fonctionnelle qui aidera à recevoir les données des utilisateurs:
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);
}
};
Obtention des données utilisateur:
String personDataEntityString = esiaPersonDataFetcher.apply(ESIA_REST_API_URL + "/prns/" + esiaAccountId);
Obtenir des contacts n'est plus aussi évident que d'obtenir des données utilisateur. Tout d'abord, vous devriez obtenir une liste de liens vers des contacts:
String contactsListEntityString = esiaPersonDataFetcher.apply(ESIA_REST_API_URL + "/prns/" + esiaAccountId + "/ctts");
EsiaListDto esiaListDto = objectMapper.readValue(contactsListEntityString, EsiaListDto.class);
Désérialisez cette liste et récupérez l'objet esiaListDto. Les champs du manuel EIES peuvent différer, il vaut donc la peine de vérifier empiriquement.
Ensuite, vous devez suivre chaque lien de la liste pour obtenir chaque contact utilisateur. Il ressemblera Ă ceci:
for (String contactUrl : esiaListDto.getElementUrls()) {
String contactEntityString = esiaPersonDataFetcher.apply(contactUrl);
EsiaContactDto esiaContactDto = objectMapper.readValue(contactEntityString, EsiaContactDto.class);
}
La situation est la mĂȘme avec l'obtention d'une liste de documents. Tout d'abord, nous obtenons une liste de liens vers des documents:
String documentsListEntityString = esiaPersonDataFetcher.apply(ESIA_REST_API_URL + "/prns/" + esiaAccountId + "/docs");
Puis désérialisez-le:
EsiaListDto esiaDocumentsListDto = objectMapper.readValue(documentsListEntityString, EsiaListDto.class);
:
for (String documentUrl : esiaDocumentsListDto.getElementUrls()) {
String documentEntityString = esiaPersonDataFetcher.apply(documentUrl);
EsiaDocumentDto esiaDocumentDto = objectMapper.readValue(documentEntityString, EsiaDocumentDto.class);
}
Que faire maintenant de toutes ces données?
Nous pouvons analyser les données et obtenir des objets avec les champs obligatoires. Ici, chaque développeur peut concevoir des classes selon ses besoins, conformément aux termes de référence.
Un exemple d'obtention d'un objet avec les champs obligatoires:
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);
Nous remplissons l'objet esiaPersonDto avec les données nécessaires, par exemple les contacts:
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 classe EsiaPersonDto ressemble Ă ceci:
@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;
}
Le travail d'amĂ©lioration du service se poursuivra, car l'EIES ne s'arrĂȘte pas.