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 401f15436..f403b7bcc 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-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 f4a6a2089..8d99e710e 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 @@ -19,9 +19,7 @@ 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.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,6 +27,8 @@ 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.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; @@ -145,4 +145,65 @@ 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); + } + } + } 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 000000000..cdc3ab460 --- /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 000000000..833396add --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessService.java @@ -0,0 +1,134 @@ +/* + * 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.ConditionalOnProperty; +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 +@ConditionalOnProperty(value = "enrollment-server-onboarding.presence-check.provider", havingValue = "innovatrics") +class InnovatricsLivenessService { + + private static final String INNOVATRICS_CUSTOMER_ID = "InnovatricsCustomerId"; + + 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); + } + + // TODO (racansky, 2023-11-28) discuss the format with Jan Pesek, extract to common logic + final String customerId = (String) sessionInfo.getSessionAttributes().get(INNOVATRICS_CUSTOMER_ID); + if (Strings.isNullOrEmpty(customerId)) { + throw new IdentityVerificationException("Missing a customer ID value for calling Innovatrics, " + id); + } + return customerId; + } +} 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 ef8410989..4a4dce8d2 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()