بفضل تنفيذ تقييم الأثر البيئي والاجتماعي (ESIA) ، تمكن مطورو ومالكو الخدمات عبر الإنترنت من تسريع عمليات الإدخال والتحقق من بيانات المستخدم والتحقق منها ، وذلك بفضل تنفيذ ESIA. قرر بنك Rusfinance أيضًا استخدام إمكانات النظام ، وعند الانتهاء من خدمة معالجة القروض عبر الإنترنت (البنك متخصص في قروض السيارات) ، نفذ التكامل مع المنصة.
لم يكن هذا من السهل القيام به. كان من الضروري تلبية عدد من المتطلبات والإجراءات لحل الصعوبات الفنية.
في هذه المقالة ، سنحاول إخبارك بالنقاط الرئيسية والإرشادات المنهجية التي من المهم معرفتها لأولئك الذين يرغبون في تنفيذ التكامل مع ESIA بشكل مستقل ، بالإضافة إلى توفير أجزاء التعليمات البرمجية في Java التي ستساعد في التغلب على الصعوبات أثناء التطوير (تم حذف جزء من التنفيذ ، ولكن التسلسل العام للإجراءات واضح).
نأمل أن تساعد تجربتنا مطوري Java (وليس فقط) في توفير الكثير من الوقت عند تطوير والتعرف على التوصيات المنهجية لوزارة الاتصالات والاتصال الجماهيري.
لماذا نحتاج إلى التكامل مع ESIA؟
بسبب وباء الفيروس التاجي ، بدأ عدد المعاملات غير المتصلة بالإنترنت في العديد من مجالات الإقراض في الانخفاض. بدأ العملاء في "الاتصال بالإنترنت" وكان من الضروري لنا تعزيز تواجدنا عبر الإنترنت في سوق قروض السيارات. في عملية إنهاء خدمة الاعتماد التلقائي (يوجد لدى Habré بالفعل مقال حول تطويرها ) ، قررنا جعل واجهة وضع طلبات القروض على موقع البنك الإلكتروني مريحة وبسيطة قدر الإمكان. أصبح التكامل مع ESIA لحظة أساسية في حل هذه المشكلة ، حيث أتاح الحصول تلقائيًا على البيانات الشخصية للعميل.
بالنسبة للعميل ، اتضح أن هذا الحل مناسب أيضًا ، لأنه أتاح استخدام تسجيل دخول واحد وكلمة مرور واحدة للتسجيل والدخول إلى خدمة الموافقة عبر الإنترنت لطلبات شراء سيارة بالائتمان.
بالإضافة إلى ذلك ، سمح التكامل مع ESIA لبنك Rusfinance بما يلي:
- تقليل وقت ملء الاستبيانات عبر الإنترنت ؛
- تقليل عدد ارتداد المستخدم عند محاولة ملء عدد كبير من الحقول يدويًا ؛
- توفير تدفق المزيد من "الجودة" ، والعملاء المعتمدين.
على الرغم من أننا نتحدث عن تجربة مصرفنا ، إلا أن المعلومات يمكن أن تكون مفيدة ليس فقط للمؤسسات المالية. توصي الحكومة باستخدام منصة ESIA لأنواع أخرى من الخدمات عبر الإنترنت (مزيد من التفاصيل هنا ).
ماذا تفعل وكيف؟
في البداية ، بدا لنا أنه لا يوجد شيء مميز في التكامل مع ESIA من وجهة نظر فنية - مهمة قياسية مرتبطة بالحصول على البيانات من خلال REST API. ومع ذلك ، بعد الفحص الدقيق ، اتضح أنه ليس كل شيء بهذه البساطة. على سبيل المثال ، اتضح أنه ليس لدينا أي فكرة عن كيفية التعامل مع الشهادات المطلوبة لتوقيع العديد من المعلمات. كان علي أن أضيع الوقت وأكتشف ذلك. لكن أول الأشياء أولاً.
بادئ ذي بدء ، كان من المهم تحديد الخطوط العريضة لخطة عمل. تضمنت خطتنا الخطوات الرئيسية التالية:
- التسجيل في بوابة تكنولوجيا ESIA ؛
- تقديم الطلبات لاستخدام واجهات برمجيات ESIA في بيئة الاختبار والصناعية ؛
- تطوير آلية للتفاعل مع تقييم الأثر البيئي والاجتماعي (وفقًا للوثيقة الحالية "التوصيات المنهجية لاستخدام تقييم الأثر البيئي والاجتماعي") ؛
- اختبار تشغيل الآلية في الاختبار والبيئة الصناعية لتقييم الأثر البيئي والاجتماعي.
نحن عادة نطور مشاريعنا في جافا. لذلك ، بالنسبة لتنفيذ البرنامج ، اخترنا:
- IntelliJ IDEA ؛
- CryptoPro JCP (أو CryptoPro Java CSP) ؛
- جافا 8 ؛
- اباتشي HttpClient ؛
- لومبوك.
- FasterXML / جاكسون.
الحصول على عنوان URL لإعادة التوجيه
الخطوة الأولى هي الحصول على رمز التفويض. في حالتنا ، يتم ذلك من خلال خدمة منفصلة مع إعادة توجيه إلى صفحة التفويض الخاصة ببوابة خدمات الدولة (سنخبرك عن هذا بمزيد من التفصيل).
أولاً ، نقوم بتهيئة المتغيرات ESIA_AUTH_URL (عنوان ESIA) و API_URL (العنوان الذي تتم إعادة التوجيه إليه في حالة التفويض الناجح). بعد ذلك ، نقوم بإنشاء كائن EsiaRequestParams ، والذي يحتوي على معلمات الطلب إلى ESIA في حقوله ، وسنشكل رابط 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;
}
}
بعد ذلك ، تحتاج إلى إعادة توجيه المستخدم إلى خدمة مصادقة ESIA. يقوم المستخدم بإدخال اسم المستخدم وكلمة المرور الخاصة به ، ويؤكد الوصول إلى البيانات الخاصة بنظامنا. ثم يرسل ESIA ردًا إلى الخدمة عبر الإنترنت ، والذي يحتوي على رمز التفويض. ستكون هناك حاجة إلى هذا الرمز لمزيد من الاستفسارات إلى ESIA.
يحتوي كل طلب إلى ESIA على معلمة client_secret ، وهي عبارة عن توقيع إلكتروني منفصل بتنسيق PKCS7 (معيار تشفير المفتاح العام). في حالتنا ، يتم استخدام شهادة للتوقيع ، والتي تم استلامها من قبل المرجع المصدق قبل بدء العمل على التكامل مع نظام تحديد الهوية والمصادقة الموحد. كيفية العمل مع متجر المفاتيح موصوفة جيدًا في هذه السلسلة من المقالات .
كمثال ، دعنا نوضح كيف يبدو مخزن المفاتيح الذي توفره CryptoPro كما يلي:
في هذه الحالة ، سيبدو استدعاء المفاتيح الخاصة والعامة كما يلي:
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 هو اسم التخزين في CryptoPro ، 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();
هذا لا يكفي بالنسبة لنا ، لأن ESIA يتطلب توقيع منفصل لصيغة 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);
}
هنا نتحقق من المفتاح العمومي والمفتاح العام ESIA. نظرًا لأن طريقة cmsSign () قد تحتوي على معلومات سرية ، فلن نكشف عنها.
فيما يلي بعض التفاصيل:
- rawClientSecret.getBytes () - مجموعة بايت من النطاق والطابع الزمني ومعرف العميل والحالة ؛
- localPrivateKey - المفتاح الخاص من الحاوية ؛
- شهادة محلية - مفتاح عام من الحاوية ؛
- صحيح - القيمة المنطقية لمعامل التوقيع - الخروج أم لا.
يمكن العثور على مثال لإنشاء توقيع في مكتبة CryptoPro java ، حيث يُطلق على معيار PKCS7 اسم CMS. وأيضًا في دليل المبرمج المضمن مع الكود المصدري للإصدار الذي تم تنزيله من CryptoPro.
الحصول على رمز
تتمثل الخطوة التالية في الحصول على رمز وصول (يُعرف أيضًا باسم رمز مميز) مقابل رمز التفويض ، والذي تم استلامه كمعامل بناءً على إذن مستخدم ناجح على بوابة خدمات الدولة.
لتلقي أي بيانات في نظام التعريف الموحد ، تحتاج إلى الحصول على رمز وصول. للقيام بذلك ، نقوم بتشكيل طلب إلى ESIA. يتم تشكيل حقول الطلب الرئيسية هنا بنفس الطريقة ، ويبدو الكود كما يلي:
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 هي معلومات حول الرمز المميز والموضوع الذي نطلبه من خدمات الدولة ؛
- التوقيع هو توقيع HEADER.PAYLOAD.
التحقق من صحة الرمز
من أجل التأكد من تلقينا ردًا من خدمات الدولة ، من الضروري التحقق من صحة الرمز من خلال تحديد المسار إلى الشهادة (المفتاح العام) ، والتي يمكن تنزيلها من موقع خدمات الدولة. من خلال تمرير السلسلة (البيانات) المستلمة والتوقيع (توقيع البيانات) إلى طريقة isEsiaSignatureValid () ، يمكنك الحصول على نتيجة التحقق كقيمة منطقية.
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. قد تختلف الحقول من دليل ESIA ، لذا فإن الأمر يستحق التحقق من الناحية التجريبية.
بعد ذلك ، تحتاج إلى اتباع كل رابط من القائمة للحصول على جهة اتصال لكل مستخدم. سيبدو مثل هذا:
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;
}
سيستمر العمل على تحسين الخدمة ، لأن تقييم الأثر البيئي والاجتماعي لا يزال قائماً.