From b1df015e94bbd90d587a41159edbb130e429bb0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Pe=C5=A1ek?= Date: Thu, 14 Dec 2023 13:01:44 +0100 Subject: [PATCH] Fix #131: Merge upstream (#132) Merge remote-tracking branch 'upstream/develop' into issues/131-merge-upstram --- docs/onboarding/Configuration-Properties.md | 28 +- .../Configuration-Verification-Providers.md | 2 + .../error/ActivationOtpErrorResponse.java | 14 +- .../api/provider/PresenceCheckProvider.java | 20 +- .../IdentityVerificationException.java | 4 + .../model/integration/SessionInfo.java | 5 + .../pom.xml | 12 + .../innovatrics/InnovatricsApiService.java | 338 +++++++++++++++- .../innovatrics/InnovatricsConfigProps.java | 18 + ...novatricsDocumentVerificationProvider.java | 363 +++++++++++++++++- .../InnovatricsLivenessController.java | 93 +++++ .../InnovatricsLivenessService.java | 133 +++++++ .../InnovatricsPresenceCheckProvider.java | 16 +- ...tricsDocumentVerificationProviderTest.java | 174 +++++++++ .../InnovatricsPresenceCheckProviderTest.java | 2 +- .../InnovatricsRestApiServiceTest.java | 138 +++++++ .../resources/application-test.properties | 8 + .../iproov/IProovPresenceCheckProvider.java | 4 +- .../configuration/OpenApiConfiguration.java | 3 +- ...ultraMockDocumentVerificationProvider.java | 6 +- .../impl/service/PresenceCheckService.java | 62 ++- .../document/DocumentProcessingService.java | 219 ++++++++--- .../WultraMockPresenceCheckProvider.java | 4 +- .../src/main/resources/application.properties | 9 +- .../src/main/resources/banner.txt | 9 + ...aMockDocumentVerificationProviderTest.java | 2 +- .../service/PresenceCheckServiceTest.java | 74 +++- .../DocumentProcessingServiceTest.java | 282 ++++++++++++-- .../DocumentProcessingServiceTest.sql | 2 + ...gServiceTest.testPairTwoSidedDocuments.sql | 6 + ...ssingServiceTest.testResubmitDocuments.sql | 6 + .../src/main/resources/application.properties | 3 + .../src/main/resources/banner.txt | 9 + pom.xml | 12 +- 34 files changed, 1891 insertions(+), 189 deletions(-) create mode 100644 enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessController.java create mode 100644 enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessService.java create mode 100644 enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProviderTest.java create mode 100644 enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsRestApiServiceTest.java create mode 100644 enrollment-server-onboarding-provider-innovatrics/src/test/resources/application-test.properties create mode 100644 enrollment-server-onboarding/src/main/resources/banner.txt create mode 100644 enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.sql create mode 100644 enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testPairTwoSidedDocuments.sql create mode 100644 enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testResubmitDocuments.sql create mode 100644 enrollment-server/src/main/resources/banner.txt diff --git a/docs/onboarding/Configuration-Properties.md b/docs/onboarding/Configuration-Properties.md index cbd0952d..1918751f 100644 --- a/docs/onboarding/Configuration-Properties.md +++ b/docs/onboarding/Configuration-Properties.md @@ -137,19 +137,21 @@ The Onboarding Server uses the following public configuration properties: ## Innovatrics Configuration -| Property | Default | Note | -|--------------------------------------------------------------------------------------------------|---------------------------|--------------------------------------------------------------------------------| -| `enrollment-server-onboarding.provider.innovatrics.serviceBaseUrl` | | Base REST service URL for Innovatrics. | -| `enrollment-server-onboarding.provider.innovatrics.serviceToken` | | Authentication token for Innovatrics. | -| `enrollment-server-onboarding.provider.innovatrics.serviceUserAgent` | `Wultra/OnboardingServer` | User agent to use when making HTTP calls to Innovatrics REST service. | -| `enrollment-server-onboarding.provider.innovatrics.presenceCheck.score` | 0.875 | Presence check minimal score threshold. | -| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.acceptInvalidSslCertificate` | `false` | Whether invalid SSL certificate is accepted when calling Zen ID REST service. | -| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.maxInMemorySize` | `10485760` | Maximum in memory size of HTTP requests when calling Innovatrics REST service. | -| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyEnabled` | `false` | Whether proxy server is enabled when calling Innovatrics REST service. | -| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyHost` | | Proxy host to be used when calling Innovatrics REST service. | -| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyPort` | 0 | Proxy port to be used when calling Innovatrics REST service. | -| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyUsername` | | Proxy username to be used when calling Innovatrics REST service. | -| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyPassword` | | Proxy password to be used when calling Innovatrics REST service. | +| Property | Default | Note | +|---------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `enrollment-server-onboarding.provider.innovatrics.serviceBaseUrl` | | Base REST service URL for Innovatrics. | +| `enrollment-server-onboarding.provider.innovatrics.serviceToken` | | Authentication token for Innovatrics. | +| `enrollment-server-onboarding.provider.innovatrics.serviceUserAgent` | `Wultra/OnboardingServer` | User agent to use when making HTTP calls to Innovatrics REST service. | +| `enrollment-server-onboarding.provider.innovatrics.presenceCheckConfiguration.score` | 0.875 | Presence check minimal score threshold. | +| `enrollment-server-onboarding.provider.innovatrics.documentVerificationConfiguration.documentCountries` | `CZE` | List of expected countries of issue of identification documents as three-letter country codes, i.e. ISO 3166-1 alpha-3. If empty, all countries of issue known to Innovatrics are considered during classification, which may have negative impact on performance. | +| `enrollment-server-onboarding.provider.innovatrics.document-verification-configuration.crucialFields` | `documentNumber`, `dateOfIssue`, `dateOfExpiry`, `surname`, `dateOfBirth`, `personalNumber`, `givenNames` | Set of fields in camelCase that are cross-validated between the machine-readable zone and visual zone. Only those specified fields that are actually extracted from a document are considered. If empty, no inconsistencies will be checked. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.acceptInvalidSslCertificate` | `false` | Whether invalid SSL certificate is accepted when calling Zen ID REST service. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.maxInMemorySize` | `10485760` | Maximum in memory size of HTTP requests when calling Innovatrics REST service. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyEnabled` | `false` | Whether proxy server is enabled when calling Innovatrics REST service. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyHost` | | Proxy host to be used when calling Innovatrics REST service. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyPort` | 0 | Proxy port to be used when calling Innovatrics REST service. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyUsername` | | Proxy username to be used when calling Innovatrics REST service. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyPassword` | | Proxy password to be used when calling Innovatrics REST service. | See [Innovatrics documentation](https://developers.innovatrics.com/digital-onboarding/docs/functionalities/face/active-liveness-check/#magnifeye-liveness) for details how the score affects false acceptances (FAR) and false rejections (FRR). diff --git a/docs/onboarding/Configuration-Verification-Providers.md b/docs/onboarding/Configuration-Verification-Providers.md index ea4f8354..ed358b8b 100644 --- a/docs/onboarding/Configuration-Verification-Providers.md +++ b/docs/onboarding/Configuration-Verification-Providers.md @@ -6,6 +6,7 @@ This document describes configuration of providers for personal identity documen The document verification process is currently supported for following providers: - [ZenID](https://zenid.trask.cz/) - use value `zenid` in configuration +- [Innovatrics](https://www.innovatrics.com/) - use value `innovatrics` in configuration - Mock - useful for simple testing and local runs - use value `mock` in configuration ### ZenID @@ -35,6 +36,7 @@ When calling `document-verification/init-sdk` following implementation fields ar The document verification process is currently supported for following providers: - [iProov](https://www.iproov.com/) - use value `iproov` in configuration +- [Innovatrics](https://www.innovatrics.com/) - use value `innovatrics` in configuration - Mock - useful for simple testing and local runs - use value `mock` in configuration #### Configuration diff --git a/enrollment-server-onboarding-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/onboarding/response/error/ActivationOtpErrorResponse.java b/enrollment-server-onboarding-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/onboarding/response/error/ActivationOtpErrorResponse.java index 43e1dc5a..11c8d181 100644 --- a/enrollment-server-onboarding-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/onboarding/response/error/ActivationOtpErrorResponse.java +++ b/enrollment-server-onboarding-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/onboarding/response/error/ActivationOtpErrorResponse.java @@ -19,21 +19,23 @@ import io.getlime.core.rest.model.base.response.ErrorResponse; import jakarta.validation.constraints.NotBlank; +import lombok.EqualsAndHashCode; /** * Response class used when OTP code verification fails during activation (soft fail). * * @author Roman Strobl, roman.strobl@wultra.com */ +@EqualsAndHashCode(callSuper = true) public class ActivationOtpErrorResponse extends ErrorResponse { - private Integer remainingAttempts; + private final Integer remainingAttempts; /** * Default constructor. */ public ActivationOtpErrorResponse() { - super(); + remainingAttempts = null; } /** @@ -55,12 +57,4 @@ public Integer getRemainingAttempts() { return remainingAttempts; } - /** - * Set remaining attempts for OTP verification during activation. - * @param remainingAttempts Remaining attempts for OTP verification during activation. - */ - public void setRemainingAttempts(Integer remainingAttempts) { - this.remainingAttempts = remainingAttempts; - } - } diff --git a/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/PresenceCheckProvider.java b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/PresenceCheckProvider.java index 298b5296..2a4ace4a 100644 --- a/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/PresenceCheckProvider.java +++ b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/PresenceCheckProvider.java @@ -44,13 +44,13 @@ public interface PresenceCheckProvider { void initPresenceCheck(OwnerId id, Image photo) throws PresenceCheckException, RemoteCommunicationException; /** - * A feature flag whether the trusted photo of the user should be passed to {@link #initPresenceCheck(OwnerId, Image)}. + * Configuration flag setting where the provider implementation expects the trusted photo of the user. *

* Some implementation may require specific source to be called by Onboarding server, some providers may handle it internally. * - * @return {@code true} if the trusted photo should be provided, {@code false} otherwise. + * @return Source where the trusted photo is expected. */ - boolean shouldProvideTrustedPhoto(); + TrustedPhotoSource trustedPhotoSource(); /** * Starts the presence check process. The process has to be initialized before this call. @@ -82,4 +82,18 @@ public interface PresenceCheckProvider { */ void cleanupIdentityData(OwnerId id, SessionInfo sessionInfo) throws PresenceCheckException, RemoteCommunicationException; + /** + * Return type for {@link #trustedPhotoSource()}. + */ + enum TrustedPhotoSource { + /** + * If the trusted photo should be passed in {@link #initPresenceCheck} + */ + IMAGE, + + /** + * If the trusted photo is passed via reference in {@link SessionInfo} + */ + REFERENCE + } } diff --git a/enrollment-server-onboarding-common/src/main/java/com/wultra/app/onboardingserver/common/errorhandling/IdentityVerificationException.java b/enrollment-server-onboarding-common/src/main/java/com/wultra/app/onboardingserver/common/errorhandling/IdentityVerificationException.java index 401f1543..f403b7bc 100644 --- a/enrollment-server-onboarding-common/src/main/java/com/wultra/app/onboardingserver/common/errorhandling/IdentityVerificationException.java +++ b/enrollment-server-onboarding-common/src/main/java/com/wultra/app/onboardingserver/common/errorhandling/IdentityVerificationException.java @@ -36,4 +36,8 @@ public IdentityVerificationException(String message) { super(message); } + public IdentityVerificationException(String message, Throwable cause) { + super(message, cause); + } + } \ No newline at end of file diff --git a/enrollment-server-onboarding-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java b/enrollment-server-onboarding-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java index 1dfec196..89aed880 100644 --- a/enrollment-server-onboarding-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java +++ b/enrollment-server-onboarding-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java @@ -30,6 +30,11 @@ @Data public class SessionInfo { + public static final String ATTRIBUTE_TIMESTAMP_LAST_USED = "timestampLastUsed"; + public static final String ATTRIBUTE_IMAGE_UPLOADED = "imageUploaded"; + public static final String ATTRIBUTE_PRIMARY_DOCUMENT_REFERENCE = "primaryDocumentReference"; + public static final String ATTRIBUTE_OTHER_DOCUMENTS_REFERENCES = "otherDocumentsReferences"; + private Map sessionAttributes = new LinkedHashMap<>(); } diff --git a/enrollment-server-onboarding-provider-innovatrics/pom.xml b/enrollment-server-onboarding-provider-innovatrics/pom.xml index 73dd6c2b..bec000cb 100644 --- a/enrollment-server-onboarding-provider-innovatrics/pom.xml +++ b/enrollment-server-onboarding-provider-innovatrics/pom.xml @@ -42,6 +42,18 @@ spring-boot-starter-test test + + + com.squareup.okhttp3 + mockwebserver + test + + + + com.h2database + h2 + test + diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsApiService.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsApiService.java index f4a6a208..916c08fb 100644 --- a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsApiService.java +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsApiService.java @@ -18,10 +18,14 @@ package com.wultra.app.onboardingserver.provider.innovatrics; import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CustomerInspectResponse; import com.wultra.app.onboardingserver.provider.innovatrics.model.api.EvaluateCustomerLivenessRequest; import com.wultra.app.onboardingserver.provider.innovatrics.model.api.EvaluateCustomerLivenessResponse; +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.*; import com.wultra.core.rest.client.base.RestClient; import com.wultra.core.rest.client.base.RestClientException; import lombok.extern.slf4j.Slf4j; @@ -29,11 +33,17 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import java.util.List; +import java.util.Optional; + /** * Implementation of the REST service toInnovatrics. *

@@ -41,6 +51,7 @@ * Both providers, document verifier and presence check, must be configured to {@code innovatrics}. * * @author Lubos Racansky, lubos.racansky@wultra.com + * @author Jan Pesek, jan.pesek@wultra.com */ @ConditionalOnExpression(""" '${enrollment-server-onboarding.presence-check.provider}' == 'innovatrics' and '${enrollment-server-onboarding.document-verification.provider}' == 'innovatrics' @@ -49,8 +60,6 @@ @Slf4j class InnovatricsApiService { - private static final MultiValueMap EMPTY_ADDITIONAL_HEADERS = new LinkedMultiValueMap<>(); - private static final MultiValueMap EMPTY_QUERY_PARAMS = new LinkedMultiValueMap<>(); /** @@ -58,14 +67,21 @@ class InnovatricsApiService { */ private final RestClient restClient; + /** + * Configuration properties. + */ + private final InnovatricsConfigProps configProps; + /** * Service constructor. * * @param restClient REST template for Innovatrics calls. */ @Autowired - public InnovatricsApiService(@Qualifier("restClientInnovatrics") final RestClient restClient) { + public InnovatricsApiService(@Qualifier("restClientInnovatrics") final RestClient restClient, + InnovatricsConfigProps configProps) { this.restClient = restClient; + this.configProps = configProps; } public EvaluateCustomerLivenessResponse evaluateLiveness(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { @@ -77,7 +93,7 @@ public EvaluateCustomerLivenessResponse evaluateLiveness(final String customerId try { logger.info("Calling liveness/evaluation, {}", ownerId); logger.debug("Calling {}, {}", apiPath, request); - final ResponseEntity response = restClient.post(apiPath, request, EMPTY_QUERY_PARAMS, EMPTY_ADDITIONAL_HEADERS, new ParameterizedTypeReference<>() {}); + final ResponseEntity response = restClient.post(apiPath, request, new ParameterizedTypeReference<>() {}); logger.info("Got {} for liveness/evaluation, {}", response.getStatusCode(), ownerId); logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); logger.trace("{} response: {}", apiPath, response); @@ -96,7 +112,7 @@ public CustomerInspectResponse inspectCustomer(final String customerId, final Ow try { logger.info("Calling /inspect, {}", ownerId); logger.debug("Calling {}", apiPath); - final ResponseEntity response = restClient.post(apiPath, null, EMPTY_QUERY_PARAMS, EMPTY_ADDITIONAL_HEADERS, new ParameterizedTypeReference<>() {}); + final ResponseEntity response = restClient.post(apiPath, null, new ParameterizedTypeReference<>() {}); logger.info("Got {} for /inspect, {}", response.getStatusCode(), ownerId); logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); logger.trace("{} response: {}", apiPath, response); @@ -145,4 +161,316 @@ public void deleteSelfie(final String customerId, final OwnerId ownerId) throws } } + public void createLiveness(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/liveness".formatted(customerId); + + try { + logger.info("Calling liveness creation, {}", ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.put(apiPath, null, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for liveness creation, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + } catch (RestClientException e) { + throw new RemoteCommunicationException( + String.format("Failed REST call to liveness creation for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when creating liveness for customerId=" + customerId, e); + } + } + + public CreateCustomerLivenessRecordResponse createLivenessRecord(final String customerId, final byte[] requestData, final OwnerId ownerId) throws RemoteCommunicationException{ + final String apiPath = "/api/v1/customers/%s/liveness/records".formatted(customerId); + + final HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM); + + try { + logger.info("Calling liveness record creation, {}", ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.post(apiPath, requestData, EMPTY_QUERY_PARAMS, httpHeaders, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for liveness record creation, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException( + String.format("Failed REST call to liveness record creation for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when creating liveness record for customerId=" + customerId, e); + } + } + + public CreateSelfieResponse createSelfie(final String customerId, final String livenessSelfieLink, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/selfie".formatted(customerId); + + final CreateSelfieRequest request = new CreateSelfieRequest().selfieOrigin(new LivenessSelfieOrigin().link(livenessSelfieLink)); + + try { + logger.info("Calling selfie creation, {}", ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.put(apiPath, request, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for selfie creation, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException( + String.format("Failed REST call to selfie creation for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when creating selfie for customerId=" + customerId, e); + } + } + + // TODO remove - temporal test call +// @PostConstruct +// public void testCall() throws RestClientException { +// logger.info("Trying a test call"); +// final ResponseEntity response = restClient.get("/api/v1/metadata", STRING_TYPE_REFERENCE); +// logger.info("Result of test call: {}", response.getBody()); +// } + + /** + * Create a new customer resource. + * @param ownerId owner identification. + * @return optional of CreateCustomerResponse with a customerId. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public CreateCustomerResponse createCustomer(final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers"; + + try { + logger.info("Creating customer, {}", ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.post(apiPath, null, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for creating customer, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed when creating a new customer resource, statusCode=%s, responseBody='%s'".formatted(e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when creating a new customer resource", e); + } + } + + /** + * Create a new document resource assigned to a customer. This resource is used for documents only, not for selfies. + * @param customerId id of the customer to assign the resource to. + * @param documentType type of document that will be uploaded later. + * @param ownerId owner identification. + * @return optional of CreateDocumentResponse. Does not contain important details. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public CreateDocumentResponse createDocument(final String customerId, final DocumentType documentType, final OwnerId ownerId) throws RemoteCommunicationException, DocumentVerificationException { + final String apiPath = "/api/v1/customers/%s/document".formatted(customerId); + + final DocumentClassificationAdvice classificationAdvice = new DocumentClassificationAdvice(); + classificationAdvice.setTypes(List.of(convertType(documentType))); + classificationAdvice.setCountries(configProps.getDocumentVerificationConfiguration().getDocumentCountries()); + final DocumentAdvice advice = new DocumentAdvice(); + advice.setClassification(classificationAdvice); + final CreateDocumentRequest request = new CreateDocumentRequest(); + request.setAdvice(advice); + + try { + logger.info("Creating new document of type {} for customer {}, {}", documentType, customerId, ownerId); + logger.debug("Calling {}, {}", apiPath, request); + final ResponseEntity response = restClient.put(apiPath, request, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for creating document, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed when creating a new document resource for customerId=%s, statusCode=%s, responseBody='%s'".formatted(customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when creating a new document resource for customerId=%s".formatted(customerId), e); + } + } + + /** + * Provide photo of a document page. A document resource must be already assigned to the customer. + * @param customerId id of the customer to whom the document should be provided. + * @param side specifies side of the document. + * @param imageBytes image of the page encoded in base64. + * @param ownerId owner identification. + * @return optional of CreateDocumentPageResponse with details extracted from the page. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public CreateDocumentPageResponse provideDocumentPage(final String customerId, final CardSide side, final byte[] imageBytes, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/document/pages".formatted(customerId); + + final DocumentPageClassificationAdvice classificationAdvice = new DocumentPageClassificationAdvice(); + classificationAdvice.setPageTypes(List.of(convertSide(side))); + final DocumentPageAdvice advice = new DocumentPageAdvice(); + advice.setClassification(classificationAdvice); + + final Image image = new Image(); + image.setData(imageBytes); + + final CreateDocumentPageRequest request = new CreateDocumentPageRequest(); + request.setAdvice(advice); + request.setImage(image); + + try { + logger.info("Providing {} side document page for customer {}, {}", convertSide(side), customerId, ownerId); + logger.debug("Calling {}, {}", apiPath, request); + final ResponseEntity response = restClient.put(apiPath, request, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for providing document page, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed when providing a document page for customerId=%s, statusCode=%s, responseBody='%s'".formatted(customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when providing a document page for customerId=%s".formatted(customerId), e); + } + } + + /** + * Get details gathered about the customer. + * @param customerId id of the customer. + * @param ownerId owner identification. + * @return optional of GetCustomerResponse with details about the customer. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public GetCustomerResponse getCustomer(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s".formatted(customerId); + + try { + logger.info("Getting details about customer {}, {}", customerId, ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.get(apiPath, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for getting details about customer, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed when getting details of customerId=%s, statusCode=%s, responseBody='%s'".formatted(customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when getting details of customerId=%s".formatted(customerId), e); + } + } + + /** + * Get document portrait of the customer. + * @param customerId id of the customer. + * @param ownerId owner identification. + * @return successful Response contains a base64 in the JPG format. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public Optional getDocumentPortrait(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/document/portrait".formatted(customerId); + + try { + logger.info("Getting document portrait of customer {}, {}", customerId, ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.get(apiPath, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for getting document portrait, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return Optional.ofNullable(response.getBody()); + } catch (RestClientException e) { + if (HttpStatus.NOT_FOUND == e.getStatusCode()) { + // API returns 404 in case of missing portrait photo. + logger.debug("Missing portrait photo for customer {}, {}", customerId, ownerId); + return Optional.empty(); + } + throw new RemoteCommunicationException("REST API call failed when getting customer portrait, statusCode=%s, responseBody='%s'".formatted(e.getStatusCode(), e.getResponse()), e); + } + } + + /** + * Inspect consistency of data of the submitted document provided for a customer. + * @param customerId id of the customer whose document to inspect. + * @param ownerId owner identification. + * @return optional of DocumentInspectResponse with details about consistency of the document. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public DocumentInspectResponse inspectDocument(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/document/inspect".formatted(customerId); + + try { + logger.info("Getting document inspect of customer {}, {}", customerId, ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.post(apiPath, null, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for getting document inspect, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed while getting document inspection for customerId=%s, statusCode=%s, responseBody='%s'".formatted(customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when getting document inspection for customerId=%s".formatted(customerId), e); + } + } + + /** + * Delete customer. + * @param customerId id of the customer. + * @param ownerId owner identification. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public void deleteCustomer(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s".formatted(customerId); + + try { + logger.info("Deleting customer {}, {}", customerId, ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.delete(apiPath, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for deleting customer, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed when deleting customer, statusCode=%s, responseBody='%s'".formatted(e.getStatusCode(), e.getResponse()), e); + } + } + + /** + * Delete customer's document. + * @param customerId id of the customer. + * @param ownerId owner identification. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public void deleteDocument(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/document".formatted(customerId); + + try { + logger.info("Deleting document of customer {}, {}", customerId, ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.delete(apiPath, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for deleting customer's document, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed when deleting customer's document, statusCode=%s, responseBody='%s'".formatted(e.getStatusCode(), e.getResponse()), e); + } + } + + /** + * Converts internal DocumentType enum to string value used by Innovatrics. + * @param type represents type of document. + * @return document type as a string value. + */ + private static String convertType(DocumentType type) throws DocumentVerificationException { + return switch (type) { + case ID_CARD -> "identity-card"; + case PASSPORT -> "passport"; + case DRIVING_LICENSE -> "drivers-licence"; + default -> throw new DocumentVerificationException("Unsupported documentType " + type); + }; + } + + /** + * Converts internal CardSide enum to string value used by Innovatrics. + * @param side represents side of a card. + * @return side of a card as a string value. + */ + private static String convertSide(CardSide side) { + return switch (side) { + case FRONT -> "front"; + case BACK -> "back"; + }; + } + } diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfigProps.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfigProps.java index 62fb7f67..ce0acde4 100644 --- a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfigProps.java +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfigProps.java @@ -24,6 +24,9 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; +import java.util.List; +import java.util.Set; + /** * Innovatrics configuration properties. *

@@ -62,6 +65,8 @@ class InnovatricsConfigProps { private PresenceCheckConfiguration presenceCheckConfiguration; + private DocumentVerificationConfiguration documentVerificationConfiguration; + @Getter @Setter public static class PresenceCheckConfiguration { /** @@ -70,4 +75,17 @@ public static class PresenceCheckConfiguration { private double score = 0.875; } + @Getter @Setter + public static class DocumentVerificationConfiguration { + /** + * Identifies expected document countries of issue in ISO 3166-1 alpha-3 format. + */ + private List documentCountries; + + /** + * Set of fields in camelCase that are cross-validated between the machine-readable zone and visual zone, if extracted. + */ + private Set crucialFields; + } + } diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProvider.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProvider.java index 70d177e7..e3d13413 100644 --- a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProvider.java +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProvider.java @@ -17,35 +17,100 @@ */ package com.wultra.app.onboardingserver.provider.innovatrics; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Strings; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentVerificationStatus; import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.enrollmentserver.model.integration.Image; import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; import com.wultra.app.onboardingserver.api.provider.DocumentVerificationProvider; import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity; import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.*; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.stream.Collectors; /** * Implementation of the {@link DocumentVerificationProvider} with Innovatrics. * * @author Jan Pesek, jan.pesek@wultra.com */ -@ConditionalOnProperty(value = "enrollment-server-onboarding.document-verification.provider", havingValue = "innovatrics") +@ConditionalOnExpression(""" + '${enrollment-server-onboarding.presence-check.provider}' == 'innovatrics' and '${enrollment-server-onboarding.document-verification.provider}' == 'innovatrics' + """) @Component -class InnovatricsDocumentVerificationProvider implements DocumentVerificationProvider { +@AllArgsConstructor +@Slf4j +public class InnovatricsDocumentVerificationProvider implements DocumentVerificationProvider { + + private final InnovatricsApiService innovatricsApiService; + private final ObjectMapper objectMapper; + private final InnovatricsConfigProps configuration; @Override public DocumentsSubmitResult checkDocumentUpload(OwnerId id, DocumentVerificationEntity document) throws RemoteCommunicationException, DocumentVerificationException { - return null; + logger.warn("Unexpected state of document {}, {}", document, id); + throw new UnsupportedOperationException("Method checkDocumentUpload is not supported by Innovatrics provider."); } @Override public DocumentsSubmitResult submitDocuments(OwnerId id, List documents) throws RemoteCommunicationException, DocumentVerificationException { - return null; + if (CollectionUtils.isEmpty(documents)) { + logger.info("Empty documents list passed to document provider, {}", id); + return new DocumentsSubmitResult(); + } + + final DocumentType documentType = documents.get(0).getType(); + if (DocumentType.SELFIE_PHOTO == documentType) { + logger.debug("Selfie photo passed as a document, {}", id); + throw new DocumentVerificationException("Selfie photo cannot be submitted as a document"); + } + + if (DocumentType.SELFIE_VIDEO == documentType) { + logger.debug("Selfie video passed as a document, {}", id); + throw new DocumentVerificationException("Selfie video cannot be submitted as a document"); + } + + final String customerId = createCustomer(id); + createDocument(customerId, documentType, id); + logger.debug("Created new customer {}, {}", customerId, id); + + final DocumentsSubmitResult results = new DocumentsSubmitResult(); + for (SubmittedDocument page : documents) { + final CreateDocumentPageResponse createDocumentPageResponse = provideDocumentPage(customerId, page, id); + if (containsError(createDocumentPageResponse)) { + logger.debug("Page upload was not successful, {}", id); + results.getResults().add(createErrorSubmitResult(customerId, createDocumentPageResponse, page)); + } else { + logger.debug("Document page was read successfully by provider, {}", id); + results.getResults().add(createSubmitResult(customerId, page)); + } + } + + final Optional primaryPage = results.getResults().stream() + .filter(result -> Strings.isNullOrEmpty(result.getRejectReason()) && Strings.isNullOrEmpty(result.getErrorDetail())) + .findFirst(); + + if (primaryPage.isPresent()) { + // Only first found successfully submitted page has extracted data, others has empty JSON + primaryPage.get().setExtractedData(getExtractedData(customerId, id)); + if (hasDocumentPortrait(customerId, id)) { + results.setExtractedPhotoId(customerId); + } + } + + return results; } @Override @@ -55,31 +120,303 @@ public boolean shouldStoreSelfie() { @Override public DocumentsVerificationResult verifyDocuments(OwnerId id, List uploadIds) throws RemoteCommunicationException, DocumentVerificationException { - return null; + final DocumentsVerificationResult results = new DocumentsVerificationResult(); + results.setResults(new ArrayList<>()); + + // Pages of the same document have same uploadId (= customerId), no reason to generate verification for each one. + final List distinctUploadIds = uploadIds.stream().distinct().toList(); + for (String customerId : distinctUploadIds) { + final DocumentVerificationResult result = createVerificationResult(customerId, id); + results.getResults().add(result); + } + + final String rejectReasons = results.getResults().stream() + .map(DocumentVerificationResult::getRejectReason) + .filter(StringUtils::hasText) + .collect(Collectors.joining(";")); + if (StringUtils.hasText(rejectReasons)) { + logger.debug("Some documents were rejected: rejectReasons={}, {}", rejectReasons, id); + results.setStatus(DocumentVerificationStatus.REJECTED); + results.setRejectReason(rejectReasons); + } else { + logger.debug("All documents accepted, {}", id); + results.setStatus(DocumentVerificationStatus.ACCEPTED); + } + results.setVerificationId(UUID.randomUUID().toString()); + return results; } @Override public DocumentsVerificationResult getVerificationResult(OwnerId id, String verificationId) throws RemoteCommunicationException, DocumentVerificationException { - return null; + logger.warn("Unexpected state of documents with verificationId={}, {}", verificationId, id); + throw new UnsupportedOperationException("Method getVerificationResult is not supported by Innovatrics provider."); } @Override public Image getPhoto(String photoId) throws RemoteCommunicationException, DocumentVerificationException { - return null; + logger.warn("Unexpected document portrait query for customerId={}", photoId); + throw new UnsupportedOperationException("Method getPhoto is not implemented by Innovatrics provider."); } @Override public void cleanupDocuments(OwnerId id, List uploadIds) throws RemoteCommunicationException, DocumentVerificationException { - + // Pages of the same document have same uploadId (= customerId), no reason to call delete for each one. + final List distinctUploadIds = uploadIds.stream().distinct().toList(); + logger.info("Invoked cleanupDocuments, {}", id); + for (String customerId : distinctUploadIds) { + innovatricsApiService.deleteCustomer(customerId, id); + } } @Override public List parseRejectionReasons(DocumentResultEntity docResult) throws DocumentVerificationException { - return null; + logger.debug("Parsing rejection reasons of {}", docResult); + final String rejectionReasons = docResult.getRejectReason(); + if (!StringUtils.hasText(rejectionReasons)) { + return Collections.emptyList(); + } + + return deserializeFromString(rejectionReasons); } @Override public VerificationSdkInfo initVerificationSdk(OwnerId id, Map initAttributes) throws RemoteCommunicationException, DocumentVerificationException { - return null; + logger.debug("#initVerificationSdk does nothing for Innovatrics, {}", id); + return new VerificationSdkInfo(); + } + + /** + * Create a new customer resource. + * @param ownerId owner identification. + * @return ID of the new customer. + * @throws RemoteCommunicationException if the resource was not created properly. + */ + private String createCustomer(final OwnerId ownerId) throws RemoteCommunicationException { + return innovatricsApiService.createCustomer(ownerId).getId(); + } + + /** + * Create a new document resource to an existing customer. + * @param customerId id of the customer to assign the resource to. + * @param documentType type of the document that will be uploaded later. + * @param ownerId owner identification. + * @throws RemoteCommunicationException if the resource was not created properly. + */ + private void createDocument(final String customerId, final DocumentType documentType, final OwnerId ownerId) throws RemoteCommunicationException, DocumentVerificationException { + innovatricsApiService.createDocument(customerId, documentType, ownerId); + } + + /** + * Upload a page of a document to a customer. + * @param customerId id of the customer to whom upload the document page. + * @param page SubmittedDocument object representing the page. + * @param ownerId owner identification. + * @return CreateDocumentPageResponse containing info about the document type. An unsuccessful response will contain an error code. + * @throws RemoteCommunicationException if the document page was not uploaded properly. + */ + private CreateDocumentPageResponse provideDocumentPage(final String customerId, final SubmittedDocument page, final OwnerId ownerId) throws RemoteCommunicationException { + return innovatricsApiService.provideDocumentPage(customerId, page.getSide(), page.getPhoto().getData(), ownerId); + } + + /** + * Checks if CreateDocumentPageResponse contains error or warnings. + * @param pageResponse response to a page upload. + * @return true if there is an error or warnings, false otherwise. + */ + private static boolean containsError(CreateDocumentPageResponse pageResponse) { + return pageResponse.getErrorCode() != null || !CollectionUtils.isEmpty(pageResponse.getWarnings()); + } + + /** + * Creates DocumentSubmitResult with error or reject reason. + * @param uploadId external id of the document. + * @param response returned from provider. + * @return DocumentSubmitResult with error or reject reason. + * @throws DocumentVerificationException in case of rejection reason serialization error. + */ + private DocumentSubmitResult createErrorSubmitResult(String uploadId, CreateDocumentPageResponse response, SubmittedDocument submitted) throws DocumentVerificationException { + final DocumentSubmitResult result = new DocumentSubmitResult(); + result.setUploadId(uploadId); + result.setDocumentId(submitted.getDocumentId()); + + final List rejectionReasons = new ArrayList<>(); + if (response.getErrorCode() != null) { + switch (response.getErrorCode()) { + case NO_CARD_CORNERS_DETECTED -> rejectionReasons.add("Document page was not detected in the photo."); + case PAGE_DOESNT_MATCH_DOCUMENT_TYPE_OF_PREVIOUS_PAGE -> rejectionReasons.add("Mismatched document pages types."); + default -> rejectionReasons.add("Unknown error: %s".formatted(response.getErrorCode().getValue())); + } + } + + if (!CollectionUtils.isEmpty(response.getWarnings())) { + for (CreateDocumentPageResponse.WarningsEnum warning : response.getWarnings()) { + switch (warning) { + case DOCUMENT_TYPE_NOT_RECOGNIZED -> rejectionReasons.add("Document type not recognized."); + default -> rejectionReasons.add("Unknown warning: %s".formatted(warning.getValue())); + } + } + } + + if (!rejectionReasons.isEmpty()) { + result.setRejectReason(serializeToString(rejectionReasons)); + } + + return result; + } + + /** + * Gets all customer data extracted from uploaded documents in a JSON form. + * @param customerId id of the customer. + * @param ownerId owner identification. + * @return JSON serialized data. + * @throws RemoteCommunicationException in case of the remote service error. + * @throws DocumentVerificationException if the returned data could not be provided. + */ + private String getExtractedData(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException, DocumentVerificationException { + return serializeToString(innovatricsApiService.getCustomer(customerId, ownerId)); } + + /** + * Checks if a document portrait of the customer is available. + * @param customerId id of the customer. + * @param ownerId owner identification. + * @return true if document portrait is available, false otherwise. + */ + private boolean hasDocumentPortrait(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + return innovatricsApiService.getDocumentPortrait(customerId, ownerId).isPresent(); + } + + /** + * Creates DocumentSubmitResult containing extracted data. + * @param customerId id of the customer to get data from. + * @return DocumentSubmitResult containing extracted data. + */ + private static DocumentSubmitResult createSubmitResult(final String customerId, final SubmittedDocument submitted) { + final DocumentSubmitResult result = new DocumentSubmitResult(); + result.setUploadId(customerId); + result.setDocumentId(submitted.getDocumentId()); + result.setExtractedData(DocumentSubmitResult.NO_DATA_EXTRACTED); + return result; + } + + /** + * Gets document inspection from Innovatrics and parses it to the verification result. + * @param customerId id of the customer the document belongs to. + * @param ownerId owner identification. + * @return DocumentVerificationResult + */ + private DocumentVerificationResult createVerificationResult(String customerId, OwnerId ownerId) throws DocumentVerificationException, RemoteCommunicationException { + final DocumentInspectResponse response = innovatricsApiService.inspectDocument(customerId, ownerId); + + final List rejectionReasons = new ArrayList<>(); + if (Boolean.TRUE.equals(response.getExpired())) { + rejectionReasons.add("Document expired."); + } + + if (response.getMrzInspection() != null && !Boolean.TRUE.equals(response.getMrzInspection().getValid())) { + rejectionReasons.add("MRZ does not conform the ICAO specification."); + } + + final VisualZoneInspection viz = response.getVisualZoneInspection(); + rejectionReasons.addAll(parseVisualZoneInspection(viz)); + + if (response.getPageTampering() != null) { + response.getPageTampering().forEach((side, inspection) -> { + if (Boolean.TRUE.equals(inspection.getColorProfileChangeDetected())) { + rejectionReasons.add("Colors on the document %s does not corresponds to the expected color profile.".formatted(side)); + } + if (Boolean.TRUE.equals(inspection.getLooksLikeScreenshot())) { + rejectionReasons.add("Provided image of the document %s was taken from a screen of another device.".formatted(side)); + } + if (Boolean.TRUE.equals(inspection.getTamperedTexts())) { + rejectionReasons.add("Text of the document %s is tampered.".formatted(side)); + } + }); + } + + final DocumentVerificationResult result = new DocumentVerificationResult(); + result.setUploadId(customerId); + result.setVerificationResult(serializeToString(response)); + if (!rejectionReasons.isEmpty()) { + result.setRejectReason(serializeToString(rejectionReasons)); + } + return result; + } + + /** + * Parse VisualZoneInspection of a document provided by Innovatrics. + * @param visualZoneInspection inspection of a document by Innovatrics. + * @return List of reasons to reject the document. + */ + private List parseVisualZoneInspection(final VisualZoneInspection visualZoneInspection) { + final List rejectionReasons = new ArrayList<>(); + if (visualZoneInspection == null) { + return rejectionReasons; + } + + // Contains fields with a ocr confidence lower than ocr-text-field-threshold settings. + final List lowOcrConfidenceAttributes = visualZoneInspection.getOcrConfidence().getLowOcrConfidenceTexts(); + if (!CollectionUtils.isEmpty(lowOcrConfidenceAttributes)) { + rejectionReasons.add("Low OCR confidence of attributes: %s".formatted(lowOcrConfidenceAttributes)); + } + + final TextConsistency textConsistency = visualZoneInspection.getTextConsistency(); + if (textConsistency == null) { + return rejectionReasons; + } + + final TextConsistentWith textConsistentWith = textConsistency.getConsistencyWith(); + if (textConsistentWith == null) { + return rejectionReasons; + } + + final MrzConsistency mrzConsistency = textConsistentWith.getMrz(); + if (mrzConsistency != null) { + final List inconsistentAttributes = getCrucial(mrzConsistency.getInconsistentTexts()); + if (!inconsistentAttributes.isEmpty()) { + rejectionReasons.add("Inconsistent crucial attributes with MRZ: %s".formatted(inconsistentAttributes)); + } + } + + final BarcodesConsistency barcodesConsistency = textConsistentWith.getBarcodes(); + if (barcodesConsistency != null) { + final List inconsistentAttributes = getCrucial(barcodesConsistency.getInconsistentTexts()); + if (!inconsistentAttributes.isEmpty()) { + rejectionReasons.add("Inconsistent crucial attributes with barcode: %s".formatted(inconsistentAttributes)); + } + } + + return rejectionReasons; + } + + /** + * Intersects list of attributes with CRUCIAL_ATTRIBUTES. + * @param attributes Attributes to do the intersection on. + * @return Attributes intersection. + */ + private List getCrucial(final List attributes) { + if (attributes == null) { + return Collections.emptyList(); + } + + final Set crucialFields = configuration.getDocumentVerificationConfiguration().getCrucialFields(); + return attributes.stream().filter(crucialFields::contains).toList(); + } + + private String serializeToString(T src) throws DocumentVerificationException { + try { + return objectMapper.writeValueAsString(src); + } catch (JsonProcessingException e) { + throw new DocumentVerificationException("Unexpected error when serializing data", e); + } + } + + private T deserializeFromString(String src) throws DocumentVerificationException { + try { + return objectMapper.readValue(src, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + throw new DocumentVerificationException("Unexpected error when deserializing data", e); + } + } + } diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessController.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessController.java new file mode 100644 index 00000000..cdc3ab46 --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessController.java @@ -0,0 +1,93 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.wultra.app.onboardingserver.common.errorhandling.IdentityVerificationException; +import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; +import io.getlime.core.rest.model.base.response.Response; +import io.getlime.security.powerauth.crypto.lib.enums.PowerAuthSignatureTypes; +import io.getlime.security.powerauth.rest.api.spring.annotation.EncryptedRequestBody; +import io.getlime.security.powerauth.rest.api.spring.annotation.PowerAuth; +import io.getlime.security.powerauth.rest.api.spring.annotation.PowerAuthEncryption; +import io.getlime.security.powerauth.rest.api.spring.authentication.PowerAuthApiAuthentication; +import io.getlime.security.powerauth.rest.api.spring.encryption.EncryptionContext; +import io.getlime.security.powerauth.rest.api.spring.encryption.EncryptionScope; +import io.getlime.security.powerauth.rest.api.spring.exception.PowerAuthAuthenticationException; +import io.getlime.security.powerauth.rest.api.spring.exception.PowerAuthEncryptionException; +import io.getlime.security.powerauth.rest.api.spring.exception.authentication.PowerAuthTokenInvalidException; +import io.swagger.v3.oas.annotations.Parameter; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + + +/** + * Controller publishing REST services for uploading Innovatrics liveness data. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@ConditionalOnExpression(""" + '${enrollment-server-onboarding.presence-check.provider}' == 'innovatrics' and ${enrollment-server-onboarding.onboarding-process.enabled} == true + """) +@RestController +@RequestMapping(value = "api/identity") +@AllArgsConstructor +@Slf4j +class InnovatricsLivenessController { + + private InnovatricsLivenessService innovatricsLivenessService; + + /** + * Upload Innovatrics liveness data. + * + * @param requestData Binary request data + * @param encryptionContext Encryption context. + * @param apiAuthentication PowerAuth authentication. + * @return Presence check initialization response. + * @throws PowerAuthAuthenticationException Thrown when request authentication fails. + * @throws PowerAuthEncryptionException Thrown when request decryption fails. + * @throws IdentityVerificationException Thrown when identity verification is invalid. + * @throws RemoteCommunicationException Thrown when there is a problem with the remote communication. + */ + @PostMapping("presence-check/upload") + @PowerAuthEncryption(scope = EncryptionScope.ACTIVATION_SCOPE) + @PowerAuth(resourceId = "/api/identity/presence-check/upload", signatureType = PowerAuthSignatureTypes.POSSESSION) + public Response upload( + @EncryptedRequestBody byte[] requestData, + @Parameter(hidden = true) EncryptionContext encryptionContext, + @Parameter(hidden = true) PowerAuthApiAuthentication apiAuthentication) throws IdentityVerificationException, PowerAuthAuthenticationException, PowerAuthEncryptionException, RemoteCommunicationException { + + if (apiAuthentication == null) { + throw new PowerAuthTokenInvalidException("Unable to verify device registration when uploading liveness"); + } + + if (encryptionContext == null) { + throw new PowerAuthEncryptionException("ECIES encryption failed when uploading liveness"); + } + + if (requestData == null) { + throw new PowerAuthEncryptionException("Invalid request received when uploading liveness"); + } + + innovatricsLivenessService.upload(requestData, encryptionContext); + return new Response(); + } +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessService.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessService.java new file mode 100644 index 00000000..8b97aa52 --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessService.java @@ -0,0 +1,133 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Strings; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import com.wultra.app.onboardingserver.common.database.IdentityVerificationRepository; +import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity; +import com.wultra.app.onboardingserver.common.errorhandling.IdentityVerificationException; +import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; +import com.wultra.app.onboardingserver.common.service.AuditService; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CreateCustomerLivenessRecordResponse; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CreateSelfieResponse; +import io.getlime.security.powerauth.rest.api.spring.encryption.EncryptionContext; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Service providing Innovatrics business features beyond {@link InnovatricsPresenceCheckProvider}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Service +@Transactional(readOnly = true) +@Slf4j +@AllArgsConstructor +@ConditionalOnExpression(""" + '${enrollment-server-onboarding.presence-check.provider}' == 'innovatrics' and ${enrollment-server-onboarding.onboarding-process.enabled} == true + """) +class InnovatricsLivenessService { + + private final InnovatricsApiService innovatricsApiService; + + private final IdentityVerificationRepository identityVerificationRepository; + + private AuditService auditService; + + public void upload(final byte[] requestData, final EncryptionContext encryptionContext) throws IdentityVerificationException, RemoteCommunicationException { + final String activationId = encryptionContext.getActivationId(); + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findFirstByActivationIdOrderByTimestampCreatedDesc(activationId).orElseThrow(() -> + new IdentityVerificationException("No identity verification entity found for Activation ID: " + activationId)); + + final OwnerId ownerId = extractOwnerId(identityVerification); + final String customerId = fetchCustomerId(ownerId, identityVerification); + + createLiveness(customerId, ownerId); + final CreateCustomerLivenessRecordResponse livenessRecordResponse = createLivenessRecord(requestData, customerId, ownerId); + createSelfie(livenessRecordResponse, customerId, ownerId); + + auditService.auditPresenceCheckProvider(identityVerification, "Uploaded presence check data for user: {}", ownerId.getUserId()); + logger.info("Liveness record successfully uploaded, {}", ownerId); + } + + private void createSelfie(final CreateCustomerLivenessRecordResponse livenessRecordResponse, final String customerId, final OwnerId ownerId) throws IdentityVerificationException, RemoteCommunicationException { + final String livenessSelfieLink = fetchSelfieLink(livenessRecordResponse); + final CreateSelfieResponse createSelfieResponse = innovatricsApiService.createSelfie(customerId, livenessSelfieLink, ownerId); + if (createSelfieResponse.getErrorCode() != null) { + logger.warn("Customer selfie error: {}, {}", createSelfieResponse.getErrorCode(), ownerId); + } + if (createSelfieResponse.getWarnings() != null) { + for (CreateSelfieResponse.WarningsEnum warning : createSelfieResponse.getWarnings()) { + logger.warn("Customer selfie warning: {}, {}", warning.getValue(), ownerId); + } + } + logger.debug("Selfie created, {}", ownerId); + } + + private CreateCustomerLivenessRecordResponse createLivenessRecord(final byte[] requestData, final String customerId, final OwnerId ownerId) throws RemoteCommunicationException, IdentityVerificationException { + final CreateCustomerLivenessRecordResponse livenessRecordResponse = innovatricsApiService.createLivenessRecord(customerId, requestData, ownerId); + if (livenessRecordResponse.getErrorCode() != null) { + throw new IdentityVerificationException("Unable to create liveness record: " + livenessRecordResponse.getErrorCode()); + } + logger.debug("Liveness record created, {}", ownerId); + return livenessRecordResponse; + } + + private void createLiveness(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + innovatricsApiService.createLiveness(customerId, ownerId); + logger.debug("Liveness created, {}", ownerId); + } + + private static String fetchSelfieLink(final CreateCustomerLivenessRecordResponse livenessRecordResponse) throws IdentityVerificationException { + if (livenessRecordResponse.getLinks() == null) { + throw new IdentityVerificationException("Unable to get selfie link"); + } + return livenessRecordResponse.getLinks().getSelfie(); + } + + private static OwnerId extractOwnerId(final IdentityVerificationEntity identityVerification) { + final OwnerId ownerId = new OwnerId(); + ownerId.setActivationId(identityVerification.getActivationId()); + ownerId.setUserId(identityVerification.getUserId()); + return ownerId; + } + + private static String fetchCustomerId(final OwnerId id, final IdentityVerificationEntity identityVerification) throws IdentityVerificationException { + final String sessionInfoString = StringUtils.defaultIfEmpty(identityVerification.getSessionInfo(), "{}"); + final SessionInfo sessionInfo; + try { + sessionInfo = new ObjectMapper().readValue(sessionInfoString, SessionInfo.class); + } catch (JsonProcessingException e) { + throw new IdentityVerificationException("Unable to deserialize session info", e); + } + + final String customerId = (String) sessionInfo.getSessionAttributes().get(SessionInfo.ATTRIBUTE_PRIMARY_DOCUMENT_REFERENCE); + if (Strings.isNullOrEmpty(customerId)) { + throw new IdentityVerificationException("Missing a customer ID value for calling Innovatrics, " + id); + } + return customerId; + } +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProvider.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProvider.java index 7899e776..007c06c6 100644 --- a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProvider.java +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProvider.java @@ -49,8 +49,6 @@ @AllArgsConstructor class InnovatricsPresenceCheckProvider implements PresenceCheckProvider { - private static final String INNOVATRICS_CUSTOMER_ID = "InnovatricsCustomerId"; - private final InnovatricsApiService innovatricsApiService; private final InnovatricsConfigProps configuration; @@ -61,8 +59,8 @@ public void initPresenceCheck(final OwnerId id, final Image photo) { } @Override - public boolean shouldProvideTrustedPhoto() { - return false; + public TrustedPhotoSource trustedPhotoSource() { + return TrustedPhotoSource.REFERENCE; } @Override @@ -152,8 +150,7 @@ private static Optional fail(final String errorDetail) { } private static String fetchCustomerId(final OwnerId id, final SessionInfo sessionInfo) throws PresenceCheckException { - // TODO (racansky, 2023-11-28) discuss the format with Jan Pesek - final String customerId = (String) sessionInfo.getSessionAttributes().get(INNOVATRICS_CUSTOMER_ID); + final String customerId = (String) sessionInfo.getSessionAttributes().get(SessionInfo.ATTRIBUTE_PRIMARY_DOCUMENT_REFERENCE); if (Strings.isNullOrEmpty(customerId)) { throw new PresenceCheckException("Missing a customer ID value for calling Innovatrics, " + id); } @@ -164,12 +161,7 @@ private static String fetchCustomerId(final OwnerId id, final SessionInfo sessio public void cleanupIdentityData(final OwnerId id, final SessionInfo sessionInfo) throws PresenceCheckException, RemoteCommunicationException { logger.info("Invoked cleanupIdentityData, {}", id); final String customerId = fetchCustomerId(id, sessionInfo); - - innovatricsApiService.deleteLiveness(customerId, id); - logger.debug("Deleted liveness, {}", id); - - innovatricsApiService.deleteSelfie(customerId, id); - logger.debug("Deleted selfie, {}", id); + innovatricsApiService.deleteCustomer(customerId, id); } record PresenceCheckError(PresenceCheckStatus status, String rejectReason, String errorDetail){} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProviderTest.java b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProviderTest.java new file mode 100644 index 00000000..fa9384eb --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProviderTest.java @@ -0,0 +1,174 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentVerificationStatus; +import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.enrollmentserver.model.integration.Image; +import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.*; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.util.StringUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Test of {@link InnovatricsDocumentVerificationProvider}. + * + * @author Jan Pesek, jan.pesek@wultra.com + */ +@SpringBootTest(classes = EnrollmentServerTestApplication.class) +@ActiveProfiles("test") +class InnovatricsDocumentVerificationProviderTest { + + @Autowired + private InnovatricsDocumentVerificationProvider tested; + + @MockBean + private InnovatricsApiService apiService; + + @Test + void testSubmitDocuments() throws Exception { + final OwnerId ownerId = createOwnerId(); + when(apiService.createCustomer(ownerId)).thenReturn(new CreateCustomerResponse("c123")); + + final Links docLink = new Links("docResource"); + final CreateDocumentResponse documentResponse = new CreateDocumentResponse(); + documentResponse.setLinks(docLink); + when(apiService.createDocument("c123", DocumentType.PASSPORT, ownerId)).thenReturn(documentResponse); + + final CreateDocumentPageResponse pageResponse = new CreateDocumentPageResponse(); + when(apiService.provideDocumentPage("c123", CardSide.FRONT, "img".getBytes(), ownerId)).thenReturn(pageResponse); + + final BiometricMultiValueAttribute ageAttr = new BiometricMultiValueAttribute("42", null, null, null, "40"); + final MultiValueAttribute surnameAttr = new MultiValueAttribute("SPECIMEN", null, null, null); + final Customer customer = new Customer(); + customer.age(ageAttr).setSurname(surnameAttr); + final GetCustomerResponse customerResponse = new GetCustomerResponse(); + customerResponse.customer(customer); + when(apiService.getCustomer("c123", ownerId)).thenReturn(customerResponse); + + final SubmittedDocument doc = new SubmittedDocument(); + doc.setType(DocumentType.PASSPORT); + doc.setSide(CardSide.FRONT); + doc.setPhoto(Image.builder().data("img".getBytes()).build()); + + final DocumentsSubmitResult results = tested.submitDocuments(ownerId, List.of(doc)); + verify(apiService).getCustomer("c123", ownerId); + assertEquals(1, results.getResults().size()); + + final DocumentSubmitResult result = results.getResults().get(0); + assertEquals("c123", result.getUploadId()); + assertFalse(StringUtils.hasText(result.getErrorDetail())); + assertFalse(StringUtils.hasText(result.getRejectReason())); + assertNotNull(result.getExtractedData()); + + assertEquals("42", JsonPath.read(result.getExtractedData(), "$.customer.age.visualZone")); + assertEquals("40", JsonPath.read(result.getExtractedData(), "$.customer.age.documentPortrait")); + assertEquals("SPECIMEN", JsonPath.read(result.getExtractedData(), "$.customer.surname.visualZone")); + } + + @Test + void testSubmitDocument_handleProvideDocumentPageError() throws Exception { + final OwnerId ownerId = createOwnerId(); + when(apiService.createCustomer(ownerId)).thenReturn(new CreateCustomerResponse("c123")); + + final Links docLink = new Links("docResource"); + final CreateDocumentResponse documentResponse = new CreateDocumentResponse(); + documentResponse.setLinks(docLink); + when(apiService.createDocument("c123", DocumentType.PASSPORT, ownerId)).thenReturn(documentResponse); + + final CreateDocumentPageResponse pageResponse = new CreateDocumentPageResponse( + "front", + CreateDocumentPageResponse.ErrorCodeEnum.NO_CARD_CORNERS_DETECTED, + List.of(CreateDocumentPageResponse.WarningsEnum.DOCUMENT_TYPE_NOT_RECOGNIZED)); + when(apiService.provideDocumentPage("c123", CardSide.FRONT, "img".getBytes(), ownerId)).thenReturn(pageResponse); + + final SubmittedDocument doc = new SubmittedDocument(); + doc.setType(DocumentType.PASSPORT); + doc.setSide(CardSide.FRONT); + doc.setPhoto(Image.builder().data("img".getBytes()).build()); + + final DocumentsSubmitResult results = tested.submitDocuments(ownerId, List.of(doc)); + verify(apiService).provideDocumentPage("c123", CardSide.FRONT, "img".getBytes(), ownerId); + assertEquals(1, results.getResults().size()); + + final DocumentSubmitResult result = results.getResults().get(0); + assertEquals("c123", result.getUploadId()); + assertTrue(StringUtils.hasText(result.getRejectReason())); + } + + @Test + void testParseRejectionReason() throws Exception { + final DocumentResultEntity entity = new DocumentResultEntity(); + entity.setRejectReason(new ObjectMapper().writeValueAsString(List.of("Reason1", "Reason2"))); + assertEquals(List.of("Reason1", "Reason2"), tested.parseRejectionReasons(entity)); + } + + @Test + void testParseEmptyRejectionReason() throws Exception { + final DocumentResultEntity entity = new DocumentResultEntity(); + assertTrue(tested.parseRejectionReasons(entity).isEmpty()); + } + + @Test + void testVerifyDocuments() throws Exception { + final OwnerId ownerId = createOwnerId(); + final DocumentInspectResponse response = new DocumentInspectResponse(); + when(apiService.inspectDocument("c123", ownerId)).thenReturn(response); + + final DocumentsVerificationResult result = tested.verifyDocuments(ownerId, List.of("c123")); + assertTrue(result.isAccepted()); + assertEquals("c123", result.getResults().get(0).getUploadId()); + assertNotNull(result.getVerificationId()); + assertNotNull(result.getResults().get(0).getVerificationResult()); + } + + @Test + void testVerifyDocuments_expired() throws Exception { + final OwnerId ownerId = createOwnerId(); + final DocumentInspectResponse response = new DocumentInspectResponse(true, null); + when(apiService.inspectDocument("c123", ownerId)).thenReturn(response); + + final DocumentsVerificationResult result = tested.verifyDocuments(ownerId, List.of("c123")); + assertEquals(DocumentVerificationStatus.REJECTED, result.getStatus()); + assertEquals(List.of("Document expired."), new ObjectMapper().readValue(result.getRejectReason(), new TypeReference>() {})); + assertEquals("c123", result.getResults().get(0).getUploadId()); + assertNotNull(result.getVerificationId()); + } + + private OwnerId createOwnerId() { + final OwnerId ownerId = new OwnerId(); + ownerId.setUserId("joe"); + ownerId.setActivationId("a123"); + return ownerId; + } + +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java index 7037ca40..ed57e155 100644 --- a/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java +++ b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java @@ -148,7 +148,7 @@ void testGetResult_customerInspectionRejected() throws Exception { private static SessionInfo createSessionInfo() { final SessionInfo sessionInfo = new SessionInfo(); - sessionInfo.setSessionAttributes(Map.of("InnovatricsCustomerId", CUSTOMER_ID)); + sessionInfo.setSessionAttributes(Map.of("primaryDocumentReference", CUSTOMER_ID)); return sessionInfo; } diff --git a/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsRestApiServiceTest.java b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsRestApiServiceTest.java new file mode 100644 index 00000000..f6f06598 --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsRestApiServiceTest.java @@ -0,0 +1,138 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CreateCustomerResponse; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CreateDocumentPageResponse; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test of {@link InnovatricsApiService}. + * + * @author Jan Pesek, jan.pesek@wultra.com + */ +@SpringBootTest( + classes = EnrollmentServerTestApplication.class, + properties = { + "enrollment-server-onboarding.provider.innovatrics.serviceBaseUrl=http://localhost:" + InnovatricsRestApiServiceTest.PORT + }) +@ActiveProfiles("test") +class InnovatricsRestApiServiceTest { + + static final int PORT = 52936; + + @Autowired + private InnovatricsApiService tested; + + private MockWebServer mockWebServer; + + @BeforeEach + void setup() throws Exception { + mockWebServer = new MockWebServer(); + mockWebServer.start(PORT); + } + + @AfterEach + void cleanup() throws Exception { + mockWebServer.shutdown(); + } + + @Test + void testCreateCustomer() throws Exception { + final OwnerId ownerId = createOwnerId(); + mockWebServer.enqueue(new MockResponse() + .setHeader("Content-Type", MediaType.APPLICATION_JSON) + // Real response to POST /api/v1/customers + .setBody(""" + { + "id": "c2e91b1f-0ccb-4ba0-93ae-d255a2a443af", + "links": { + "self": "/api/v1/customers/c2e91b1f-0ccb-4ba0-93ae-d255a2a443af" + } + } + """) + .setResponseCode(HttpStatus.OK.value())); + + final CreateCustomerResponse response = tested.createCustomer(ownerId); + assertEquals("c2e91b1f-0ccb-4ba0-93ae-d255a2a443af", response.getId()); + assertEquals("/api/v1/customers/c2e91b1f-0ccb-4ba0-93ae-d255a2a443af", response.getLinks().getSelf()); + } + + @Test + void testErrorResponse() { + final OwnerId ownerId = createOwnerId(); + mockWebServer.enqueue(new MockResponse() + .setHeader("Content-Type", MediaType.APPLICATION_JSON) + // Real response to uploading a page without previous document resource creation + .setBody(""" + { + "errorCode": "NOT_FOUND", + "errorMessage": "string" + } + """) + .setResponseCode(500)); + + assertThrows(RemoteCommunicationException.class, () -> tested.createCustomer(ownerId)); + } + + @Test + void testNonMatchingPageType() throws Exception { + final OwnerId ownerId = createOwnerId(); + mockWebServer.enqueue(new MockResponse() + .setHeader("Content-Type", MediaType.APPLICATION_JSON) + // Real response to uploading a second page that is different from the first one + .setBody(""" + { + "errorCode": "PAGE_DOESNT_MATCH_DOCUMENT_TYPE_OF_PREVIOUS_PAGE" + } + """) + .setResponseCode(HttpStatus.OK.value())); + + final CreateDocumentPageResponse response = tested.provideDocumentPage("123", CardSide.FRONT, "data".getBytes(), ownerId); + assertNotNull(response.getErrorCode()); + + final RecordedRequest recordedRequest = mockWebServer.takeRequest(1L, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + assertEquals("PUT /api/v1/customers/123/document/pages HTTP/1.1", recordedRequest.getRequestLine()); + } + + private OwnerId createOwnerId() { + final OwnerId ownerId = new OwnerId(); + ownerId.setUserId("joe"); + ownerId.setActivationId("a123"); + return ownerId; + } + +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/test/resources/application-test.properties b/enrollment-server-onboarding-provider-innovatrics/src/test/resources/application-test.properties new file mode 100644 index 00000000..8e68187c --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/test/resources/application-test.properties @@ -0,0 +1,8 @@ +enrollment-server-onboarding.document-verification.provider=innovatrics +enrollment-server-onboarding.presence-check.provider=innovatrics +enrollment-server-onboarding.onboarding-process.enabled=false + +enrollment-server-onboarding.provider.innovatrics.documentVerificationConfiguration.documentCountries=CZE,SVK +enrollment-server-onboarding.provider.innovatrics.document-verification-configuration.crucialFields=documentNumber, dateOfIssue, dateOfExpiry, surname, dateOfBirth, personalNumber, givenNames + +enrollment-server-onboarding.provider.innovatrics.restClientConfig.maxInMemorySize=1048576 \ No newline at end of file diff --git a/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProvider.java b/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProvider.java index 8e85e5f6..d3457922 100644 --- a/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProvider.java +++ b/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProvider.java @@ -127,8 +127,8 @@ public void initPresenceCheck(final OwnerId id, final Image photo) throws Presen } @Override - public boolean shouldProvideTrustedPhoto() { - return true; + public TrustedPhotoSource trustedPhotoSource() { + return TrustedPhotoSource.IMAGE; } @Override diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/configuration/OpenApiConfiguration.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/configuration/OpenApiConfiguration.java index ef841098..4a4dce8d 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/configuration/OpenApiConfiguration.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/configuration/OpenApiConfiguration.java @@ -53,7 +53,8 @@ public class OpenApiConfiguration { public GroupedOpenApi defaultApiGroup() { String[] packages = { "io.getlime.security.powerauth", - "com.wultra.app.onboardingserver.controller.api" + "com.wultra.app.onboardingserver.controller.api", + "com.wultra.app.onboardingserver.provider.innovatrics" }; return GroupedOpenApi.builder() diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProvider.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProvider.java index bddd9e43..69283098 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProvider.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProvider.java @@ -104,7 +104,11 @@ public DocumentsSubmitResult checkDocumentUpload(OwnerId id, DocumentVerificatio } @Override - public DocumentsSubmitResult submitDocuments(OwnerId id, List documents) { + public DocumentsSubmitResult submitDocuments(OwnerId id, List documents) throws DocumentVerificationException { + if (documents.stream().anyMatch(doc -> "throw.exception".equals(doc.getPhoto().getFilename()))) { + throw new DocumentVerificationException("Filename to throw an exception is present in documents."); + } + final List submitResults = documents.stream() .map(this::toDocumentSubmitResult) .toList(); diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckService.java index 86323a35..12471f92 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckService.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckService.java @@ -44,10 +44,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase.PRESENCE_CHECK; import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus.*; @@ -62,9 +59,6 @@ @Slf4j public class PresenceCheckService { - private static final String SESSION_ATTRIBUTE_TIMESTAMP_LAST_USED = "timestampLastUsed"; - private static final String SESSION_ATTRIBUTE_IMAGE_UPLOADED = "imageUploaded"; - private final IdentityVerificationConfig identityVerificationConfig; private final DocumentVerificationRepository documentVerificationRepository; private final DocumentProcessingService documentProcessingService; @@ -128,7 +122,7 @@ public void checkPresenceVerification( final OwnerId ownerId, final IdentityVerificationEntity idVerification) throws PresenceCheckException, RemoteCommunicationException { - final SessionInfo sessionInfo = updateSessionInfo(ownerId, idVerification, Map.of(SESSION_ATTRIBUTE_TIMESTAMP_LAST_USED, ownerId.getTimestamp())); + final SessionInfo sessionInfo = updateSessionInfo(ownerId, idVerification, Map.of(SessionInfo.ATTRIBUTE_TIMESTAMP_LAST_USED, ownerId.getTimestamp())); final PresenceCheckResult result = presenceCheckProvider.getResult(ownerId, sessionInfo); auditService.auditPresenceCheckProvider(idVerification, "Got presence check result: {} for user: {}", result.getStatus(), ownerId.getUserId()); @@ -216,15 +210,16 @@ private void initPresentCheckWithImage(final OwnerId ownerId, final IdentityVeri } final Optional photo = fetchTrustedPhoto(ownerId, idVerification); + setIdentityDocumentReferences(ownerId, idVerification); presenceCheckProvider.initPresenceCheck(ownerId, photo.orElse(null)); logger.info("Presence check initialized, {}", ownerId); - updateSessionInfo(ownerId, idVerification, Map.of(SESSION_ATTRIBUTE_IMAGE_UPLOADED, true)); + updateSessionInfo(ownerId, idVerification, Map.of(SessionInfo.ATTRIBUTE_IMAGE_UPLOADED, true)); auditService.auditPresenceCheckProvider(idVerification, "Presence check initialized for user: {}", ownerId.getUserId()); } } private Optional fetchTrustedPhoto(final OwnerId ownerId, final IdentityVerificationEntity idVerification) throws DocumentVerificationException, RemoteCommunicationException, PresenceCheckException { - if (presenceCheckProvider.shouldProvideTrustedPhoto()) { + if (PresenceCheckProvider.TrustedPhotoSource.IMAGE == presenceCheckProvider.trustedPhotoSource()) { final Image photo = fetchTrustedPhotoFromDocumentVerifier(ownerId, idVerification); final Image upscaledPhoto = imageProcessor.upscaleImage(ownerId, photo, identityVerificationConfig.getMinimalSelfieWidth()); return Optional.of(upscaledPhoto); @@ -233,6 +228,22 @@ private Optional fetchTrustedPhoto(final OwnerId ownerId, final IdentityV } } + private void setIdentityDocumentReferences(final OwnerId ownerId, final IdentityVerificationEntity idVerification) throws DocumentVerificationException, PresenceCheckException { + if (PresenceCheckProvider.TrustedPhotoSource.REFERENCE != presenceCheckProvider.trustedPhotoSource()) { + return; + } + + final List docsWithPhoto = getDocsWithPhoto(idVerification, ownerId); + final String primaryDocReference = getPreferredDocWithPhoto(docsWithPhoto, ownerId).getPhotoId(); + final List otherDocsReferences = docsWithPhoto.stream() + .map(DocumentVerificationEntity::getPhotoId) + .filter(id -> !Objects.equals(id, primaryDocReference)) + .distinct().toList(); + + updateSessionInfo(ownerId, idVerification, Map.of(SessionInfo.ATTRIBUTE_PRIMARY_DOCUMENT_REFERENCE, primaryDocReference, + SessionInfo.ATTRIBUTE_OTHER_DOCUMENTS_REFERENCES, otherDocsReferences)); + } + /** * Starts new presence check process. * @@ -264,6 +275,15 @@ private SessionInfo startPresenceCheck(OwnerId ownerId, IdentityVerificationEnti protected Image fetchTrustedPhotoFromDocumentVerifier(final OwnerId ownerId, final IdentityVerificationEntity idVerification) throws DocumentVerificationException, RemoteCommunicationException { + final List docsWithPhoto = getDocsWithPhoto(idVerification, ownerId); + final DocumentVerificationEntity preferredDocWithPhoto = getPreferredDocWithPhoto(docsWithPhoto, ownerId); + logger.info("Selected {} as the source of person photo, {}", preferredDocWithPhoto, ownerId); + final String photoId = preferredDocWithPhoto.getPhotoId(); + return identityVerificationService.getPhotoById(photoId, ownerId); + } + + private List getDocsWithPhoto(final IdentityVerificationEntity idVerification, + final OwnerId ownerId) throws DocumentVerificationException { final List docsWithPhoto = documentVerificationRepository.findAllWithPhoto(idVerification); if (docsWithPhoto.isEmpty()) { throw new DocumentVerificationException("Unable to initialize presence check - missing person photo, " + ownerId); @@ -273,23 +293,23 @@ protected Image fetchTrustedPhotoFromDocumentVerifier(final OwnerId ownerId, fin Preconditions.checkNotNull(docWithPhoto.getPhotoId(), "Expected photoId value in " + docWithPhoto) ); - DocumentVerificationEntity preferredDocWithPhoto = null; + return docsWithPhoto; + } + + private DocumentVerificationEntity getPreferredDocWithPhoto(final List docsWithPhoto, + final OwnerId ownerId) { + for (DocumentType documentType : DocumentType.PREFERRED_SOURCE_OF_PERSON_PHOTO) { Optional docEntity = docsWithPhoto.stream() .filter(value -> value.getType() == documentType) .findFirst(); if (docEntity.isPresent()) { - preferredDocWithPhoto = docEntity.get(); - break; + return docEntity.get(); } } - if (preferredDocWithPhoto == null) { - logger.warn("Unable to select a source of person photo to initialize presence check, {}", ownerId); - preferredDocWithPhoto = docsWithPhoto.get(0); - } - logger.info("Selected {} as the source of person photo, {}", preferredDocWithPhoto, ownerId); - String photoId = preferredDocWithPhoto.getPhotoId(); - return identityVerificationService.getPhotoById(photoId, ownerId); + + logger.warn("Unable to select a source of person photo to initialize presence check, {}", ownerId); + return docsWithPhoto.get(0); } private void evaluatePresenceCheckResult(OwnerId ownerId, @@ -389,6 +409,6 @@ private boolean imageAlreadyUploaded(final IdentityVerificationEntity identityVe final SessionInfo sessionInfo = jsonSerializationService.deserialize(sessionInfoString, SessionInfo.class); return sessionInfo != null && !CollectionUtils.isEmpty(sessionInfo.getSessionAttributes()) - && Boolean.TRUE.equals(sessionInfo.getSessionAttributes().get(SESSION_ATTRIBUTE_IMAGE_UPLOADED)); + && Boolean.TRUE.equals(sessionInfo.getSessionAttributes().get(SessionInfo.ATTRIBUTE_IMAGE_UPLOADED)); } } diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingService.java index 94891972..7dae9452 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingService.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingService.java @@ -17,6 +17,7 @@ */ package com.wultra.app.onboardingserver.impl.service.document; +import com.google.common.base.Strings; import com.wultra.app.enrollmentserver.api.model.onboarding.request.DocumentSubmitRequest; import com.wultra.app.enrollmentserver.model.Document; import com.wultra.app.enrollmentserver.model.DocumentMetadata; @@ -43,10 +44,9 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Optional; +import java.util.*; + +import static java.util.stream.Collectors.groupingBy; /** * Service implementing document processing features. @@ -119,22 +119,44 @@ public List submitDocuments( DocumentSubmitRequest request, OwnerId ownerId) throws DocumentSubmitException { - List documents = getDocuments(ownerId, request); - - List docVerifications = new ArrayList<>(); - List docResults = new ArrayList<>(); + checkDocumentResubmit(ownerId, request); + final List documents = getDocuments(ownerId, request); + final var documentsByType = request.getDocuments().stream() + .collect(groupingBy(DocumentSubmitRequest.DocumentMetadata::getType)); - List docsMetadata = request.getDocuments(); - for (DocumentSubmitRequest.DocumentMetadata docMetadata : docsMetadata) { - DocumentVerificationEntity docVerification = createDocumentVerification(ownerId, idVerification, docMetadata); - docVerification.setIdentityVerification(idVerification); - docVerifications.add(docVerification); + final List docVerifications = new ArrayList<>(); + for (var docMetadataList : documentsByType.values()) { + docVerifications.addAll(submitDocument(docMetadataList, documents, idVerification, ownerId)); + } + return docVerifications; + } - checkDocumentResubmit(ownerId, request, docVerification); + /** + * Submit pages of a document to document verify provider. + * @param pagesMetadata Pages metadata from request. + * @param pagesData Pages data. + * @param idVerification Identity verification entity. + * @param ownerId Owner identification. + * @return + */ + private List submitDocument(final List pagesMetadata, + final List pagesData, + final IdentityVerificationEntity idVerification, + final OwnerId ownerId) { + + // Maps are used to associate DocumentsSubmitResult - DocumentVerificationEntities - DocumentMetadata + final Map docVerifications = new HashMap<>(); + final Map docMetadataMap = new HashMap<>(); + + final List submittedDocuments = new ArrayList<>(); + for (var metadata : pagesMetadata) { + final DocumentVerificationEntity docVerification = createDocumentVerification(ownerId, idVerification, metadata); + docVerifications.put(docVerification.getId(), docVerification); + docMetadataMap.put(docVerification.getId(), metadata); + handleResubmit(ownerId, metadata.getOriginalDocumentId(), docVerification); - SubmittedDocument submittedDoc; try { - submittedDoc = createSubmittedDocument(ownerId, docMetadata, documents); + submittedDocuments.add(createSubmittedDocument(ownerId, metadata, pagesData, docVerification)); } catch (DocumentSubmitException e) { logger.warn("Document verification ID: {}, failed: {}", docVerification.getId(), e.getMessage()); logger.debug("Document verification ID: {}, failed", docVerification.getId(), e); @@ -142,60 +164,98 @@ public List submitDocuments( docVerification.setErrorDetail(ErrorDetail.DOCUMENT_VERIFICATION_FAILED); docVerification.setErrorOrigin(ErrorOrigin.DOCUMENT_VERIFICATION); auditService.audit(docVerification, "Document verification failed for user: {}", ownerId.getUserId()); - return docVerifications; + return docVerifications.values().stream().toList(); } + } - DocumentSubmitResult docSubmitResult = submitDocumentToProvider(ownerId, docVerification, submittedDoc); + final List docVerificationsList = docVerifications.values().stream().toList(); + final DocumentsSubmitResult results = submitDocumentToProvider(submittedDocuments, docVerificationsList, idVerification, ownerId); + processSubmitResults(results, docVerifications, ownerId); - // TODO - after synchronous submission to document verification provider the document state should be - // set to VERIFICATION_PENDING, for asynchronous processing the UPLOAD_IN_PROGRESS state should remain + docVerificationsList.stream() + .filter(doc -> StringUtils.isNotBlank(doc.getUploadId())) + .map(doc -> docMetadataMap.get(doc.getId()).getUploadId()) + .filter(StringUtils::isNotBlank) + .forEach(fileUploadId -> { + documentDataRepository.deleteById(fileUploadId); + logger.info("Deleted stored document data with id={}, {}", fileUploadId, ownerId); + }); - DocumentResultEntity docResult = createDocumentResult(docVerification, docSubmitResult); - docResult.setTimestampCreated(ownerId.getTimestamp()); + return docVerificationsList; + } - docResults.add(docResult); + /** + * Process submit results. + * @param results Document submit result from provider. + * @param docVerificationsMap To pair results with corresponding DocumentVerificationEntity. + * @param ownerId Owner identification. + */ + private void processSubmitResults(final DocumentsSubmitResult results, + final Map docVerificationsMap, + final OwnerId ownerId) { - // Delete previously persisted document after a successful upload to the provider - if (docVerification.getUploadId() != null && docMetadata.getUploadId() != null) { - documentDataRepository.deleteById(docMetadata.getUploadId()); - } - } + final List docResults = new ArrayList<>(); - documentVerificationRepository.saveAll(docVerifications); + for (final DocumentSubmitResult result : results.getResults()) { + final DocumentVerificationEntity docVerification = docVerificationsMap.get(result.getDocumentId()); + processDocsSubmitResults(ownerId, docVerification, results, result); - for (int i = 0; i < docVerifications.size(); i++) { - DocumentVerificationEntity docVerificationEntity = docVerifications.get(i); - docResults.get(i).setDocumentVerification(docVerificationEntity); + final DocumentResultEntity docResult = createDocumentResult(docVerification, result); + docResult.setTimestampCreated(ownerId.getTimestamp()); + docResult.setDocumentVerification(docVerification); + + docResults.add(docResult); } - documentResultRepository.saveAll(docResults); - return docVerifications; + documentVerificationRepository.saveAll(docVerificationsMap.values()); + documentResultRepository.saveAll(docResults); + logger.debug("Processed submit result of documents {}, {}", docVerificationsMap.values(), ownerId); } - public void checkDocumentResubmit(OwnerId ownerId, - DocumentSubmitRequest request, - DocumentVerificationEntity docVerification) throws DocumentSubmitException { - if (request.isResubmit() && docVerification.getOriginalDocumentId() == null) { - throw new DocumentSubmitException( - String.format("Detected a resubmit request without specified originalDocumentId for %s, %s", docVerification, ownerId)); - } else if (request.isResubmit()) { - Optional originalDocOptional = - documentVerificationRepository.findById(docVerification.getOriginalDocumentId()); - if (originalDocOptional.isEmpty()) { - logger.warn("Missing previous DocumentVerificationEntity(originalDocumentId={}), {}", - docVerification.getOriginalDocumentId(), ownerId); - } else { - DocumentVerificationEntity originalDoc = originalDocOptional.get(); - originalDoc.setStatus(DocumentStatus.DISPOSED); - originalDoc.setUsedForVerification(false); - originalDoc.setTimestampDisposed(ownerId.getTimestamp()); - originalDoc.setTimestampLastUpdated(ownerId.getTimestamp()); - logger.info("Replaced previous {} with new {}, {}", originalDocOptional, docVerification, ownerId); - auditService.audit(docVerification, "Document replaced with new one for user: {}", ownerId.getUserId()); + /** + * Validates resubmit parameters of DocumentSubmitRequest. + * @param ownerId Owner identification. + * @param request Request body. + * @throws DocumentSubmitException If request is resubmit without original document ID, or is not resubmit with original document ID + */ + private void checkDocumentResubmit(final OwnerId ownerId, final DocumentSubmitRequest request) throws DocumentSubmitException { + final boolean isResubmit = request.isResubmit(); + for (var metadata : request.getDocuments()) { + final String originalDocumentId = metadata.getOriginalDocumentId(); + if (isResubmit && StringUtils.isBlank(originalDocumentId)) { + logger.debug("Request has resubmit flag but misses originalDocumentId {}, {}", metadata, ownerId); + throw new DocumentSubmitException("Detected a resubmit request without specified originalDocumentId, %s".formatted(ownerId)); + } else if (!isResubmit && StringUtils.isNotBlank(originalDocumentId)) { + logger.debug("Request has originalDocumentId but is not flagged as resubmit {}, {}", metadata, ownerId); + throw new DocumentSubmitException("Detected a submit request with specified originalDocumentId=%s, %s".formatted(originalDocumentId, ownerId)); } - } else if (!request.isResubmit() && docVerification.getOriginalDocumentId() != null) { - throw new DocumentSubmitException( - String.format("Detected a submit request with specified originalDocumentId=%s for %s, %s", docVerification.getOriginalDocumentId(), docVerification, ownerId)); + } + } + + /** + * Sets document with originalDocumentId as disposed. If passed originalDocumentId does not exist, no further action is taken. + * @param ownerId Owner identification. + * @param originalDocumentId Id of the original document. + * @param docVerification Resubmitted document. + */ + private void handleResubmit(final OwnerId ownerId, final String originalDocumentId, final DocumentVerificationEntity docVerification) { + if (Strings.isNullOrEmpty(originalDocumentId)) { + logger.debug("Document {} is not a resubmit {}", docVerification, ownerId); + return; + } + + logger.debug("Document {} is a resubmit, {}", docVerification, ownerId); + final Optional originalDocOptional = documentVerificationRepository.findById(originalDocumentId); + if (originalDocOptional.isEmpty()) { + logger.warn("Missing previous DocumentVerificationEntity(originalDocumentId={}), {}", originalDocumentId, ownerId); + } else { + final DocumentVerificationEntity originalDoc = originalDocOptional.get(); + originalDoc.setStatus(DocumentStatus.DISPOSED); + originalDoc.setUsedForVerification(false); + originalDoc.setTimestampDisposed(ownerId.getTimestamp()); + originalDoc.setTimestampLastUpdated(ownerId.getTimestamp()); + logger.info("Replaced previous {} with new {}, {}", originalDoc, docVerification, ownerId); + auditService.audit(docVerification, "Document replaced with new one for user: {}", ownerId.getUserId()); } } @@ -243,6 +303,41 @@ public void checkDocumentSubmitWithProvider(OwnerId ownerId, DocumentResultEntit processDocsSubmitResults(ownerId, docVerification, docsSubmitResults, docSubmitResult); } + /** + * Pass all pages of a document to document verification provider at a single call. + * @param submittedDocs Document pages to submit. + * @param docVerifications Entities associated with the document pages to submit. + * @param identityVerification Identity verification entity. + * @param ownerId Owner identification. + * @return document submit result + */ + private DocumentsSubmitResult submitDocumentToProvider(final List submittedDocs, + final List docVerifications, + final IdentityVerificationEntity identityVerification, + final OwnerId ownerId) { + + final List docVerificationIds = docVerifications.stream().map(DocumentVerificationEntity::getId).toList(); + + try { + final DocumentsSubmitResult results = documentVerificationProvider.submitDocuments(ownerId, submittedDocs); + logger.debug("Documents {} submitted to provider, {}", docVerifications, ownerId); + auditService.auditDocumentVerificationProvider(identityVerification, "Submit documents for user: {}, document IDs: {}", ownerId.getUserId(), docVerificationIds); + return results; + } catch (DocumentVerificationException | RemoteCommunicationException e) { + logger.warn("Document verification ID: {}, failed: {}", docVerificationIds, e.getMessage()); + logger.debug("Document verification ID: {}, failed", docVerificationIds, e); + final DocumentsSubmitResult results = new DocumentsSubmitResult(); + submittedDocs.forEach(doc -> { + final DocumentSubmitResult result = new DocumentSubmitResult(); + result.setDocumentId(doc.getDocumentId()); + result.setErrorDetail(e.getMessage()); + results.getResults().add(result); + }); + return results; + } + + } + public DocumentSubmitResult submitDocumentToProvider(OwnerId ownerId, DocumentVerificationEntity docVerification, SubmittedDocument submittedDoc) { DocumentsSubmitResult docsSubmitResults; DocumentSubmitResult docSubmitResult; @@ -293,8 +388,8 @@ public void pairTwoSidedDocuments(List documents) { continue; } documents.stream() - .filter(item -> item.getType().equals(document.getType())) - .filter(item -> !item.getSide().equals(document.getSide())) + .filter(item -> item.getType() == document.getType()) + .filter(item -> item.getSide() != document.getSide()) .forEach(item -> { logger.debug("Found other side {} for {}", item, document); item.setOtherSideId(document.getId()); @@ -353,6 +448,7 @@ private DocumentVerificationEntity createDocumentVerification(OwnerId ownerId, I entity.setTimestampCreated(ownerId.getTimestamp()); entity.setUsedForVerification(true); final DocumentVerificationEntity saveEntity = documentVerificationRepository.save(entity); + logger.debug("Created {} for {}", saveEntity, ownerId); auditService.auditDebug(entity, "Document created for user: {}", ownerId.getUserId()); return saveEntity; } @@ -360,13 +456,14 @@ private DocumentVerificationEntity createDocumentVerification(OwnerId ownerId, I private SubmittedDocument createSubmittedDocument( OwnerId ownerId, DocumentSubmitRequest.DocumentMetadata docMetadata, - List docs) throws DocumentSubmitException { + List docs, + DocumentVerificationEntity docVerification) throws DocumentSubmitException { final Image photo = Image.builder() .filename(docMetadata.getFilename()) .build(); SubmittedDocument submittedDoc = new SubmittedDocument(); - submittedDoc.setDocumentId(docMetadata.getUploadId()); + submittedDoc.setDocumentId(docVerification.getId()); submittedDoc.setPhoto(photo); submittedDoc.setSide(docMetadata.getSide()); submittedDoc.setType(docMetadata.getType()); diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java index 92cfdb65..37463eb5 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java @@ -62,8 +62,8 @@ public void initPresenceCheck(OwnerId id, Image photo) { } @Override - public boolean shouldProvideTrustedPhoto() { - return true; + public TrustedPhotoSource trustedPhotoSource() { + return TrustedPhotoSource.IMAGE; } @Override diff --git a/enrollment-server-onboarding/src/main/resources/application.properties b/enrollment-server-onboarding/src/main/resources/application.properties index abdecabe..5a8528e9 100644 --- a/enrollment-server-onboarding/src/main/resources/application.properties +++ b/enrollment-server-onboarding/src/main/resources/application.properties @@ -21,6 +21,9 @@ spring.profiles.active=ext spring.application.name=onboarding-server +banner.application.name=${spring.application.name} +banner.application.version=@project.version@ + # Database Configuration - PostgreSQL spring.datasource.url=jdbc:postgresql://localhost:5432/powerauth spring.datasource.username=powerauth @@ -183,7 +186,11 @@ enrollment-server-onboarding.provider.innovatrics.serviceToken=${INNOVATRICS_SER enrollment-server-onboarding.provider.innovatrics.serviceUserAgent=Wultra/OnboardingServer # Innovatrics presence-check configuration -enrollment-server-onboarding.provider.innovatrics.presenceCheck.score=0.875 +enrollment-server-onboarding.provider.innovatrics.presenceCheckConfiguration.score=0.875 + +# Innovatrics document-verification configuration +enrollment-server-onboarding.provider.innovatrics.documentVerificationConfiguration.documentCountries=CZE +enrollment-server-onboarding.provider.innovatrics.document-verification-configuration.crucialFields=documentNumber, dateOfIssue, dateOfExpiry, surname, dateOfBirth, personalNumber, givenNames # Innovatrics REST client configuration enrollment-server-onboarding.provider.innovatrics.restClientConfig.acceptInvalidSslCertificate=false diff --git a/enrollment-server-onboarding/src/main/resources/banner.txt b/enrollment-server-onboarding/src/main/resources/banner.txt new file mode 100644 index 00000000..964cb7bd --- /dev/null +++ b/enrollment-server-onboarding/src/main/resources/banner.txt @@ -0,0 +1,9 @@ + ___ _ _ _ ____ + / _ \ _ __ | |__ ___ __ _ _ __ __| (_)_ __ __ _ / ___| ___ _ ____ _____ _ __ + | | | | '_ \| '_ \ / _ \ / _` | '__/ _` | | '_ \ / _` | \___ \ / _ \ '__\ \ / / _ \ '__| + | |_| | | | | |_) | (_) | (_| | | | (_| | | | | | (_| | ___) | __/ | \ V / __/ | + \___/|_| |_|_.__/ \___/ \__,_|_| \__,_|_|_| |_|\__, | |____/ \___|_| \_/ \___|_| + |___/ +${AnsiColor.GREEN} :: ${banner.application.name} (${banner.application.version}) :: ${AnsiColor.GREEN} +${AnsiColor.RED} :: Spring Boot${spring-boot.formatted-version} :: ${AnsiColor.RED} +${AnsiColor.DEFAULT} \ No newline at end of file diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java index 721d72e8..bad60c5b 100644 --- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java +++ b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java @@ -65,7 +65,7 @@ public void setProvider(WultraMockDocumentVerificationProvider provider) { } @Test - void checkDocumentUploadTest() { + void checkDocumentUploadTest() throws Exception { SubmittedDocument document = createSubmittedDocument(); DocumentsSubmitResult submitResult = provider.submitDocuments(ownerId, List.of(document)); diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckServiceTest.java b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckServiceTest.java index c5daedb8..c256c6d5 100644 --- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckServiceTest.java +++ b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckServiceTest.java @@ -16,20 +16,39 @@ */ package com.wultra.app.onboardingserver.impl.service; +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus; import com.wultra.app.enrollmentserver.model.integration.Image; import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import com.wultra.app.onboardingserver.EnrollmentServerTestApplication; +import com.wultra.app.onboardingserver.api.provider.PresenceCheckProvider; import com.wultra.app.onboardingserver.common.database.DocumentVerificationRepository; +import com.wultra.app.onboardingserver.common.database.IdentityVerificationRepository; +import com.wultra.app.onboardingserver.common.database.ScaResultRepository; import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity; +import com.wultra.app.onboardingserver.impl.service.internal.JsonSerializationService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; import java.util.List; +import java.util.Optional; +import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase.DOCUMENT_VERIFICATION_FINAL; +import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase.PRESENCE_CHECK; +import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus.*; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.*; /** @@ -38,16 +57,23 @@ * @author Lukas Lukovsky, lukas.lukovsky@wultra.com * @author Lubos Racansky, lubos.racansky@wultra.com */ -@ExtendWith(MockitoExtension.class) +@SpringBootTest(classes = EnrollmentServerTestApplication.class) +@ActiveProfiles("test") class PresenceCheckServiceTest { - @Mock + @MockBean private IdentityVerificationService identityVerificationService; - @Mock + @MockBean private DocumentVerificationRepository documentVerificationRepository; - @InjectMocks + @MockBean + private PresenceCheckLimitService presenceCheckLimitService; + + @MockBean + private PresenceCheckProvider presenceCheckProvider; + + @Autowired private PresenceCheckService tested; @Test @@ -94,4 +120,44 @@ void testFetchTrustedPhotoFromDocumentVerifier_unknownDocument() throws Exceptio verify(identityVerificationService, times(1)).getPhotoById(docPhotoUnknown.getPhotoId(), ownerId); } + @Test + void initPresentCheckWithImage_withDocumentReferences() throws Exception { + final OwnerId ownerId = new OwnerId(); + ownerId.setActivationId("a1"); + + final DocumentVerificationEntity page1 = new DocumentVerificationEntity(); + page1.setId("1"); + page1.setType(DocumentType.ID_CARD); + page1.setSide(CardSide.FRONT); + page1.setPhotoId("id_card_portrait"); + + final DocumentVerificationEntity page2 = new DocumentVerificationEntity(); + page2.setId("2"); + page2.setType(DocumentType.ID_CARD); + page2.setSide(CardSide.BACK); + page2.setPhotoId("id_card_portrait"); + + final DocumentVerificationEntity page3 = new DocumentVerificationEntity(); + page3.setId("3"); + page3.setType(DocumentType.DRIVING_LICENSE); + page3.setSide(CardSide.FRONT); + page3.setPhotoId("driving_licence_portrait"); + + when(presenceCheckProvider.trustedPhotoSource()).thenReturn(PresenceCheckProvider.TrustedPhotoSource.REFERENCE); + + final IdentityVerificationEntity identityVerification = new IdentityVerificationEntity(); + identityVerification.setPhase(PRESENCE_CHECK); + identityVerification.setStatus(NOT_INITIALIZED); + + when(documentVerificationRepository.findAllWithPhoto(identityVerification)).thenReturn(List.of(page1, page2, page3)); + when(identityVerificationService.findByOptional(ownerId)).thenReturn(Optional.of(identityVerification)); + when(presenceCheckProvider.startPresenceCheck(ownerId)).thenReturn(new SessionInfo()); + + tested.init(ownerId, "p1"); + + assertTrue(identityVerification.getSessionInfo().contains("\"primaryDocumentReference\":\"id_card_portrait\"")); + assertTrue(identityVerification.getSessionInfo().contains("\"otherDocumentsReferences\":[\"driving_licence_portrait\"]")); + verify(presenceCheckProvider).initPresenceCheck(ownerId, null); + } + } diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.java b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.java index 92fd2aeb..37309ec0 100644 --- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.java +++ b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.java @@ -17,58 +17,274 @@ */ package com.wultra.app.onboardingserver.impl.service.document; +import com.wultra.app.enrollmentserver.api.model.onboarding.request.DocumentSubmitRequest; +import com.wultra.app.enrollmentserver.model.Document; +import com.wultra.app.enrollmentserver.model.enumeration.*; +import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.onboardingserver.EnrollmentServerTestApplication; +import com.wultra.app.onboardingserver.common.database.DocumentResultRepository; import com.wultra.app.onboardingserver.common.database.DocumentVerificationRepository; +import com.wultra.app.onboardingserver.common.database.IdentityVerificationRepository; +import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity; import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; -import com.wultra.app.enrollmentserver.model.enumeration.CardSide; -import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; -import org.junit.jupiter.api.BeforeEach; +import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity; +import com.wultra.app.onboardingserver.errorhandling.DocumentSubmitException; +import com.wultra.app.onboardingserver.impl.service.DataExtractionService; import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; /** * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + * @author Jan Pesek, jan.pesek@wultra.com */ +@SpringBootTest(classes = EnrollmentServerTestApplication.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@ActiveProfiles("test") +@Sql class DocumentProcessingServiceTest { - @InjectMocks - DocumentProcessingService service; + @MockBean + DataExtractionService dataExtractionService; - @Mock + @Autowired + DocumentProcessingService tested; + + @Autowired DocumentVerificationRepository documentVerificationRepository; - @BeforeEach - public void init() { - MockitoAnnotations.openMocks(this); + @Autowired + IdentityVerificationRepository identityVerificationRepository; + + @Autowired + DocumentResultRepository documentResultRepository; + + @Test + @Sql + void testPairTwoSidedDocuments() { + tested.pairTwoSidedDocuments(documentVerificationRepository.findAll()); + assertEquals("2", documentVerificationRepository.findById("1").map(DocumentVerificationEntity::getOtherSideId).get()); + assertEquals("1", documentVerificationRepository.findById("2").map(DocumentVerificationEntity::getOtherSideId).get()); + } + + @Test + void testSubmitDocuments() throws Exception { + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get(); + assertNotNull(identityVerification); + + final List metadata = createIdCardMetadata(); + final List data = createIdCardData(); + final OwnerId ownerId = createOwnerId(); + + final DocumentSubmitRequest request = new DocumentSubmitRequest(); + request.setProcessId("p1"); + request.setResubmit(false); + request.setData("files".getBytes()); + request.setDocuments(metadata); + when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data); + + tested.submitDocuments(identityVerification, request, ownerId); + + final List documents = documentVerificationRepository.findAll(); + assertEquals(2, documents.size()); + assertThat(documents) + .extracting(DocumentVerificationEntity::getSide) + .containsExactlyInAnyOrder(CardSide.FRONT, CardSide.BACK); + assertThat(documents) + .extracting(DocumentVerificationEntity::getStatus) + .containsOnly(DocumentStatus.VERIFICATION_PENDING); + + final List results = new ArrayList<>(); + documentResultRepository.findAll().forEach(results::add); + assertEquals(2, results.size()); + assertThat(results) + .extracting(DocumentResultEntity::getDocumentVerification) + .extracting(DocumentVerificationEntity::getId) + .containsExactlyInAnyOrder(documents.stream().map(DocumentVerificationEntity::getId).toArray(String[]::new)); + assertThat(results) + .extracting(DocumentResultEntity::getPhase) + .containsOnly(DocumentProcessingPhase.UPLOAD); + } + + @Test + void testSubmitDocuments_providerThrows() throws Exception { + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get(); + assertNotNull(identityVerification); + + final List metadata = createIdCardMetadata(); + metadata.get(1).setFilename("throw.exception"); + final List data = createIdCardData(); + data.get(1).setFilename("throw.exception"); + final OwnerId ownerId = createOwnerId(); + + final DocumentSubmitRequest request = new DocumentSubmitRequest(); + request.setProcessId("p1"); + request.setResubmit(false); + request.setData("files".getBytes()); + request.setDocuments(metadata); + when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data); + + tested.submitDocuments(identityVerification, request, ownerId); + + final List documents = documentVerificationRepository.findAll(); + assertEquals(2, documents.size()); + assertThat(documents) + .extracting(DocumentVerificationEntity::getSide) + .containsExactlyInAnyOrder(CardSide.FRONT, CardSide.BACK); + assertThat(documents) + .extracting(DocumentVerificationEntity::getStatus) + .containsOnly(DocumentStatus.FAILED); + + final List results = new ArrayList<>(); + documentResultRepository.findAll().forEach(results::add); + assertEquals(2, results.size()); + assertThat(results) + .extracting(DocumentResultEntity::getErrorDetail) + .containsOnly("documentVerificationFailed"); + assertThat(results) + .extracting(DocumentResultEntity::getErrorOrigin) + .containsOnly(ErrorOrigin.DOCUMENT_VERIFICATION); + } + + @Test + void testSubmitDocuments_missingData() throws Exception { + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get(); + assertNotNull(identityVerification); + + final List metadata = createIdCardMetadata(); + final List data = Collections.emptyList(); + final OwnerId ownerId = createOwnerId(); + + final DocumentSubmitRequest request = new DocumentSubmitRequest(); + request.setProcessId("p1"); + request.setResubmit(false); + request.setData("files".getBytes()); + request.setDocuments(metadata); + when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data); + + tested.submitDocuments(identityVerification, request, ownerId); + + List documents = documentVerificationRepository.findAll(); + assertEquals(1, documents.size()); + assertThat(documents) + .extracting(DocumentVerificationEntity::getStatus) + .containsExactlyInAnyOrder(DocumentStatus.FAILED); + } + + @Test + @Sql + void testResubmitDocuments() throws Exception { + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get(); + assertNotNull(identityVerification); + + final List metadata = createIdCardMetadata(); + metadata.get(0).setOriginalDocumentId("original1"); + metadata.get(1).setOriginalDocumentId("original2"); + final List data = createIdCardData(); + final OwnerId ownerId = createOwnerId(); + + final DocumentSubmitRequest request = new DocumentSubmitRequest(); + request.setProcessId("p1"); + request.setResubmit(true); + request.setData("files".getBytes()); + request.setDocuments(metadata); + when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data); + + tested.submitDocuments(identityVerification, request, ownerId); + List documents = documentVerificationRepository.findAll(); + assertEquals(4, documents.size()); + assertThat(documents) + .extracting(DocumentVerificationEntity::getStatus) + .containsOnly(DocumentStatus.VERIFICATION_PENDING, DocumentStatus.DISPOSED); + } + + @Test + void testResubmitDocuments_missingOriginalDocumentId() throws Exception { + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get(); + assertNotNull(identityVerification); + + final List metadata = createIdCardMetadata(); + final List data = createIdCardData(); + final OwnerId ownerId = createOwnerId(); + + final DocumentSubmitRequest request = new DocumentSubmitRequest(); + request.setProcessId("p1"); + request.setResubmit(true); + request.setData("files".getBytes()); + request.setDocuments(metadata); + when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data); + + final DocumentSubmitException exception = assertThrows(DocumentSubmitException.class, + () -> tested.submitDocuments(identityVerification, request, ownerId)); + assertEquals("Detected a resubmit request without specified originalDocumentId, %s".formatted(ownerId), exception.getMessage()); } @Test - void pairTwoSidedDocumentsTest() { - DocumentVerificationEntity docIdCardFront = new DocumentVerificationEntity(); - docIdCardFront.setId("1"); - docIdCardFront.setType(DocumentType.ID_CARD); - docIdCardFront.setSide(CardSide.FRONT); - - DocumentVerificationEntity docIdCardBack = new DocumentVerificationEntity(); - docIdCardBack.setId("2"); - docIdCardBack.setType(DocumentType.ID_CARD); - docIdCardBack.setSide(CardSide.BACK); - - List documents = List.of(docIdCardFront, docIdCardBack); - - service.pairTwoSidedDocuments(documents); - when(documentVerificationRepository.setOtherDocumentSide("1", "2")).thenReturn(1); - verify(documentVerificationRepository, times(1)).setOtherDocumentSide("1", "2"); - when(documentVerificationRepository.setOtherDocumentSide("2", "1")).thenReturn(1); - verify(documentVerificationRepository, times(1)).setOtherDocumentSide("2", "1"); - assertEquals(docIdCardBack.getId(), docIdCardFront.getOtherSideId()); - assertEquals(docIdCardFront.getId(), docIdCardBack.getOtherSideId()); + void testResubmitDocuments_missingResubmitFlag() throws Exception { + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get(); + assertNotNull(identityVerification); + + final List metadata = createIdCardMetadata(); + metadata.get(0).setOriginalDocumentId("original1"); + metadata.get(1).setOriginalDocumentId("original2"); + final List data = createIdCardData(); + final OwnerId ownerId = createOwnerId(); + + final DocumentSubmitRequest request = new DocumentSubmitRequest(); + request.setProcessId("p1"); + request.setResubmit(false); + request.setData("files".getBytes()); + request.setDocuments(metadata); + when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data); + + final DocumentSubmitException exception = assertThrows(DocumentSubmitException.class, + () -> tested.submitDocuments(identityVerification, request, ownerId)); + assertEquals("Detected a submit request with specified originalDocumentId=original1, %s".formatted(ownerId), exception.getMessage()); + } + + private List createIdCardMetadata() { + final DocumentSubmitRequest.DocumentMetadata page1 = new DocumentSubmitRequest.DocumentMetadata(); + page1.setFilename("id_card_front.png"); + page1.setType(DocumentType.ID_CARD); + page1.setSide(CardSide.FRONT); + + final DocumentSubmitRequest.DocumentMetadata page2 = new DocumentSubmitRequest.DocumentMetadata(); + page2.setFilename("id_card_back.png"); + page2.setType(DocumentType.ID_CARD); + page2.setSide(CardSide.BACK); + + return List.of(page1, page2); + } + + private List createIdCardData() { + final Document documentPage1 = new Document(); + documentPage1.setData("img1".getBytes()); + documentPage1.setFilename("id_card_front.png"); + + final Document documentPage2 = new Document(); + documentPage2.setData("img2".getBytes()); + documentPage2.setFilename("id_card_back.png"); + + return List.of(documentPage1, documentPage2); + } + + private OwnerId createOwnerId() { + final OwnerId ownerId = new OwnerId(); + ownerId.setActivationId("a1"); + ownerId.setUserId("u1"); + return ownerId; } } diff --git a/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.sql b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.sql new file mode 100644 index 00000000..a8d5f54f --- /dev/null +++ b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.sql @@ -0,0 +1,2 @@ +INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created) VALUES + ('v1', 'a1', 'u1', 'p1', 'IN_PROGRESS', 'DOCUMENT_UPLOAD', now()); diff --git a/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testPairTwoSidedDocuments.sql b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testPairTwoSidedDocuments.sql new file mode 100644 index 00000000..7f5ef45c --- /dev/null +++ b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testPairTwoSidedDocuments.sql @@ -0,0 +1,6 @@ +INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created) VALUES + ('v1', 'a1', 'u1', 'p1', 'VERIFICATION_PENDING', 'DOCUMENT_VERIFICATION', now()); + +INSERT INTO es_document_verification(id, activation_id, identity_verification_id, type, side, status, used_for_verification, filename, timestamp_created) VALUES + ('1', 'a1', 'v1', 'ID_CARD', 'FRONT', 'VERIFICATION_PENDING', true, 'id_front.png', now()), + ('2', 'a1', 'v1', 'ID_CARD', 'BACK', 'VERIFICATION_PENDING', true, 'id_back.png', now()); diff --git a/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testResubmitDocuments.sql b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testResubmitDocuments.sql new file mode 100644 index 00000000..ecaf09aa --- /dev/null +++ b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testResubmitDocuments.sql @@ -0,0 +1,6 @@ +INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created) VALUES + ('v1', 'a1', 'u1', 'p1', 'VERIFICATION_PENDING', 'DOCUMENT_VERIFICATION', now()); + +INSERT INTO es_document_verification(id, activation_id, identity_verification_id, type, side, status, used_for_verification, filename, timestamp_created) VALUES + ('original1', 'a1', 'v1', 'ID_CARD', 'FRONT', 'VERIFICATION_PENDING', true, 'original_id_front.png', now()), + ('original2', 'a1', 'v1', 'ID_CARD', 'BACK', 'VERIFICATION_PENDING', true, 'original_id_back.png', now()); \ No newline at end of file diff --git a/enrollment-server/src/main/resources/application.properties b/enrollment-server/src/main/resources/application.properties index 8af60b1d..ae1737be 100644 --- a/enrollment-server/src/main/resources/application.properties +++ b/enrollment-server/src/main/resources/application.properties @@ -21,6 +21,9 @@ spring.profiles.active=ext spring.application.name=enrollment-server +banner.application.name=${spring.application.name} +banner.application.version=@project.version@ + # Database Configuration - PostgreSQL spring.datasource.url=jdbc:postgresql://localhost:5432/powerauth spring.datasource.username=powerauth diff --git a/enrollment-server/src/main/resources/banner.txt b/enrollment-server/src/main/resources/banner.txt new file mode 100644 index 00000000..b1696bc7 --- /dev/null +++ b/enrollment-server/src/main/resources/banner.txt @@ -0,0 +1,9 @@ + _____ _ _ _ ____ + | ____|_ __ _ __ ___ | | |_ __ ___ ___ _ __ | |_ / ___| ___ _ ____ _____ _ __ + | _| | '_ \| '__/ _ \| | | '_ ` _ \ / _ \ '_ \| __| \___ \ / _ \ '__\ \ / / _ \ '__| + | |___| | | | | | (_) | | | | | | | | __/ | | | |_ ___) | __/ | \ V / __/ | + |_____|_| |_|_| \___/|_|_|_| |_| |_|\___|_| |_|\__| |____/ \___|_| \_/ \___|_| + +${AnsiColor.GREEN} :: ${banner.application.name} (${banner.application.version}) :: ${AnsiColor.GREEN} +${AnsiColor.RED} :: Spring Boot${spring-boot.formatted-version} :: ${AnsiColor.RED} +${AnsiColor.DEFAULT} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 22b5f355..6cd680b3 100644 --- a/pom.xml +++ b/pom.xml @@ -88,12 +88,12 @@ - 7.0.0 + 7.1.0 - 5.10.0 - 3.2.1 - 2.2.15 - 2.2.0 + 5.10.2 + 4.0.0 + 2.2.19 + 2.3.0 1.4.2 @@ -106,6 +106,8 @@ 1.77 7.4 + + 1.4.14