From 7529debee403c63131c36b77578ee6d2e1718d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lubo=C5=A1=20Ra=C4=8Dansk=C3=BD?= Date: Tue, 5 Dec 2023 14:41:43 +0100 Subject: [PATCH 1/3] Fix #908: Innovatrics PresenceCheckProvider (#936) * Fix #908: Innovatrics PresenceCheckProvider --- docs/onboarding/Configuration-Properties.md | 2 + .../DocumentVerificationProvider.java | 9 + .../api/provider/PresenceCheckProvider.java | 12 +- .../innovatrics/InnovatricsApiService.java | 94 +++++++++- .../innovatrics/InnovatricsConfigProps.java | 10 ++ ...novatricsDocumentVerificationProvider.java | 5 + .../InnovatricsPresenceCheckProvider.java | 130 +++++++++++++- .../InnovatricsPresenceCheckProviderTest.java | 160 ++++++++++++++++++ .../iproov/IProovPresenceCheckProvider.java | 9 +- .../ZenidDocumentVerificationProvider.java | 5 + ...ultraMockDocumentVerificationProvider.java | 5 + .../impl/service/PresenceCheckService.java | 91 ++++++---- .../document/DocumentProcessingService.java | 4 + .../WultraMockPresenceCheckProvider.java | 7 +- .../src/main/resources/application.properties | 5 +- .../service/PresenceCheckServiceTest.java | 61 ++++--- .../WultraMockPresenceCheckProviderTest.java | 2 +- 17 files changed, 534 insertions(+), 77 deletions(-) create mode 100644 enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java diff --git a/docs/onboarding/Configuration-Properties.md b/docs/onboarding/Configuration-Properties.md index 330412aa..cbd0952d 100644 --- a/docs/onboarding/Configuration-Properties.md +++ b/docs/onboarding/Configuration-Properties.md @@ -142,6 +142,7 @@ The Onboarding Server uses the following public configuration properties: | `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. | @@ -150,6 +151,7 @@ The Onboarding Server uses the following public configuration properties: | `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). ## Correlation HTTP Header Configuration diff --git a/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/DocumentVerificationProvider.java b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/DocumentVerificationProvider.java index 60ceb556..dfbfb5e4 100644 --- a/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/DocumentVerificationProvider.java +++ b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/DocumentVerificationProvider.java @@ -58,6 +58,15 @@ public interface DocumentVerificationProvider { */ DocumentsSubmitResult submitDocuments(OwnerId id, List documents) throws RemoteCommunicationException, DocumentVerificationException; + /** + * A feature flag whether the selfie result gained by {@link PresenceCheckProvider} should be stored by {@link #submitDocuments(OwnerId, List)}. + *

+ * Some implementation may require this cross-sending between providers by the Onboarding server, some providers may handle it internally. + * + * @return {@code true} if cross-sending between providers should be handled by Onboarding server, {@code false} otherwise. + */ + boolean shouldStoreSelfie(); + /** * Analyze previously submitted documents, detect frauds, return binary result * 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 f8d2af93..298b5296 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 @@ -43,6 +43,15 @@ 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)}. + *

+ * 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. + */ + boolean shouldProvideTrustedPhoto(); + /** * Starts the presence check process. The process has to be initialized before this call. * @@ -67,9 +76,10 @@ public interface PresenceCheckProvider { * Cleans up all presence check data related to the identity. * * @param id Owner identification. + * @param sessionInfo Session info with presence check relevant data. * @throws PresenceCheckException In case of business logic error. * @throws RemoteCommunicationException In case of remote communication error. */ - void cleanupIdentityData(OwnerId id) throws PresenceCheckException, RemoteCommunicationException; + void cleanupIdentityData(OwnerId id, SessionInfo sessionInfo) throws PresenceCheckException, RemoteCommunicationException; } 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 92260f9b..f4a6a208 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 @@ -17,9 +17,13 @@ */ package com.wultra.app.onboardingserver.provider.innovatrics; +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.CustomerInspectResponse; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.EvaluateCustomerLivenessRequest; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.EvaluateCustomerLivenessResponse; import com.wultra.core.rest.client.base.RestClient; import com.wultra.core.rest.client.base.RestClientException; -import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; @@ -27,6 +31,8 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; /** * Implementation of the REST service toInnovatrics. @@ -43,7 +49,9 @@ @Slf4j class InnovatricsApiService { - private static final ParameterizedTypeReference STRING_TYPE_REFERENCE = new ParameterizedTypeReference<>() { }; + private static final MultiValueMap EMPTY_ADDITIONAL_HEADERS = new LinkedMultiValueMap<>(); + + private static final MultiValueMap EMPTY_QUERY_PARAMS = new LinkedMultiValueMap<>(); /** * REST client for Innovatrics calls. @@ -60,11 +68,81 @@ public InnovatricsApiService(@Qualifier("restClientInnovatrics") final RestClien this.restClient = restClient; } - // 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()); + public EvaluateCustomerLivenessResponse evaluateLiveness(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final EvaluateCustomerLivenessRequest request = new EvaluateCustomerLivenessRequest(); + request.setType(EvaluateCustomerLivenessRequest.TypeEnum.MAGNIFEYE_LIVENESS); + + final String apiPath = "/api/v1/customers/%s/liveness/evaluation".formatted(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<>() {}); + logger.info("Got {} for liveness/evaluation, {}", 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 evaluate liveness for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when evaluating liveness for customerId=" + customerId, e); + } } + + public CustomerInspectResponse inspectCustomer(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/inspect".formatted(customerId); + + 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<>() {}); + logger.info("Got {} for /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( + String.format("Failed REST call to evaluate inspect for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when evaluating inspect for customerId=" + customerId, e); + } + } + + public void deleteLiveness(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/liveness".formatted(customerId); + + try { + logger.info("Deleting liveness, {}", ownerId); + logger.debug("Deleting {}", apiPath); + final ResponseEntity response = restClient.delete(apiPath, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for liveness delete, {}", 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 delete liveness for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when deleting liveness for customerId=" + customerId, e); + } + } + + public void deleteSelfie(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/selfie".formatted(customerId); + + try { + logger.info("Deleting selfie, {}", ownerId); + logger.debug("Deleting {}", apiPath); + final ResponseEntity response = restClient.delete(apiPath, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for selfie delete, {}", 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 delete selfie for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when deleting selfie for customerId=" + customerId, e); + } + } + } 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 b12c4970..62fb7f67 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 @@ -60,4 +60,14 @@ class InnovatricsConfigProps { */ private RestClientConfiguration restClientConfig; + private PresenceCheckConfiguration presenceCheckConfiguration; + + @Getter @Setter + public static class PresenceCheckConfiguration { + /** + * Presence check minimal score threshold. + */ + private double score = 0.875; + } + } 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 c7bb40be..70d177e7 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 @@ -48,6 +48,11 @@ public DocumentsSubmitResult submitDocuments(OwnerId id, List return null; } + @Override + public boolean shouldStoreSelfie() { + return false; + } + @Override public DocumentsVerificationResult verifyDocuments(OwnerId id, List uploadIds) throws RemoteCommunicationException, DocumentVerificationException { return null; 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 1c7c6539..7899e776 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 @@ -17,6 +17,8 @@ */ package com.wultra.app.onboardingserver.provider.innovatrics; +import com.google.common.base.Strings; +import com.wultra.app.enrollmentserver.model.enumeration.PresenceCheckStatus; import com.wultra.app.enrollmentserver.model.integration.Image; import com.wultra.app.enrollmentserver.model.integration.OwnerId; import com.wultra.app.enrollmentserver.model.integration.PresenceCheckResult; @@ -24,35 +26,151 @@ import com.wultra.app.onboardingserver.api.errorhandling.PresenceCheckException; import com.wultra.app.onboardingserver.api.provider.PresenceCheckProvider; 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.EvaluateCustomerLivenessResponse; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.SelfieSimilarityWith; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import java.util.Locale; +import java.util.Optional; + /** * Implementation of the {@link PresenceCheckProvider} with Innovatrics. * * @author Jan Pesek, jan.pesek@wultra.com + * @author Lubos Racansky, lubos.racansky@wultra.com */ @ConditionalOnProperty(value = "enrollment-server-onboarding.presence-check.provider", havingValue = "innovatrics") @Component +@Slf4j +@AllArgsConstructor class InnovatricsPresenceCheckProvider implements PresenceCheckProvider { + private static final String INNOVATRICS_CUSTOMER_ID = "InnovatricsCustomerId"; + + private final InnovatricsApiService innovatricsApiService; + + private final InnovatricsConfigProps configuration; + @Override - public void initPresenceCheck(OwnerId id, Image photo) throws PresenceCheckException, RemoteCommunicationException { + public void initPresenceCheck(final OwnerId id, final Image photo) { + logger.debug("#initPresenceCheck does nothing for Innovatrics, {}", id); + } + @Override + public boolean shouldProvideTrustedPhoto() { + return false; } @Override - public SessionInfo startPresenceCheck(OwnerId id) throws PresenceCheckException, RemoteCommunicationException { - return null; + public SessionInfo startPresenceCheck(final OwnerId id) { + logger.debug("#startPresenceCheck does nothing for Innovatrics, {}", id); + return new SessionInfo(); } @Override - public PresenceCheckResult getResult(OwnerId id, SessionInfo sessionInfo) throws PresenceCheckException, RemoteCommunicationException { - return null; + public PresenceCheckResult getResult(final OwnerId id, final SessionInfo sessionInfo) throws PresenceCheckException, RemoteCommunicationException { + final String customerId = fetchCustomerId(id, sessionInfo); + + final Optional evaluateLivenessError = evaluateLiveness(customerId, id); + if (evaluateLivenessError.isPresent()) { + return convert(evaluateLivenessError.get()); + } + logger.debug("Liveness passed, {}", id); + + // do not be afraid of the timing attack, the action is invoked by the state machine, not by the user + final Optional inspectCustomerError = inspectCustomer(customerId, id); + if (inspectCustomerError.isPresent()) { + return convert(inspectCustomerError.get()); + } + logger.debug("Customer inspection passed, {}", id); + + return accepted(); + } + + private static PresenceCheckResult accepted() { + final PresenceCheckResult result = new PresenceCheckResult(); + result.setStatus(PresenceCheckStatus.ACCEPTED); + return result; + } + + private static PresenceCheckResult convert(final PresenceCheckError source) { + final PresenceCheckResult target = new PresenceCheckResult(); + target.setStatus(source.status()); + target.setErrorDetail(source.errorDetail()); + target.setRejectReason(source.rejectReason()); + return target; + } + + private Optional evaluateLiveness(final String customerId, final OwnerId id) throws RemoteCommunicationException { + final EvaluateCustomerLivenessResponse livenessResponse = innovatricsApiService.evaluateLiveness(customerId, id); + final Double score = livenessResponse.getScore(); + final EvaluateCustomerLivenessResponse.ErrorCodeEnum errorCode = livenessResponse.getErrorCode(); + logger.debug("Presence check score: {}, errorCode: {}, {}", score, errorCode, id); + final double scoreThreshold = configuration.getPresenceCheckConfiguration().getScore(); + + if (score == null) { + return fail(errorCode == null ? "Score is null" : errorCode.getValue()); + } else if (score < scoreThreshold) { + return reject(String.format(Locale.ENGLISH, "Score %.3f is bellow the threshold %.3f", score, scoreThreshold)); + } else { + return success(); + } + } + + private Optional inspectCustomer(final String customerId, final OwnerId id) throws RemoteCommunicationException{ + final CustomerInspectResponse customerInspectResponse = innovatricsApiService.inspectCustomer(customerId, id); + + if (customerInspectResponse.getSelfieInspection() == null || customerInspectResponse.getSelfieInspection().getSimilarityWith() == null) { + return fail("Missing selfie inspection payload"); + } + + final SelfieSimilarityWith similarityWith = customerInspectResponse.getSelfieInspection().getSimilarityWith(); + + if (!Boolean.TRUE.equals(similarityWith.getLivenessSelfies())) { + return reject("The person in the selfie does not match a person in each liveness selfie"); + } else if (!Boolean.TRUE.equals(similarityWith.getDocumentPortrait())) { + return reject("The person in the selfie does not match a person in the document portrait"); + } else { + return success(); + } + } + + private static Optional success() { + return Optional.empty(); + } + + private static Optional reject(final String rejectReason) { + return Optional.of(new PresenceCheckError(PresenceCheckStatus.REJECTED, rejectReason, null)); + } + + private static Optional fail(final String errorDetail) { + return Optional.of(new PresenceCheckError(PresenceCheckStatus.FAILED, null, 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); + if (Strings.isNullOrEmpty(customerId)) { + throw new PresenceCheckException("Missing a customer ID value for calling Innovatrics, " + id); + } + return customerId; } @Override - public void cleanupIdentityData(OwnerId id) throws PresenceCheckException, RemoteCommunicationException { + 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); } + + 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/InnovatricsPresenceCheckProviderTest.java b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java new file mode 100644 index 00000000..7037ca40 --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java @@ -0,0 +1,160 @@ +/* + * 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.PresenceCheckStatus; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.model.integration.PresenceCheckResult; +import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CustomerInspectResponse; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.EvaluateCustomerLivenessResponse; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.SelfieInspection; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.SelfieSimilarityWith; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.when; + +/** + * Test for {@link InnovatricsPresenceCheckProvider}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@ExtendWith(MockitoExtension.class) +class InnovatricsPresenceCheckProviderTest { + + private static final String CUSTOMER_ID = "customer-1"; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private InnovatricsConfigProps innovatricsConfigProps; + + @Mock + private InnovatricsApiService innovatricsApiService; + + @InjectMocks + private InnovatricsPresenceCheckProvider tested; + + @Test + void testGetResult_success() throws Exception { + final OwnerId id = new OwnerId(); + final SessionInfo sessionInfo = createSessionInfo(); + + when(innovatricsApiService.evaluateLiveness(CUSTOMER_ID, id)) + .thenReturn(new EvaluateCustomerLivenessResponse(0.95, null)); + when(innovatricsConfigProps.getPresenceCheckConfiguration().getScore()) + .thenReturn(0.80); + when(innovatricsApiService.inspectCustomer(CUSTOMER_ID, id)) + .thenReturn(createCustomerInspectResponse(true, true)); + + final PresenceCheckResult result = tested.getResult(id, sessionInfo); + + assertEquals(PresenceCheckStatus.ACCEPTED, result.getStatus()); + assertNull(result.getErrorDetail()); + assertNull(result.getRejectReason()); + } + + @Test + void testGetResult_livenessFailed() throws Exception { + final OwnerId id = new OwnerId(); + final SessionInfo sessionInfo = createSessionInfo(); + + when(innovatricsApiService.evaluateLiveness(CUSTOMER_ID, id)) + .thenReturn(new EvaluateCustomerLivenessResponse(null, EvaluateCustomerLivenessResponse.ErrorCodeEnum.NOT_ENOUGH_DATA)); + + final PresenceCheckResult result = tested.getResult(id, sessionInfo); + + assertEquals(PresenceCheckStatus.FAILED, result.getStatus()); + assertEquals("NOT_ENOUGH_DATA", result.getErrorDetail()); + assertNull(result.getRejectReason()); + } + + @Test + void testGetResult_livenessRejected() throws Exception { + final OwnerId id = new OwnerId(); + final SessionInfo sessionInfo = createSessionInfo(); + + when(innovatricsApiService.evaluateLiveness(CUSTOMER_ID, id)) + .thenReturn(new EvaluateCustomerLivenessResponse(0.70, null)); + when(innovatricsConfigProps.getPresenceCheckConfiguration().getScore()) + .thenReturn(0.875); + + final PresenceCheckResult result = tested.getResult(id, sessionInfo); + + assertEquals(PresenceCheckStatus.REJECTED, result.getStatus()); + assertNull(result.getErrorDetail()); + assertEquals("Score 0.700 is bellow the threshold 0.875", result.getRejectReason()); + } + + @Test + void testGetResult_customerInspectionFailed() throws Exception { + final OwnerId id = new OwnerId(); + final SessionInfo sessionInfo = createSessionInfo(); + + when(innovatricsApiService.evaluateLiveness(CUSTOMER_ID, id)) + .thenReturn(new EvaluateCustomerLivenessResponse(0.95, null)); + when(innovatricsConfigProps.getPresenceCheckConfiguration().getScore()) + .thenReturn(0.80); + when(innovatricsApiService.inspectCustomer(CUSTOMER_ID, id)) + .thenReturn(new CustomerInspectResponse()); // selfieInspection == null + + final PresenceCheckResult result = tested.getResult(id, sessionInfo); + + assertEquals(PresenceCheckStatus.FAILED, result.getStatus()); + assertEquals("Missing selfie inspection payload", result.getErrorDetail()); + assertNull(result.getRejectReason()); + } + + @Test + void testGetResult_customerInspectionRejected() throws Exception { + final OwnerId id = new OwnerId(); + final SessionInfo sessionInfo = createSessionInfo(); + + when(innovatricsApiService.evaluateLiveness(CUSTOMER_ID, id)) + .thenReturn(new EvaluateCustomerLivenessResponse(0.95, null)); + when(innovatricsConfigProps.getPresenceCheckConfiguration().getScore()) + .thenReturn(0.80); + when(innovatricsApiService.inspectCustomer(CUSTOMER_ID, id)) + .thenReturn(createCustomerInspectResponse(false, true)); + + final PresenceCheckResult result = tested.getResult(id, sessionInfo); + + assertEquals(PresenceCheckStatus.REJECTED, result.getStatus()); + assertNull(result.getErrorDetail()); + assertEquals("The person in the selfie does not match a person in the document portrait", result.getRejectReason()); + } + + private static SessionInfo createSessionInfo() { + final SessionInfo sessionInfo = new SessionInfo(); + sessionInfo.setSessionAttributes(Map.of("InnovatricsCustomerId", CUSTOMER_ID)); + return sessionInfo; + } + + private static CustomerInspectResponse createCustomerInspectResponse(final Boolean documentPortrait, final Boolean livenessSelfies) { + return new CustomerInspectResponse() + .selfieInspection(new SelfieInspection() + .similarityWith(new SelfieSimilarityWith(documentPortrait, livenessSelfies))); + } +} \ 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 aa15ce1b..8e85e5f6 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 @@ -40,6 +40,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; +import org.springframework.util.Assert; import java.util.Base64; @@ -80,6 +81,7 @@ public IProovPresenceCheckProvider( @Override public void initPresenceCheck(final OwnerId id, final Image photo) throws PresenceCheckException, RemoteCommunicationException { + Assert.notNull(photo, "iProov presence check requires trusted photo"); iProovRestApiService.deleteUserIfAlreadyExists(id); final ResponseEntity responseEntityToken = callGenerateEnrolToken(id); @@ -124,6 +126,11 @@ public void initPresenceCheck(final OwnerId id, final Image photo) throws Presen } } + @Override + public boolean shouldProvideTrustedPhoto() { + return true; + } + @Override public SessionInfo startPresenceCheck(OwnerId id) throws PresenceCheckException, RemoteCommunicationException { final ResponseEntity responseEntity; @@ -210,7 +217,7 @@ private static PresenceCheckResult convert(final ClaimValidateResponse source, f } @Override - public void cleanupIdentityData(final OwnerId id) { + public void cleanupIdentityData(final OwnerId id, final SessionInfo sessionInfo) { // https://docs.iproov.com/docs/Content/ImplementationGuide/security/data-retention.htm logger.info("No data deleted, retention policy left to iProov server, {}", id); } diff --git a/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidDocumentVerificationProvider.java b/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidDocumentVerificationProvider.java index 6f766b97..012d4871 100644 --- a/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidDocumentVerificationProvider.java +++ b/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidDocumentVerificationProvider.java @@ -178,6 +178,11 @@ public DocumentsSubmitResult submitDocuments(OwnerId id, List return result; } + @Override + public boolean shouldStoreSelfie() { + return true; + } + @Override public DocumentsVerificationResult verifyDocuments(OwnerId id, List uploadIds) throws RemoteCommunicationException, DocumentVerificationException { ResponseEntity responseEntity; 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 539e9b36..bddd9e43 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 @@ -122,6 +122,11 @@ public DocumentsSubmitResult submitDocuments(OwnerId id, List return result; } + @Override + public boolean shouldStoreSelfie() { + return true; + } + @Override public DocumentsVerificationResult verifyDocuments(OwnerId id, List uploadIds) { final String verificationId = UUID.randomUUID().toString(); 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 814e8fd7..86323a35 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 @@ -21,6 +21,9 @@ import com.google.common.base.Preconditions; import com.wultra.app.enrollmentserver.model.enumeration.*; import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.PresenceCheckException; +import com.wultra.app.onboardingserver.api.provider.PresenceCheckProvider; import com.wultra.app.onboardingserver.common.database.DocumentVerificationRepository; import com.wultra.app.onboardingserver.common.database.ScaResultRepository; import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; @@ -31,16 +34,12 @@ import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; import com.wultra.app.onboardingserver.common.service.AuditService; import com.wultra.app.onboardingserver.configuration.IdentityVerificationConfig; -import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; -import com.wultra.app.onboardingserver.api.errorhandling.PresenceCheckException; import com.wultra.app.onboardingserver.errorhandling.PresenceCheckLimitException; import com.wultra.app.onboardingserver.impl.service.document.DocumentProcessingService; import com.wultra.app.onboardingserver.impl.service.internal.JsonSerializationService; -import com.wultra.app.onboardingserver.api.provider.PresenceCheckProvider; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; @@ -60,10 +59,9 @@ */ @Service @AllArgsConstructor +@Slf4j public class PresenceCheckService { - private static final Logger logger = LoggerFactory.getLogger(PresenceCheckService.class); - private static final String SESSION_ATTRIBUTE_TIMESTAMP_LAST_USED = "timestampLastUsed"; private static final String SESSION_ATTRIBUTE_IMAGE_UPLOADED = "imageUploaded"; @@ -141,13 +139,20 @@ public void checkPresenceVerification( logger.debug("Processing a result of an accepted presence check, {}", ownerId); } - // Process the photo irrespective of the result status - final Image photo = result.getPhoto(); - if (photo == null) { - evaluatePresenceCheckResult(ownerId, idVerification, result); - throw new PresenceCheckException("Missing person photo from presence verification, " + ownerId); + if (!documentProcessingService.shouldDocumentProviderStoreSelfie()) { + logger.debug("Selfie will not be submitted to document provider, {}", ownerId); + } else if (result.getPhoto() == null) { + logger.warn("Missing person photo from presence verification, {}", ownerId); + } else { + logger.debug("Obtained a person photo from the presence verification, {}", ownerId); + submitSelfiePhoto(ownerId, idVerification, result); } - logger.debug("Obtained a photo from the result, {}", ownerId); + + evaluatePresenceCheckResult(ownerId, idVerification, result); + } + + private void submitSelfiePhoto(final OwnerId ownerId, final IdentityVerificationEntity idVerification, final PresenceCheckResult result) { + final Image photo = result.getPhoto(); final SubmittedDocument submittedDoc = new SubmittedDocument(); // TODO use different random id approach @@ -157,22 +162,20 @@ public void checkPresenceVerification( submittedDoc.setPhoto(photo); submittedDoc.setType(DocumentType.SELFIE_PHOTO); - DocumentVerificationEntity docVerificationEntity = new DocumentVerificationEntity(); + final DocumentVerificationEntity docVerificationEntity = new DocumentVerificationEntity(); docVerificationEntity.setActivationId(ownerId.getActivationId()); docVerificationEntity.setIdentityVerification(idVerification); - docVerificationEntity.setFilename(result.getPhoto().getFilename()); + docVerificationEntity.setFilename(photo.getFilename()); docVerificationEntity.setTimestampCreated(ownerId.getTimestamp()); docVerificationEntity.setType(DocumentType.SELFIE_PHOTO); docVerificationEntity.setUsedForVerification(identityVerificationConfig.isVerifySelfieWithDocumentsEnabled()); - DocumentSubmitResult documentSubmitResult = + final DocumentSubmitResult documentSubmitResult = documentProcessingService.submitDocumentToProvider(ownerId, docVerificationEntity, submittedDoc); docVerificationEntity.setTimestampUploaded(ownerId.getTimestamp()); docVerificationEntity.setUploadId(documentSubmitResult.getUploadId()); documentVerificationRepository.save(docVerificationEntity); - - evaluatePresenceCheckResult(ownerId, idVerification, result); } /** @@ -184,9 +187,9 @@ public void checkPresenceVerification( */ public void cleanup(OwnerId ownerId) throws PresenceCheckException, RemoteCommunicationException { if (identityVerificationConfig.isPresenceCheckCleanupEnabled()) { - presenceCheckProvider.cleanupIdentityData(ownerId); final IdentityVerificationEntity identityVerification = identityVerificationService.findByOptional(ownerId).orElseThrow(() -> new PresenceCheckException("Unable to find identity verification for " + ownerId)); + presenceCheckProvider.cleanupIdentityData(ownerId, deserializeSessionInfo(identityVerification, ownerId)); auditService.auditPresenceCheckProvider(identityVerification, "Clean up presence check data for user: {}", ownerId.getUserId()); } else { logger.debug("Skipped cleanup of presence check data at the provider (not enabled), {}", ownerId); @@ -198,7 +201,7 @@ public void cleanup(OwnerId ownerId) throws PresenceCheckException, RemoteCommun * * @param ownerId Owner identification. * @param idVerification Verification identity. - * @throws DocumentVerificationException When not able to find documet image. + * @throws DocumentVerificationException When not able to find document image. * @throws PresenceCheckException In case of business logic error. * @throws RemoteCommunicationException In case of remote communication error. */ @@ -212,17 +215,21 @@ private void initPresentCheckWithImage(final OwnerId ownerId, final IdentityVeri return; } - final List docsWithPhoto = documentVerificationRepository.findAllWithPhoto(idVerification); - if (docsWithPhoto.isEmpty()) { - throw new PresenceCheckException("Unable to initialize presence check - missing person photo, " + ownerId); - } else { - final Image photo = selectPhotoForPresenceCheck(ownerId, docsWithPhoto); - final Image upscaledPhoto = imageProcessor.upscaleImage(ownerId, photo, identityVerificationConfig.getMinimalSelfieWidth()); - presenceCheckProvider.initPresenceCheck(ownerId, upscaledPhoto); - logger.info("Presence check initialized, {}", ownerId); - updateSessionInfo(ownerId, idVerification, Map.of(SESSION_ATTRIBUTE_IMAGE_UPLOADED, true)); - auditService.auditPresenceCheckProvider(idVerification, "Presence check initialized for user: {}", ownerId.getUserId()); - } + final Optional photo = fetchTrustedPhoto(ownerId, idVerification); + presenceCheckProvider.initPresenceCheck(ownerId, photo.orElse(null)); + logger.info("Presence check initialized, {}", ownerId); + updateSessionInfo(ownerId, idVerification, Map.of(SESSION_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()) { + final Image photo = fetchTrustedPhotoFromDocumentVerifier(ownerId, idVerification); + final Image upscaledPhoto = imageProcessor.upscaleImage(ownerId, photo, identityVerificationConfig.getMinimalSelfieWidth()); + return Optional.of(upscaledPhoto); + } else { + return Optional.empty(); } } @@ -249,12 +256,19 @@ private SessionInfo startPresenceCheck(OwnerId ownerId, IdentityVerificationEnti /** * Selects person photo for the presence check process * @param ownerId Owner identification. - * @param docsWithPhoto Documents with a mined person photography. + * @param idVerification Verification identity. * @return Image with a person photography * @throws RemoteCommunicationException In case of remote communication error. * @throws DocumentVerificationException In case of business logic error. */ - protected Image selectPhotoForPresenceCheck(OwnerId ownerId, List docsWithPhoto) throws DocumentVerificationException, RemoteCommunicationException { + protected Image fetchTrustedPhotoFromDocumentVerifier(final OwnerId ownerId, final IdentityVerificationEntity idVerification) + throws DocumentVerificationException, RemoteCommunicationException { + + final List docsWithPhoto = documentVerificationRepository.findAllWithPhoto(idVerification); + if (docsWithPhoto.isEmpty()) { + throw new DocumentVerificationException("Unable to initialize presence check - missing person photo, " + ownerId); + } + docsWithPhoto.forEach(docWithPhoto -> Preconditions.checkNotNull(docWithPhoto.getPhotoId(), "Expected photoId value in " + docWithPhoto) ); @@ -262,7 +276,7 @@ protected Image selectPhotoForPresenceCheck(OwnerId ownerId, List docEntity = docsWithPhoto.stream() - .filter(value -> documentType.equals(value.getType())) + .filter(value -> value.getType() == documentType) .findFirst(); if (docEntity.isPresent()) { preferredDocWithPhoto = docEntity.get(); @@ -352,13 +366,18 @@ private IdentityVerificationEntity fetchIdVerification(OwnerId ownerId) throws P } private SessionInfo updateSessionInfo(final OwnerId ownerId, final IdentityVerificationEntity identityVerification, final Map sessionAttributes) throws PresenceCheckException { + final SessionInfo sessionInfo = deserializeSessionInfo(identityVerification, ownerId); + sessionInfo.getSessionAttributes().putAll(sessionAttributes); + identityVerification.setSessionInfo(jsonSerializationService.serialize(sessionInfo)); + return sessionInfo; + } + + private SessionInfo deserializeSessionInfo(final IdentityVerificationEntity identityVerification, final OwnerId ownerId) throws PresenceCheckException { final String sessionInfoString = StringUtils.defaultIfEmpty(identityVerification.getSessionInfo(), "{}"); final SessionInfo sessionInfo = jsonSerializationService.deserialize(sessionInfoString, SessionInfo.class); if (sessionInfo == null) { throw new PresenceCheckException("Unable to parse SessionInfo, identity verification ID: %s, %s".formatted(identityVerification.getId(), ownerId)); } - sessionInfo.getSessionAttributes().putAll(sessionAttributes); - identityVerification.setSessionInfo(jsonSerializationService.serialize(sessionInfo)); return sessionInfo; } 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 5cd96d74..94891972 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 @@ -263,6 +263,10 @@ public DocumentSubmitResult submitDocumentToProvider(OwnerId ownerId, DocumentVe return docSubmitResult; } + public boolean shouldDocumentProviderStoreSelfie() { + return documentVerificationProvider.shouldStoreSelfie(); + } + /** * Upload a single document related to identity verification. * @param idVerification Identity verification entity. 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 797d5212..a4224179 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 @@ -61,6 +61,11 @@ public void initPresenceCheck(OwnerId id, Image photo) { logger.info("Mock - initialized presence check with a photo, {}", id); } + @Override + public boolean shouldProvideTrustedPhoto() { + return true; + } + @Override public SessionInfo startPresenceCheck(OwnerId id) { String token = UUID.randomUUID().toString(); @@ -101,7 +106,7 @@ private static byte[] readImage() { } @Override - public void cleanupIdentityData(OwnerId id) { + public void cleanupIdentityData(final OwnerId id, final SessionInfo sessionInfo) { logger.info("Mock - cleaned up identity data, {}", id); } diff --git a/enrollment-server-onboarding/src/main/resources/application.properties b/enrollment-server-onboarding/src/main/resources/application.properties index a35d7ba1..45255f2e 100644 --- a/enrollment-server-onboarding/src/main/resources/application.properties +++ b/enrollment-server-onboarding/src/main/resources/application.properties @@ -164,11 +164,14 @@ enrollment-server-onboarding.presence-check.iproov.restClientConfig.connectionTi enrollment-server-onboarding.presence-check.iproov.restClientConfig.responseTimeout=60000 enrollment-server-onboarding.presence-check.iproov.restClientConfig.maxIdleTime=200s -# Innovatrics configuration +# Innovatrics common configuration enrollment-server-onboarding.provider.innovatrics.serviceBaseUrl=${INNOVATRICS_SERVICE_BASE_URL} enrollment-server-onboarding.provider.innovatrics.serviceToken=${INNOVATRICS_SERVICE_TOKEN} enrollment-server-onboarding.provider.innovatrics.serviceUserAgent=Wultra/OnboardingServer +# Innovatrics presence-check configuration +enrollment-server-onboarding.provider.innovatrics.presenceCheck.score=0.875 + # Innovatrics REST client configuration enrollment-server-onboarding.provider.innovatrics.restClientConfig.acceptInvalidSslCertificate=false enrollment-server-onboarding.provider.innovatrics.restClientConfig.maxInMemorySize=10485760 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 3bef254f..c5daedb8 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,64 +16,81 @@ */ package com.wultra.app.onboardingserver.impl.service; -import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; import com.wultra.app.enrollmentserver.model.integration.Image; import com.wultra.app.enrollmentserver.model.integration.OwnerId; -import org.junit.jupiter.api.BeforeEach; +import com.wultra.app.onboardingserver.common.database.DocumentVerificationRepository; +import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; +import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; import static org.mockito.Mockito.*; /** + * Test for {@link PresenceCheckService}. + * * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + * @author Lubos Racansky, lubos.racansky@wultra.com */ +@ExtendWith(MockitoExtension.class) class PresenceCheckServiceTest { @Mock private IdentityVerificationService identityVerificationService; - @InjectMocks - private PresenceCheckService service; + @Mock + private DocumentVerificationRepository documentVerificationRepository; - @BeforeEach - void init() { - MockitoAnnotations.openMocks(this); - } + @InjectMocks + private PresenceCheckService tested; @Test - void selectPhotoForPresenceCheckTest() throws Exception { - OwnerId ownerId = new OwnerId(); + void testFetchTrustedPhotoFromDocumentVerifier_reverseOrder() throws Exception { + final OwnerId ownerId = new OwnerId(); + final IdentityVerificationEntity identityVerification = new IdentityVerificationEntity(); - // Two documents with person photo in reversed order of preference - DocumentVerificationEntity docPhotoDrivingLicense = new DocumentVerificationEntity(); + final DocumentVerificationEntity docPhotoDrivingLicense = new DocumentVerificationEntity(); docPhotoDrivingLicense.setPhotoId("drivingLicensePhotoId"); docPhotoDrivingLicense.setType(DocumentType.DRIVING_LICENSE); - DocumentVerificationEntity docPhotoIdCard = new DocumentVerificationEntity(); + final DocumentVerificationEntity docPhotoIdCard = new DocumentVerificationEntity(); docPhotoIdCard.setPhotoId("idCardPhotoId"); docPhotoIdCard.setType(DocumentType.ID_CARD); - List documentsReversedOrder = List.of(docPhotoDrivingLicense, docPhotoIdCard); + final List documentsReversedOrder = List.of(docPhotoDrivingLicense, docPhotoIdCard); + + when(documentVerificationRepository.findAllWithPhoto(identityVerification)) + .thenReturn(documentsReversedOrder); + when(identityVerificationService.getPhotoById(docPhotoIdCard.getPhotoId(), ownerId)) + .thenReturn(Image.builder().build()); + + tested.fetchTrustedPhotoFromDocumentVerifier(ownerId, identityVerification); - service.selectPhotoForPresenceCheck(ownerId, documentsReversedOrder); - when(identityVerificationService.getPhotoById(docPhotoIdCard.getPhotoId(), ownerId)).thenReturn(Image.builder().build()); verify(identityVerificationService, times(1)).getPhotoById(docPhotoIdCard.getPhotoId(), ownerId); + } - // Unknown document with a person photo - DocumentVerificationEntity docPhotoUnknown = new DocumentVerificationEntity(); + @Test + void testFetchTrustedPhotoFromDocumentVerifier_unknownDocument() throws Exception { + final OwnerId ownerId = new OwnerId(); + final IdentityVerificationEntity identityVerification = new IdentityVerificationEntity(); + + final DocumentVerificationEntity docPhotoUnknown = new DocumentVerificationEntity(); docPhotoUnknown.setPhotoId("unknownPhotoId"); docPhotoUnknown.setType(DocumentType.UNKNOWN); - List documentUnknown = List.of(docPhotoUnknown); + when(documentVerificationRepository.findAllWithPhoto(identityVerification)) + .thenReturn(List.of(docPhotoUnknown)); + when(identityVerificationService.getPhotoById(docPhotoUnknown.getPhotoId(), ownerId)) + .thenReturn(Image.builder().build()); + + tested.fetchTrustedPhotoFromDocumentVerifier(ownerId, identityVerification); - service.selectPhotoForPresenceCheck(ownerId, documentUnknown); - when(identityVerificationService.getPhotoById(docPhotoUnknown.getPhotoId(), ownerId)).thenReturn(Image.builder().build()); verify(identityVerificationService, times(1)).getPhotoById(docPhotoUnknown.getPhotoId(), ownerId); } diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProviderTest.java b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProviderTest.java index 6f3f1924..5361e51d 100644 --- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProviderTest.java +++ b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProviderTest.java @@ -86,7 +86,7 @@ void getResultTest() { @Test void cleanupIdentityDataTest() { - provider.cleanupIdentityData(ownerId); + provider.cleanupIdentityData(ownerId, new SessionInfo()); } private OwnerId createOwnerId() { From 11bc4fb8223fa61350ff5b44333a029d9241b038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20=C5=A0trobl?= Date: Wed, 6 Dec 2023 14:45:55 +0100 Subject: [PATCH 2/3] Fix issues found during operation claim integration into mobile token (#943) --- docs/Mobile-Token-API.md | 3 +-- .../app/enrollmentserver/impl/service/MobileTokenService.java | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/Mobile-Token-API.md b/docs/Mobile-Token-API.md index 2647df51..d155655e 100644 --- a/docs/Mobile-Token-API.md +++ b/docs/Mobile-Token-API.md @@ -395,8 +395,7 @@ Claim an operation for a user. ```json { "requestObject": { - "id": "7e0ba60f-bf22-4ff5-b999-2733784e5eaa", - "userId": "user12345" + "id": "7e0ba60f-bf22-4ff5-b999-2733784e5eaa" } } ``` diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/MobileTokenService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/MobileTokenService.java index b2f87be5..5f27018c 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/MobileTokenService.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/MobileTokenService.java @@ -278,7 +278,8 @@ public Response operationReject( */ public Operation getOperationDetail(String operationId, String language, String userId) throws MobileTokenException, PowerAuthClientException, MobileTokenConfigurationException { final OperationDetailResponse operationDetail = getOperationDetailInternal(operationId); - if (!userId.equals(operationDetail.getUserId())) { + // Check user ID against authenticated user, however skip the check in case operation is not claimed yet + if (operationDetail.getUserId() != null && !userId.equals(operationDetail.getUserId())) { logger.warn("User ID from operation does not match authenticated user ID."); throw new MobileTokenException(ErrorCode.INVALID_REQUEST, "Invalid request"); } From c46e03226f21856c221f278d733579309e9b1241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lubo=C5=A1=20Ra=C4=8Dansk=C3=BD?= Date: Thu, 7 Dec 2023 09:40:39 +0100 Subject: [PATCH 3/3] Fix #942: Rename proximity anti-fraud check parameters (#944) * Fix #942: Rename proximity anti-fraud check parameters --- .../controller/api/MobileTokenController.java | 2 +- .../model/request/OperationApproveRequest.java | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/MobileTokenController.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/MobileTokenController.java index 1a901b16..3da69aba 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/MobileTokenController.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/MobileTokenController.java @@ -300,7 +300,7 @@ private static String fetchProximityCheckOtp(OperationApproveRequest requestObje return null; } final var proximityCheck = requestObject.getProximityCheck().get(); - logger.info("Operation ID: {} using proximity check OTP, timestampRequested: {}, timestampSigned: {}", requestObject.getId(), proximityCheck.getTimestampRequested(), proximityCheck.getTimestampSigned()); + logger.info("Operation ID: {} using proximity check OTP, timestampReceived: {}, timestampSent: {}", requestObject.getId(), proximityCheck.getTimestampReceived(), proximityCheck.getTimestampSent()); return proximityCheck.getOtp(); } diff --git a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationApproveRequest.java b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationApproveRequest.java index 00c528ac..4a633f86 100644 --- a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationApproveRequest.java +++ b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationApproveRequest.java @@ -60,16 +60,16 @@ public static class ProximityCheck { private Type type; /** - * When OTP obtained by the client. An optional hint for possible better estimation of the time shift correction. + * When OTP received by the client. An optional hint for possible better estimation of the time shift correction. */ - @Schema(description = "When OTP requested by the client. An optional hint for possible better estimation of the time shift correction.") - private Instant timestampRequested; + @Schema(description = "When OTP received by the client. An optional hint for possible better estimation of the time shift correction.") + private Instant timestampReceived; /** - * When OTP signed by the client. An optional hint for possible better estimation of the time shift correction. + * When OTP is used by the client as part of a signed message. An optional hint for possible better estimation of the time shift correction. */ - @Schema(description = "When OTP signed by the client. An optional hint for possible better estimation of the time shift correction.") - private Instant timestampSigned; + @Schema(description = "When OTP is used by the client as part of a signed message. An optional hint for possible better estimation of the time shift correction.") + private Instant timestampSent; public enum Type { QR_CODE,