Skip to content

Commit

Permalink
Fix #911: Expose API to upload presence check data (#941)
Browse files Browse the repository at this point in the history
* Fix #911: Expose API to upload presence check data
  • Loading branch information
banterCZ authored Dec 12, 2023
1 parent 7cea252 commit ad8bf89
Show file tree
Hide file tree
Showing 5 changed files with 297 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,8 @@ public IdentityVerificationException(String message) {
super(message);
}

public IdentityVerificationException(String message, Throwable cause) {
super(message, cause);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@

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;
import org.springframework.beans.factory.annotation.Autowired;
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;
Expand Down Expand Up @@ -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<CreateCustomerLivenessResponse> 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<CreateCustomerLivenessRecordResponse> 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<CreateSelfieResponse> 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);
}
}

}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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, [email protected]
*/
@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();
}
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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, [email protected]
*/
@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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down

0 comments on commit ad8bf89

Please sign in to comment.