From bcc72ad0934553adf911d199b5aecffa05cfd3f6 Mon Sep 17 00:00:00 2001 From: Roman Strobl Date: Wed, 29 Dec 2021 13:06:23 +0100 Subject: [PATCH 001/267] Update documentation --- docs/Deploying-Enrollment-Server.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/Deploying-Enrollment-Server.md b/docs/Deploying-Enrollment-Server.md index 9cc1e6531..1e1dc07e2 100644 --- a/docs/Deploying-Enrollment-Server.md +++ b/docs/Deploying-Enrollment-Server.md @@ -2,9 +2,9 @@ This chapter explains how to deploy Enrollment Server. -<-- begin box warning --> + The enrollment server component will need to be customized in case you need to customize the activation process. The customization is described in the tutorial [Implementing the Server-Side for Authentication in Mobile Banking Apps (SCA)](https://developers.wultra.com/products/mobile-token/2021-05/tutorials/Authentication-in-Mobile-Apps/Server-Side-Tutorial#deploying-the-enrollment-server). -<-- end --> + ## Downloading Enrollment Server @@ -18,6 +18,17 @@ The default implementation of an Enrollment Server has only one compulsory confi powerauth.service.url=http://localhost:8080/powerauth-java-server/rest ``` +## Configuration of Enrollment Server Functionality + +Publishing of Mobile Token endpoints can be enabled or disabled using following configuration property: +```bash +enrollment-server.mtoken.enabled=true +``` +The activation spawn functionality can be enabled or disabled using following configuration property: +```bash +enrollment-server.activation-spawn.enabled=false +``` + ## Setting Up REST Service Credentials _(optional)_ In case PowerAuth Server uses a [restricted access flag in the server configuration](https://github.com/wultra/powerauth-server/blob/develop/docs/Deploying-PowerAuth-Server.md#enabling-powerauth-server-security), you need to configure credentials for the Enrollment Server so that it can connect to the REST service: @@ -27,9 +38,9 @@ powerauth.service.security.clientToken= powerauth.service.security.clientSecret= ``` -<-- begin box info --> + The RESTful interface is secured using Basic HTTP Authentication (pre-emptive). The credentials are stored in the `pa_integration` table. -<-- end --> + ## Configuring Push Server @@ -55,9 +66,9 @@ You can also execute WAR file directly using the following command: java -jar enrollment-server.war ``` -<-- begin box warning --> + You can overwrite the port using `-Dserver.port=8090` parameter to avoid port conflicts. -<-- end --> + ## Deploying Enrollment Server On JBoss / Wildfly From cc25ca76ca0f0a44e51af1799d0d1cdeaee7dfdc Mon Sep 17 00:00:00 2001 From: Petr Dvorak Date: Tue, 18 Jan 2022 00:40:31 +0100 Subject: [PATCH 002/267] Fix #124: Try adding the Docker support right in the repository --- Dockerfile | 25 ++++++++++++++++++++++ docker/enrollment-server.xml | 40 ++++++++++++++++++++++++++++++++++++ docker/env.list.tmp | 19 +++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 Dockerfile create mode 100644 docker/enrollment-server.xml create mode 100644 docker/env.list.tmp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..b07b1c3dc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM tomcat:jdk11-adoptopenjdk-openj9 +LABEL maintainer="petr@wultra.com" + +# Prepare environment variables +ENV JAVA_HOME /opt/java/openjdk +ENV TOMCAT_HOME /usr/local/tomcat +ENV WAR_VERSION 1.2.0 + +# Clear root context +RUN rm -rf $TOMCAT_HOME/webapps/* + +# Add valve for proxy with SSL termination +RUN sed -i 's/<\/Host>/<\/Host>/' $TOMCAT_HOME/conf/server.xml + +# Deploy and run applications +COPY docker/enrollment-server.xml $TOMCAT_HOME/conf/Catalina/localhost/ +COPY target/enrollment-server-$WAR_VERSION.war $TOMCAT_HOME/webapps/enrollment-server.war + +# Create user tomcat and run Tomcat under this user +RUN groupadd -r tomcat +RUN useradd -r -g tomcat -d $TOMCAT_HOME -s /sbin/nologin tomcat +RUN chown -R tomcat:tomcat $TOMCAT_HOME + +USER tomcat +CMD ["catalina.sh", "run"] diff --git a/docker/enrollment-server.xml b/docker/enrollment-server.xml new file mode 100644 index 000000000..3ffa71084 --- /dev/null +++ b/docker/enrollment-server.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker/env.list.tmp b/docker/env.list.tmp new file mode 100644 index 000000000..699d832f6 --- /dev/null +++ b/docker/env.list.tmp @@ -0,0 +1,19 @@ +ENROLLMENT_SERVER_POWERAUTH_SERVICE_URL=http://localhost:8080/powerauth-java-server/rest +ENROLLMENT_SERVER_SECURITY_CLIENT_TOKEN= +ENROLLMENT_SERVER_SECURITY_CLIENT_SECRET= +ENROLLMENT_SERVER_PUSH_SERVER_URL= +ENROLLMENT_SERVER_MTOKEN_ENABLED=true +ENROLLMENT_SERVER_ACTIVATION_SPAWN_ENABLED=false +ENROLLMENT_SERVER_DATASOURCE_URL= +ENROLLMENT_SERVER_DATASOURCE_USERNAME= +ENROLLMENT_SERVER_DATASOURCE_PASSWORD= +ENROLLMENT_SERVER_DATASOURCE_DRIVER= +ENROLLMENT_SERVER_JPA_DDL_AUTO= +ENROLLMENT_SERVER_JPA_CHARSET= +ENROLLMENT_SERVER_JPA_CHARACTER_ENCODING= +ENROLLMENT_SERVER_JPA_USE_UNICODE= +ENROLLMENT_SERVER_JPA_DATABASE_PLATFORM= +ENROLLMENT_SERVER_JPA_LOCK_TIMEOUT= +ENROLLMENT_SERVER_DATASOURCE_JNDI_NAME= +ENROLLMENT_SERVER_SPRING_JMX_ENABLED= +ENROLLMENT_SERVER_SPRING_JMX_DEFAULT_DOMAIN= From a52d58f4940205591747a6479f71d5b826b3ca29 Mon Sep 17 00:00:00 2001 From: Roman Strobl Date: Fri, 21 Jan 2022 15:04:52 +0100 Subject: [PATCH 003/267] Update version to 1.3.0-SNAPSHOT --- enrollment-server/pom.xml | 6 +++--- mtoken-model/pom.xml | 4 ++-- pom.xml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/enrollment-server/pom.xml b/enrollment-server/pom.xml index e3df9549e..b17fb86be 100644 --- a/enrollment-server/pom.xml +++ b/enrollment-server/pom.xml @@ -25,13 +25,13 @@ Base implementation of the enrollment server. enrollment-server - 1.2.0 + 1.3.0-SNAPSHOT war com.wultra.security enrollment-server-parent - 1.2.0 + 1.3.0-SNAPSHOT ../pom.xml @@ -41,7 +41,7 @@ com.wultra.security mtoken-model - 1.2.0 + 1.3.0-SNAPSHOT io.getlime.security diff --git a/mtoken-model/pom.xml b/mtoken-model/pom.xml index 795e0da06..910a39860 100644 --- a/mtoken-model/pom.xml +++ b/mtoken-model/pom.xml @@ -23,12 +23,12 @@ 4.0.0 mtoken-model com.wultra.security - 1.2.0 + 1.3.0-SNAPSHOT enrollment-server-parent com.wultra.security - 1.2.0 + 1.3.0-SNAPSHOT ../pom.xml diff --git a/pom.xml b/pom.xml index 6e6635432..cf7898e26 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ com.wultra.security enrollment-server-parent - 1.2.0 + 1.3.0-SNAPSHOT pom From 85fcd4418d1bc90c7f31ba60878968a4e47cb0a0 Mon Sep 17 00:00:00 2001 From: Roman Strobl Date: Fri, 21 Jan 2022 17:43:08 +0100 Subject: [PATCH 004/267] Fix #126: Update documentation --- docs/Configuration-Properties.md | 37 +++++++++++++++++++ docs/Deploying-Enrollment-Server.md | 19 +++++----- docs/_Sidebar.md | 6 +++ .../src/main/resources/application.properties | 14 +++---- 4 files changed, 59 insertions(+), 17 deletions(-) create mode 100644 docs/Configuration-Properties.md diff --git a/docs/Configuration-Properties.md b/docs/Configuration-Properties.md new file mode 100644 index 000000000..addcfd066 --- /dev/null +++ b/docs/Configuration-Properties.md @@ -0,0 +1,37 @@ +# Configuration Properties + +The Enrollment Server uses the following public configuration properties: + +## Database Configuration + +| Property | Default | Note | +|---|---|---| +| `spring.datasource.url` | `_empty_` | Database JDBC URL | +| `spring.datasource.username` | `_empty_` | Database JDBC username | +| `spring.datasource.password` | `_empty_` | Database JDBC password | +| `spring.datasource.driver-class-name` | `_empty_` | Datasource JDBC class name | +| `spring.jpa.database-platform` | `_empty_` | Database dialect | +| `spring.jpa.hibernate.ddl-auto` | `none` | Configuration of automatic database schema creation | +| `spring.jpa.properties.hibernate.connection.characterEncoding` | `_empty_` | Character encoding | +| `spring.jpa.properties.hibernate.connection.useUnicode` | `_empty_` | Character encoding - Unicode support | + +## PowerAuth Service Configuration + +| Property | Default | Note | +|---|---|---| +| `powerauth.service.url` | `http://localhost:8080/powerauth-java-server/rest` | PowerAuth service REST API base URL. | +| `powerauth.service.security.clientToken` | `_empty_` | PowerAuth REST API authentication token. | +| `powerauth.service.security.clientSecret` | `_empty_` | PowerAuth REST API authentication secret / password. | + +## PowerAuth Push Service Configuration + +| Property | Default | Note | +|---|---|---| +| `powerauth.push.service.url` | `http://localhost:8080/powerauth-push-server` | PowerAuth Push service REST API base URL. | + +## Enrollment Server Configuration + +| Property | Default | Note | +|---|---|---| +| `enrollment-server.mtoken.enabled` | `true` | Publishing of Mobile Token endpoints can be enabled or disabled using this property. | +| `enrollment-server.activation-spawn.enabled` | `false` | The activation spawn functionality can be enabled or disabled using this property. | diff --git a/docs/Deploying-Enrollment-Server.md b/docs/Deploying-Enrollment-Server.md index 1e1dc07e2..82a220217 100644 --- a/docs/Deploying-Enrollment-Server.md +++ b/docs/Deploying-Enrollment-Server.md @@ -58,18 +58,17 @@ The default configuration works best with Apache Tomcat server running on defaul To deploy Enrollment Server to Apache Tomcat, simply copy the WAR file in your `webapps` folder or deploy it using the "Tomcat Web Application Manager" application (usually deployed on default Tomcat address `http://localhost:8080/manager`). -## Deploying Enrollment Server Outside the Container +Running PowerAuth Admin application from console using the `java -jar` command is not supported. -You can also execute WAR file directly using the following command: +## Deploying Enrollment Server On JBoss / Wildfly -```bash -java -jar enrollment-server.war -``` +Follow the extra instructions in chapter [Deploying Enrollment Server on JBoss / Wildfly](./Deploying-Wildfly.md). - -You can overwrite the port using `-Dserver.port=8090` parameter to avoid port conflicts. - +## Supported Java Runtime Versions -## Deploying Enrollment Server On JBoss / Wildfly +The following Java runtime versions are supported: +- Java 8 (LTS release) +- Java 11 (LTS release) +- Java 17 (LTS release) -Follow the extra instructions in chapter [Deploying Enrollment Server on JBoss / Wildfly](./Deploying-Wildfly.md). +The Enrollment Server may run on other Java versions, however we do not perform extensive testing with non-LTS releases. diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md index e50534152..5555e0389 100644 --- a/docs/_Sidebar.md +++ b/docs/_Sidebar.md @@ -2,3 +2,9 @@ - [Deploying Enrollment Server](./Deploying-Enrollment-Server.md) - [Deploying Enrollment Server on JBoss/Wildfly](./Deploying-Wildfly.md) +- [Configuration Properties](./Configuration-Properties.md) + +**Implementation Tutorials** + +- [Authentication in Mobile Banking Apps (SCA)](https://developers.wultra.com/products/mobile-security-suite/develop/tutorials/Authentication-in-Mobile-Apps) +- [Verifying PowerAuth Signatures On The Server](https://developers.wultra.com/products/mobile-security-suite/develop/tutorials/Manual-Signature-Verification) diff --git a/enrollment-server/src/main/resources/application.properties b/enrollment-server/src/main/resources/application.properties index 9cfc9ff92..fc8f93a94 100644 --- a/enrollment-server/src/main/resources/application.properties +++ b/enrollment-server/src/main/resources/application.properties @@ -19,11 +19,12 @@ # Allow externalization of properties using application-ext.properties spring.profiles.active=ext -# Database Configuration - MySQL -#spring.datasource.url=jdbc:mysql://localhost:3306/powerauth?autoReconnect=true&useSSL=false +# Database Configuration - PostgreSQL +#spring.datasource.url=jdbc:postgresql://localhost:5432/powerauth #spring.datasource.username=powerauth #spring.datasource.password= -#spring.datasource.driver-class-name=com.mysql.jdbc.Driver +#spring.datasource.driver-class-name=org.postgresql.Driver +#spring.jpa.properties.hibernate.temp.use_jdbc_metadata_defaults=false #spring.jpa.properties.hibernate.connection.characterEncoding=utf8 #spring.jpa.properties.hibernate.connection.useUnicode=true @@ -35,12 +36,11 @@ spring.profiles.active=ext # The following property speeds up Spring Boot startup #spring.jpa.properties.hibernate.temp.use_jdbc_metadata_defaults=false -# Database Configuration - PostgreSQL -#spring.datasource.url=jdbc:postgresql://localhost:5432/powerauth +# Database Configuration - MySQL +#spring.datasource.url=jdbc:mysql://localhost:3306/powerauth?autoReconnect=true&useSSL=false #spring.datasource.username=powerauth #spring.datasource.password= -#spring.datasource.driver-class-name=org.postgresql.Driver -#spring.jpa.properties.hibernate.temp.use_jdbc_metadata_defaults=false +#spring.datasource.driver-class-name=com.mysql.jdbc.Driver #spring.jpa.properties.hibernate.connection.characterEncoding=utf8 #spring.jpa.properties.hibernate.connection.useUnicode=true From 92a005b6c2df24bbc619d8bb303cf9433eb45240 Mon Sep 17 00:00:00 2001 From: Roman Strobl Date: Sat, 22 Jan 2022 21:00:06 +0100 Subject: [PATCH 005/267] Fix copy-paste issue in documentation --- docs/Deploying-Enrollment-Server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Deploying-Enrollment-Server.md b/docs/Deploying-Enrollment-Server.md index 82a220217..f24d7d518 100644 --- a/docs/Deploying-Enrollment-Server.md +++ b/docs/Deploying-Enrollment-Server.md @@ -58,7 +58,7 @@ The default configuration works best with Apache Tomcat server running on defaul To deploy Enrollment Server to Apache Tomcat, simply copy the WAR file in your `webapps` folder or deploy it using the "Tomcat Web Application Manager" application (usually deployed on default Tomcat address `http://localhost:8080/manager`). -Running PowerAuth Admin application from console using the `java -jar` command is not supported. +Running Enrollment Server from console using the `java -jar` command is not supported. ## Deploying Enrollment Server On JBoss / Wildfly From 075f4a85208155e2011510b80017a4569b193e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lukovsk=C3=BD?= <937429+saalistaja@users.noreply.github.com> Date: Wed, 26 Jan 2022 11:51:02 +0100 Subject: [PATCH 006/267] Fix #129: Update Bouncy Castle version in JBoss deployment descriptor --- .../src/main/webapp/WEB-INF/jboss-deployment-structure.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enrollment-server/src/main/webapp/WEB-INF/jboss-deployment-structure.xml b/enrollment-server/src/main/webapp/WEB-INF/jboss-deployment-structure.xml index 1a678d11f..60bf57ef1 100644 --- a/enrollment-server/src/main/webapp/WEB-INF/jboss-deployment-structure.xml +++ b/enrollment-server/src/main/webapp/WEB-INF/jboss-deployment-structure.xml @@ -8,7 +8,7 @@ - + From 11925b482813cdc1dd834faa3a744eb46bee603a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lukovsk=C3=BD?= <937429+saalistaja@users.noreply.github.com> Date: Wed, 26 Jan 2022 11:51:02 +0100 Subject: [PATCH 007/267] Fix #129: Update Bouncy Castle version in JBoss deployment descriptor --- .../src/main/webapp/WEB-INF/jboss-deployment-structure.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enrollment-server/src/main/webapp/WEB-INF/jboss-deployment-structure.xml b/enrollment-server/src/main/webapp/WEB-INF/jboss-deployment-structure.xml index 1a678d11f..60bf57ef1 100644 --- a/enrollment-server/src/main/webapp/WEB-INF/jboss-deployment-structure.xml +++ b/enrollment-server/src/main/webapp/WEB-INF/jboss-deployment-structure.xml @@ -8,7 +8,7 @@ - + From 4c79e7232c743267711f00c7423171435b4295d1 Mon Sep 17 00:00:00 2001 From: Petr Dvorak Date: Thu, 24 Feb 2022 18:09:42 +0100 Subject: [PATCH 008/267] Fix #162: Add flag conditioning for operations APIs --- enrollment-server/pom.xml | 8 +- .../controller/HomeController.java | 1 + .../controller/api/MobileTokenController.java | 19 +++-- .../database/entity/OperationTemplate.java | 26 +++++- .../impl/service/MobileTokenService.java | 83 ++++++++++++++----- mtoken-model/pom.xml | 5 +- .../model/response/OperationListResponse.java | 2 +- pom.xml | 2 +- 8 files changed, 105 insertions(+), 41 deletions(-) diff --git a/enrollment-server/pom.xml b/enrollment-server/pom.xml index b17fb86be..30b1a396c 100644 --- a/enrollment-server/pom.xml +++ b/enrollment-server/pom.xml @@ -46,12 +46,12 @@ io.getlime.security powerauth-restful-security-spring - 1.2.0 + 1.3.0-SNAPSHOT io.getlime.security powerauth-push-client - 1.2.0 + 1.3.0-SNAPSHOT @@ -108,12 +108,12 @@ org.springdoc springdoc-openapi-ui - 1.6.2 + 1.6.6 org.springdoc springdoc-openapi-security - 1.6.2 + 1.6.5 diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/HomeController.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/HomeController.java index 4a1d7cd22..58364c795 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/HomeController.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/HomeController.java @@ -40,6 +40,7 @@ public void setBuildProperties(BuildProperties buildProperties) { this.buildProperties = buildProperties; } + @SuppressWarnings("SameReturnValue") @RequestMapping(value = "/", method = RequestMethod.GET) public String home(Model model) { if (buildProperties != null) { 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 b6616ad64..4ae0c73b0 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 @@ -43,6 +43,7 @@ import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; +import java.util.List; import java.util.Locale; /** @@ -94,8 +95,9 @@ public ObjectResponse operationList(@Parameter(hidden = t if (auth != null) { final String userId = auth.getUserId(); final Long applicationId = auth.getApplicationId(); + final List activationFlags = auth.getActivationContext().getActivationFlags(); final String language = locale.getLanguage(); - final OperationListResponse listResponse = mobileTokenService.operationListForUser(userId, applicationId, language, true); + final OperationListResponse listResponse = mobileTokenService.operationListForUser(userId, applicationId, language, activationFlags, true); return new ObjectResponse<>(listResponse); } else { throw new MobileTokenAuthException(); @@ -125,8 +127,9 @@ public ObjectResponse operationListAll(@Parameter(hidden if (auth != null) { final String userId = auth.getUserId(); final Long applicationId = auth.getApplicationId(); + final List activationFlags = auth.getActivationContext().getActivationFlags(); final String language = locale.getLanguage(); - final OperationListResponse listResponse = mobileTokenService.operationListForUser(userId, applicationId, language, false); + final OperationListResponse listResponse = mobileTokenService.operationListForUser(userId, applicationId, language, activationFlags, false); return new ObjectResponse<>(listResponse); } else { throw new MobileTokenAuthException(); @@ -171,7 +174,8 @@ public Response operationApprove(@RequestBody ObjectRequest activationFlags = auth.getActivationContext().getActivationFlags(); + return mobileTokenService.operationApprove(userId, applicationId, operationId, data, signatureFactors, activationFlags); } else { // make sure to fail operation as well, to increase the failed number mobileTokenService.operationFailApprove(operationId); @@ -205,10 +209,11 @@ public Response operationReject(@RequestBody ObjectRequest activationFlags = auth.getActivationContext().getActivationFlags(); + final String operationId = requestObject.getId(); + return mobileTokenService.operationReject(userId, applicationId, operationId, activationFlags); } else { throw new MobileTokenAuthException(); } diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java index 656cdbf1e..486696e3d 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java @@ -18,17 +18,25 @@ package com.wultra.app.enrollmentserver.database.entity; -import lombok.Data; +import lombok.*; +import org.hibernate.Hibernate; -import javax.persistence.*; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; import java.io.Serializable; +import java.util.Objects; /** * Entity representing an operation template. * * @author Petr Dvorak, petr@wultra.com */ -@Data +@Getter +@Setter +@ToString +@RequiredArgsConstructor @Entity @Table(name = "es_operation_template") public class OperationTemplate implements Serializable { @@ -54,4 +62,16 @@ public class OperationTemplate implements Serializable { @Column(name = "attributes") private String attributes; + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false; + OperationTemplate that = (OperationTemplate) o; + return id != null && Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } } 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 f3c97c757..943657a50 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 @@ -41,6 +41,7 @@ import org.springframework.stereotype.Service; import javax.validation.constraints.NotNull; +import java.util.List; /** * Service responsible for mobile token features. @@ -77,6 +78,7 @@ public MobileTokenService(PowerAuthClient powerAuthClient, MobileTokenConverter * @param userId User ID. * @param applicationId Application ID. * @param language Language. + * @param activationFlags Activation flags to condition the operation against. * @param pendingOnly Flag indicating if only pending or all operation should be returned. * @return Response with pending or all operations, depending on the "pendingOnly" flag. * @throws PowerAuthClientException In the case that PowerAuth service call fails. @@ -86,6 +88,7 @@ public OperationListResponse operationListForUser( @NotNull String userId, @NotNull Long applicationId, @NotNull String language, + List activationFlags, boolean pendingOnly) throws PowerAuthClientException, MobileTokenConfigurationException { final OperationListForUserRequest request = new OperationListForUserRequest(); @@ -96,11 +99,14 @@ public OperationListResponse operationListForUser( final OperationListResponse responseObject = new OperationListResponse(); for (OperationDetailResponse operationDetail: pendingList) { - final OperationTemplate operationTemplate = operationTemplateService.prepareTemplate(operationDetail.getOperationType(), language); - final Operation operation = mobileTokenConverter.convert(operationDetail, operationTemplate); - responseObject.add(operation); - if (responseObject.size() >= OPERATION_LIST_LIMIT) { // limit the list size in response - break; + final String activationFlag = operationDetail.getActivationFlag(); + if (activationFlag == null || activationFlags.contains(activationFlag)) { // only return data if there is no flag, or if flag matches flags of activation + final OperationTemplate operationTemplate = operationTemplateService.prepareTemplate(operationDetail.getOperationType(), language); + final Operation operation = mobileTokenConverter.convert(operationDetail, operationTemplate); + responseObject.add(operation); + if (responseObject.size() >= OPERATION_LIST_LIMIT) { // limit the list size in response + break; + } } } return responseObject; @@ -114,6 +120,7 @@ public OperationListResponse operationListForUser( * @param operationId Operation ID. * @param data Operation Data. * @param signatureFactors Used signature factors. + * @param activationFlags Activation flags. * @return Simple response. * @throws MobileTokenException In the case error mobile token service occurs. * @throws PowerAuthClientException In the case that PowerAuth service call fails. @@ -123,13 +130,15 @@ public Response operationApprove( @NotNull Long applicationId, @NotNull String operationId, @NotNull String data, - @NotNull PowerAuthSignatureTypes signatureFactors) throws MobileTokenException, PowerAuthClientException { + @NotNull PowerAuthSignatureTypes signatureFactors, + List activationFlags) throws MobileTokenException, PowerAuthClientException { - final OperationDetailRequest operationDetailRequest = new OperationDetailRequest(); - operationDetailRequest.setOperationId(operationId); - final OperationDetailResponse operationDetailResponse = powerAuthClient.operationDetail(operationDetailRequest); - OperationStatus status = operationDetailResponse.getStatus(); - handleStatus(status); + final OperationDetailResponse operationDetail = getOperationDetail(operationId); + + final String activationFlag = operationDetail.getActivationFlag(); + if (activationFlag != null && !activationFlags.contains(activationFlag)) { // allow approval if there is no flag, or if flag matches flags of activation + throw new MobileTokenException("OPERATION_REQUIRES_ACTIVATION_FLAG", "Operation requires activation flag: " + activationFlag + ", which is not present on activation."); + } final com.wultra.security.powerauth.client.model.request.OperationApproveRequest approveRequest = new com.wultra.security.powerauth.client.model.request.OperationApproveRequest(); approveRequest.setOperationId(operationId); @@ -144,8 +153,7 @@ public Response operationApprove( return new Response(); } else { final OperationDetailResponse operation = approveResponse.getOperation(); - status = operation.getStatus(); - handleStatus(status); + handleStatus(operation.getStatus()); throw new MobileTokenAuthException(); } } @@ -157,7 +165,7 @@ public Response operationApprove( * @throws MobileTokenException In the case error mobile token service occurs. * @throws PowerAuthClientException In the case that PowerAuth service call fails. */ - public void operationFailApprove(String operationId) throws PowerAuthClientException, MobileTokenException { + public void operationFailApprove(@NotNull String operationId) throws PowerAuthClientException, MobileTokenException { final OperationFailApprovalRequest request = new OperationFailApprovalRequest(); request.setOperationId(operationId); final OperationUserActionResponse failApprovalResponse = powerAuthClient.failApprovalOperation(request); @@ -172,6 +180,7 @@ public void operationFailApprove(String operationId) throws PowerAuthClientExcep * @param userId User ID. * @param applicationId Application ID. * @param operationId Operation ID. + * @param activationFlags Activation flags. * @return Simple response. * @throws MobileTokenException In the case error mobile token service occurs. * @throws PowerAuthClientException In the case that PowerAuth service call fails. @@ -179,12 +188,14 @@ public void operationFailApprove(String operationId) throws PowerAuthClientExcep public Response operationReject( @NotNull String userId, @NotNull Long applicationId, - @NotNull String operationId) throws MobileTokenException, PowerAuthClientException { - final OperationDetailRequest operationDetailRequest = new OperationDetailRequest(); - operationDetailRequest.setOperationId(operationId); - final OperationDetailResponse operationDetailResponse = powerAuthClient.operationDetail(operationDetailRequest); - OperationStatus status = operationDetailResponse.getStatus(); - handleStatus(status); + @NotNull String operationId, + List activationFlags) throws MobileTokenException, PowerAuthClientException { + final OperationDetailResponse operationDetail = getOperationDetail(operationId); + + final String activationFlag = operationDetail.getActivationFlag(); + if (activationFlag != null && !activationFlags.contains(activationFlag)) { // allow approval if there is no flag, or if flag matches flags of activation + throw new MobileTokenException("OPERATION_REQUIRES_ACTIVATION_FLAG", "Operation requires activation flag: " + activationFlag + ", which is not present on activation."); + } final com.wultra.security.powerauth.client.model.request.OperationRejectRequest rejectRequest = new com.wultra.security.powerauth.client.model.request.OperationRejectRequest(); rejectRequest.setOperationId(operationId); @@ -198,12 +209,40 @@ public Response operationReject( return new Response(); } else { final OperationDetailResponse operation = rejectResponse.getOperation(); - status = operation.getStatus(); - handleStatus(status); + handleStatus(operation.getStatus()); throw new MobileTokenAuthException(); } } + // Private methods + + /** + * Get operation detail by calling PowerAuth Server. + * + * @param operationId Operation ID. + * @return Operation detail. + * @throws PowerAuthClientException In case communication with PowerAuth Server fails. + * @throws MobileTokenException When the operation is in incorrect state. + */ + private OperationDetailResponse getOperationDetail(String operationId) throws PowerAuthClientException, MobileTokenException { + final OperationDetailRequest operationDetailRequest = new OperationDetailRequest(); + operationDetailRequest.setOperationId(operationId); + final OperationDetailResponse operationDetail = powerAuthClient.operationDetail(operationDetailRequest); + handleStatus(operationDetail.getStatus()); + return operationDetail; + } + + /** + * Handle operation status. + * + *
    + *
  • PENDING - noop
  • + *
  • CANCELLED, APPROVED, REJECTED, or EXPIRED - throws exception with appropriate code and message.
  • + *
+ * + * @param status Operation status. + * @throws MobileTokenException In case operation is in status that does not allow processing, the method throws appropriate exception. + */ private void handleStatus(OperationStatus status) throws MobileTokenException { switch (status) { case PENDING: { diff --git a/mtoken-model/pom.xml b/mtoken-model/pom.xml index 910a39860..4245efbe4 100644 --- a/mtoken-model/pom.xml +++ b/mtoken-model/pom.xml @@ -22,7 +22,6 @@ 4.0.0 mtoken-model - com.wultra.security 1.3.0-SNAPSHOT @@ -36,12 +35,12 @@ io.getlime.core rest-model-base - 1.4.0 + 1.4.1 com.fasterxml.jackson.core jackson-annotations - 2.13.0 + 2.13.1 org.projectlombok diff --git a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/response/OperationListResponse.java b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/response/OperationListResponse.java index 6e99cf13e..80eedd61f 100644 --- a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/response/OperationListResponse.java +++ b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/response/OperationListResponse.java @@ -18,7 +18,6 @@ package com.wultra.security.powerauth.lib.mtoken.model.response; import com.wultra.security.powerauth.lib.mtoken.model.entity.Operation; -import lombok.Data; import java.util.ArrayList; @@ -28,4 +27,5 @@ * @author Petr Dvorak, petr@wultra.com */ public class OperationListResponse extends ArrayList { + private static final long serialVersionUID = 3244852586716741686L; } diff --git a/pom.xml b/pom.xml index cf7898e26..1cee831e1 100644 --- a/pom.xml +++ b/pom.xml @@ -32,7 +32,7 @@ org.springframework.boot spring-boot-starter-parent - 2.6.1 + 2.6.3 From bdd72a43643371949bc94f9a0ab8a12ef884913c Mon Sep 17 00:00:00 2001 From: Petr Dvorak Date: Thu, 24 Feb 2022 18:19:16 +0100 Subject: [PATCH 009/267] Fix equals and hashCode methods --- .../database/entity/OperationTemplate.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java index 486696e3d..b1f027d90 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java @@ -65,13 +65,18 @@ public class OperationTemplate implements Serializable { @Override public boolean equals(Object o) { if (this == o) return true; - if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false; + if (!(o instanceof OperationTemplate)) return false; OperationTemplate that = (OperationTemplate) o; - return id != null && Objects.equals(id, that.id); + return id.equals(that.id) + && Objects.equals(placeholder, that.placeholder) + && Objects.equals(language, that.language) + && Objects.equals(title, that.title) + && Objects.equals(message, that.message) + && Objects.equals(attributes, that.attributes); } @Override public int hashCode() { - return getClass().hashCode(); + return Objects.hash(id, placeholder, language, title, message, attributes); } } From 8456955b3d4cc98eea844eb02bb13c378b238533 Mon Sep 17 00:00:00 2001 From: Petr Dvorak Date: Fri, 25 Feb 2022 10:33:27 +0100 Subject: [PATCH 010/267] Add @NoArgsConstructor to entity class --- .../app/enrollmentserver/database/entity/OperationTemplate.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java index b1f027d90..4a22f9956 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java @@ -19,7 +19,6 @@ package com.wultra.app.enrollmentserver.database.entity; import lombok.*; -import org.hibernate.Hibernate; import javax.persistence.Column; import javax.persistence.Entity; @@ -36,6 +35,7 @@ @Getter @Setter @ToString +@NoArgsConstructor @RequiredArgsConstructor @Entity @Table(name = "es_operation_template") From caffd9e2facdcb7a60736d2e66bf7c4f67ebc2a8 Mon Sep 17 00:00:00 2001 From: Petr Dvorak Date: Thu, 17 Mar 2022 20:55:40 +0100 Subject: [PATCH 011/267] Fix #171: Add support for request context calls --- enrollment-server/pom.xml | 2 +- .../controller/api/MobileTokenController.java | 32 +++++-- .../impl/service/MobileTokenService.java | 24 +++++- .../converter/RequestContextConverter.java | 85 +++++++++++++++++++ .../impl/service/model/RequestContext.java | 33 +++++++ 5 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/RequestContextConverter.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/model/RequestContext.java diff --git a/enrollment-server/pom.xml b/enrollment-server/pom.xml index 30b1a396c..93384f406 100644 --- a/enrollment-server/pom.xml +++ b/enrollment-server/pom.xml @@ -58,7 +58,7 @@ org.bouncycastle bcprov-jdk15on - 1.69 + 1.70 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 4ae0c73b0..dfb9a61be 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 @@ -22,6 +22,8 @@ import com.wultra.app.enrollmentserver.errorhandling.MobileTokenConfigurationException; import com.wultra.app.enrollmentserver.errorhandling.MobileTokenException; import com.wultra.app.enrollmentserver.impl.service.MobileTokenService; +import com.wultra.app.enrollmentserver.impl.service.converter.RequestContextConverter; +import com.wultra.app.enrollmentserver.impl.service.model.RequestContext; import com.wultra.security.powerauth.client.model.error.PowerAuthClientException; import io.getlime.core.rest.model.base.request.ObjectRequest; import io.getlime.core.rest.model.base.response.ObjectResponse; @@ -43,6 +45,7 @@ import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; +import javax.servlet.http.HttpServletRequest; import java.util.List; import java.util.Locale; @@ -63,14 +66,17 @@ public class MobileTokenController { private static final Logger logger = LoggerFactory.getLogger(MobileTokenController.class); private final MobileTokenService mobileTokenService; + private final RequestContextConverter requestContextConverter; /** * Default constructor with autowired dependencies. * * @param mobileTokenService Mobile token service. + * @param requestContextConverter Converter for request context. */ @Autowired - public MobileTokenController(MobileTokenService mobileTokenService) { + public MobileTokenController(MobileTokenService mobileTokenService, RequestContextConverter requestContextConverter) { + this.requestContextConverter = requestContextConverter; this.mobileTokenService = mobileTokenService; } @@ -145,6 +151,7 @@ public ObjectResponse operationListAll(@Parameter(hidden * * @param request Request for operation approval. * @param auth Authentication object. + * @param servletRequest HttpServletRequest instance. * @return Simple response object. * @throws MobileTokenException In the case error mobile token service occurs. */ @@ -154,7 +161,10 @@ public ObjectResponse operationListAll(@Parameter(hidden PowerAuthSignatureTypes.POSSESSION_KNOWLEDGE, PowerAuthSignatureTypes.POSSESSION_BIOMETRY }) - public Response operationApprove(@RequestBody ObjectRequest request, @Parameter(hidden = true) PowerAuthApiAuthentication auth) throws MobileTokenException { + public Response operationApprove( + @RequestBody ObjectRequest request, + @Parameter(hidden = true) PowerAuthApiAuthentication auth, + HttpServletRequest servletRequest) throws MobileTokenException { try { final OperationApproveRequest requestObject = request.getRequestObject(); @@ -170,15 +180,18 @@ public Response operationApprove(@RequestBody ObjectRequest activationFlags = auth.getActivationContext().getActivationFlags(); - return mobileTokenService.operationApprove(userId, applicationId, operationId, data, signatureFactors, activationFlags); + return mobileTokenService.operationApprove(activationId, userId, applicationId, operationId, data, signatureFactors, requestContext, activationFlags); } else { // make sure to fail operation as well, to increase the failed number - mobileTokenService.operationFailApprove(operationId); + mobileTokenService.operationFailApprove(operationId, requestContext); logger.debug("Operation approval failed due to failed user authentication, operation ID: {}.", operationId); throw new MobileTokenAuthException(); } @@ -193,6 +206,7 @@ public Response operationApprove(@RequestBody ObjectRequest request, @Parameter(hidden = true) PowerAuthApiAuthentication auth) throws MobileTokenException { + public Response operationReject( + @RequestBody ObjectRequest request, + @Parameter(hidden = true) PowerAuthApiAuthentication auth, + HttpServletRequest servletRequest) throws MobileTokenException { try { final OperationRejectRequest requestObject = request.getRequestObject(); @@ -208,12 +225,15 @@ public Response operationReject(@RequestBody ObjectRequest activationFlags = auth.getActivationContext().getActivationFlags(); final String operationId = requestObject.getId(); - return mobileTokenService.operationReject(userId, applicationId, operationId, activationFlags); + return mobileTokenService.operationReject(activationId, userId, applicationId, operationId, requestContext, activationFlags); } else { throw new MobileTokenAuthException(); } 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 943657a50..14aeed752 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 @@ -23,6 +23,7 @@ import com.wultra.app.enrollmentserver.errorhandling.MobileTokenConfigurationException; import com.wultra.app.enrollmentserver.errorhandling.MobileTokenException; import com.wultra.app.enrollmentserver.impl.service.converter.MobileTokenConverter; +import com.wultra.app.enrollmentserver.impl.service.model.RequestContext; import com.wultra.security.powerauth.client.PowerAuthClient; import com.wultra.security.powerauth.client.model.enumeration.OperationStatus; import com.wultra.security.powerauth.client.model.enumeration.SignatureType; @@ -115,22 +116,26 @@ public OperationListResponse operationListForUser( /** * Approve an operation. * + * @param activationId Activation ID. * @param userId User ID. * @param applicationId Application ID. * @param operationId Operation ID. * @param data Operation Data. * @param signatureFactors Used signature factors. + * @param requestContext Request context. * @param activationFlags Activation flags. * @return Simple response. * @throws MobileTokenException In the case error mobile token service occurs. * @throws PowerAuthClientException In the case that PowerAuth service call fails. */ public Response operationApprove( + @NotNull String activationId, @NotNull String userId, @NotNull Long applicationId, @NotNull String operationId, @NotNull String data, @NotNull PowerAuthSignatureTypes signatureFactors, + @NotNull RequestContext requestContext, List activationFlags) throws MobileTokenException, PowerAuthClientException { final OperationDetailResponse operationDetail = getOperationDetail(operationId); @@ -146,6 +151,10 @@ public Response operationApprove( approveRequest.setUserId(userId); approveRequest.setSignatureType(SignatureType.enumFromString(signatureFactors.name())); // 'toString' would perform additional toLowerCase() call approveRequest.setApplicationId(applicationId); + // Prepare additional data + approveRequest.getAdditionalData().put("activation_id", activationId); + approveRequest.getAdditionalData().put("ip_address", requestContext.getIpAddress()); + approveRequest.getAdditionalData().put("user_agent", requestContext.getUserAgent()); final OperationUserActionResponse approveResponse = powerAuthClient.operationApprove(approveRequest); final UserActionResult result = approveResponse.getResult(); @@ -162,12 +171,17 @@ public Response operationApprove( * Fail operation approval (increase operation counter). * * @param operationId Operation ID. + * @param requestContext Request context. * @throws MobileTokenException In the case error mobile token service occurs. * @throws PowerAuthClientException In the case that PowerAuth service call fails. */ - public void operationFailApprove(@NotNull String operationId) throws PowerAuthClientException, MobileTokenException { + public void operationFailApprove(@NotNull String operationId, @NotNull RequestContext requestContext) throws PowerAuthClientException, MobileTokenException { final OperationFailApprovalRequest request = new OperationFailApprovalRequest(); request.setOperationId(operationId); + // Prepare additional data + request.getAdditionalData().put("ip_address", requestContext.getIpAddress()); + request.getAdditionalData().put("user_agent", requestContext.getUserAgent()); + final OperationUserActionResponse failApprovalResponse = powerAuthClient.failApprovalOperation(request); final OperationDetailResponse operation = failApprovalResponse.getOperation(); @@ -177,18 +191,22 @@ public void operationFailApprove(@NotNull String operationId) throws PowerAuthCl /** * Reject an operation. * + * @param activationId Activation ID. * @param userId User ID. * @param applicationId Application ID. * @param operationId Operation ID. + * @param requestContext Request context. * @param activationFlags Activation flags. * @return Simple response. * @throws MobileTokenException In the case error mobile token service occurs. * @throws PowerAuthClientException In the case that PowerAuth service call fails. */ public Response operationReject( + @NotNull String activationId, @NotNull String userId, @NotNull Long applicationId, @NotNull String operationId, + @NotNull RequestContext requestContext, List activationFlags) throws MobileTokenException, PowerAuthClientException { final OperationDetailResponse operationDetail = getOperationDetail(operationId); @@ -201,6 +219,10 @@ public Response operationReject( rejectRequest.setOperationId(operationId); rejectRequest.setUserId(userId); rejectRequest.setApplicationId(applicationId); + // Prepare additional data + rejectRequest.getAdditionalData().put("activation_id", activationId); + rejectRequest.getAdditionalData().put("ip_address", requestContext.getIpAddress()); + rejectRequest.getAdditionalData().put("user_agent", requestContext.getUserAgent()); final OperationUserActionResponse rejectResponse = powerAuthClient.operationReject(rejectRequest); diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/RequestContextConverter.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/RequestContextConverter.java new file mode 100644 index 000000000..a3fbfa1cc --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/RequestContextConverter.java @@ -0,0 +1,85 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.enrollmentserver.impl.service.converter; + +import com.wultra.app.enrollmentserver.impl.service.model.RequestContext; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; + +/** + * Converter for HTTP request context information. + * + * @author Petr Dvorak, petr@wultra.com + */ +@Component +public class RequestContextConverter { + + /** + * List of HTTP headers that may contain the actual IP address + * when hidden behind a proxy component. + */ + private static final String[] HTTP_HEADERS_IP_ADDRESS = { + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_X_FORWARDED_FOR", + "HTTP_X_FORWARDED", + "HTTP_X_CLUSTER_CLIENT_IP", + "HTTP_CLIENT_IP", + "HTTP_FORWARDED_FOR", + "HTTP_FORWARDED", + "HTTP_VIA", + "REMOTE_ADDR" + }; + + /** + * Convert HTTP Servlet Request to request context representation. + * + * @param source HttpServletRequest instance. + * @return Request context data. + */ + public RequestContext convert(HttpServletRequest source) { + if (source == null) { + return null; + } + final RequestContext destination = new RequestContext(); + destination.setUserAgent(source.getHeader("User-Agent")); + destination.setIpAddress(getClientIpAddress(source)); + return destination; + } + + /** + * Obtain the best-effort guess of the client IP address. + * @param request HttpServletRequest instance. + * @return Best-effort information about the client IP address. + */ + private String getClientIpAddress(HttpServletRequest request) { + if (request == null) { // safety null check + return null; + } + for (String header: HTTP_HEADERS_IP_ADDRESS) { + final String ip = request.getHeader(header); + if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) { + return ip; + } + } + return request.getRemoteAddr(); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/model/RequestContext.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/model/RequestContext.java new file mode 100644 index 000000000..f67cf4980 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/model/RequestContext.java @@ -0,0 +1,33 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.enrollmentserver.impl.service.model; + +import lombok.Data; + +/** + * Context of the HTTP request. + * + * @author Petr Dvorak, petr@wultra.com + */ +@Data +public class RequestContext { + + private String ipAddress; + private String userAgent; + +} From a9adfb5923c55a91330369a13329db6d799f3d91 Mon Sep 17 00:00:00 2001 From: Petr Dvorak Date: Fri, 18 Mar 2022 10:18:14 +0100 Subject: [PATCH 012/267] Fix comments from the code review --- .../impl/service/MobileTokenService.java | 19 +++++++++++-------- .../converter/RequestContextConverter.java | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) 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 14aeed752..8a4744a50 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 @@ -53,6 +53,9 @@ public class MobileTokenService { private static final int OPERATION_LIST_LIMIT = 100; + private static final String ATTR_ACTIVATION_ID = "activation_id"; + private static final String ATTR_IP_ADDRESS = "ip_address"; + private static final String ATTR_USER_AGENT = "user_agent"; private final PowerAuthClient powerAuthClient; private final MobileTokenConverter mobileTokenConverter; @@ -152,9 +155,9 @@ public Response operationApprove( approveRequest.setSignatureType(SignatureType.enumFromString(signatureFactors.name())); // 'toString' would perform additional toLowerCase() call approveRequest.setApplicationId(applicationId); // Prepare additional data - approveRequest.getAdditionalData().put("activation_id", activationId); - approveRequest.getAdditionalData().put("ip_address", requestContext.getIpAddress()); - approveRequest.getAdditionalData().put("user_agent", requestContext.getUserAgent()); + approveRequest.getAdditionalData().put(ATTR_ACTIVATION_ID, activationId); + approveRequest.getAdditionalData().put(ATTR_IP_ADDRESS, requestContext.getIpAddress()); + approveRequest.getAdditionalData().put(ATTR_USER_AGENT, requestContext.getUserAgent()); final OperationUserActionResponse approveResponse = powerAuthClient.operationApprove(approveRequest); final UserActionResult result = approveResponse.getResult(); @@ -179,8 +182,8 @@ public void operationFailApprove(@NotNull String operationId, @NotNull RequestCo final OperationFailApprovalRequest request = new OperationFailApprovalRequest(); request.setOperationId(operationId); // Prepare additional data - request.getAdditionalData().put("ip_address", requestContext.getIpAddress()); - request.getAdditionalData().put("user_agent", requestContext.getUserAgent()); + request.getAdditionalData().put(ATTR_IP_ADDRESS, requestContext.getIpAddress()); + request.getAdditionalData().put(ATTR_USER_AGENT, requestContext.getUserAgent()); final OperationUserActionResponse failApprovalResponse = powerAuthClient.failApprovalOperation(request); @@ -220,9 +223,9 @@ public Response operationReject( rejectRequest.setUserId(userId); rejectRequest.setApplicationId(applicationId); // Prepare additional data - rejectRequest.getAdditionalData().put("activation_id", activationId); - rejectRequest.getAdditionalData().put("ip_address", requestContext.getIpAddress()); - rejectRequest.getAdditionalData().put("user_agent", requestContext.getUserAgent()); + rejectRequest.getAdditionalData().put(ATTR_ACTIVATION_ID, activationId); + rejectRequest.getAdditionalData().put(ATTR_IP_ADDRESS, requestContext.getIpAddress()); + rejectRequest.getAdditionalData().put(ATTR_USER_AGENT, requestContext.getUserAgent()); final OperationUserActionResponse rejectResponse = powerAuthClient.operationReject(rejectRequest); diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/RequestContextConverter.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/RequestContextConverter.java index a3fbfa1cc..88f5952cd 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/RequestContextConverter.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/RequestContextConverter.java @@ -75,7 +75,7 @@ private String getClientIpAddress(HttpServletRequest request) { } for (String header: HTTP_HEADERS_IP_ADDRESS) { final String ip = request.getHeader(header); - if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) { + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { return ip; } } From 5a97d73b098f0bd82d8821094b3a5283b33009ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20=C5=A0trobl?= Date: Wed, 23 Mar 2022 18:19:12 +0100 Subject: [PATCH 013/267] Customer onboarding and verification (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial functionality for customer onboarding and verification * Remove v1 version * Onboarding - start, OTP delivery, OTP verification, OTP resend * Detailed configuration of onboarding process * Fix JPA issues found during testing * Allow larger OTP codes * Removal of base project in restful-integration, code cleanup * Make the findProcess() method public so that it can be used from activation provider * Add method for updating the onboarding process entity * Interfaces for document verification and presence check, model classes and improvements of entities * Use separate OTP delivery exception, updates related to identity check implementation * Implement endpoint services and providers for document verification and presence check - Implemented simple mock for document verification and presence check - Implemented ZenID provider for document verification - Implemented iProov provider for presence check - Added by swagger generated api model classes - Added simple integration tests and test data - Implemented several endpoint services * Fix imports * Fix JPA definitions * Persist session data used in presence check * Add primary id generators to all es entities * Use automatically generated UUIDs * Do not rely on ZipEntry::getSize * Ensure consistent timestamps on updates * Improve submit of documents and check of the pending verification on documents * Prevent NullPointer exception on verification id check * Fix mock data * Set activationId in selfie photo document * Fix too long uploadId value * Remove 1FA signature verification for requests with large data * Check document verification results * Endpoint for initialization of identity verification, updates of activation flags * Fix issues found in pull request review * Allow empty request object * Add more javadoc comments - Add missing javadoc to ZenID classes - Add missing javadoc to iProov classes - Add missing javadoc to enrollment server classes * Implement small enhancements - Add options to enable/disable cleanup at providers - Restyle and refactor iProov demo page - Add small doc notes about integration and providers - Unescape slashes from jpeg frame response - Set used_for_verification for SELFIE_PHOTO - Generate proper uploadId in mock doc verification for documents from the request - Set completed phase as the final state of identity verification - Do not persist documents from the request * Fix issues from end-2-end test with ZenID and iProov * Fix null pointer exception in test * Fix invalid equals method * Fix eslint issues * Skip directories in zip files, allow documents to be present in subdirectories * Add missing modifying to an update query * Fail processing on doc verification error during submit * Fix logging in extractDocuments * Reset identity verification process during cleanup * Fail all running verifications during cleanup * Fix JPA queries when multiple verification entities are present per activation * Check related activationId of documents returned by the status endpoint * Implement foreign key relationships in JPA, allow empty filter in document status, fine tune database indexes * Fix JPA issues found during testing * Remove unused variable * Ensure database integrity in case an existing finished process already exists * Revert "Ensure database integrity in case an existing finished process already exists" This reverts commit ead2387f93eacb9437a56e6931a4551a3bc3f616. * Set maximum request and file size during upload * Remove abstract test validation on validationResult - Not all providers have to return this value * Upgrade Spring Boot to version 2.6.1 * Merge changes from develop branch * Fix Log4J import, update dependencies * Fix Log4J import * Update version to 1.3.0-SNAPSHOT, remove duplicate dependency * Use PowerAuthEncryptionException when request decryption fails * Allow run of swagger codegen on JDK16 (#128) - Swagger codegen was failing during the build - https://github.com/swagger-api/swagger-codegen/issues/10966 - Fixed by setting direct dependency on latest handlebars 4.3.0 library * Fix #129: Update Bouncy Castle version in JBoss deployment descriptor * Implement presence check restarts with an already enrolled photo (#131) - Allowed repeated starts of already initialized presence check at expected statuses - Refactored presence check code a little - Added more logging - Removed not thrown DocumentVerificationException from checkPresenceVerification - Added integration test for repeated presence check - Fixed verify-token context * Check activation flags during status to identify state when re-initialization is required after cleanup, improve error handling * Allow disabling presence check in the identity verification process (#132) - Added a configuration option to enable/disable presence check - Adapted phase/state flow when is the presence check disabled - Returning bad request when calling services on disabled presence check * Use secured user identification in iProov API requests (#135) - Allows API cost optimization - Hides potentially sensitive user identification with hashed value * Allow asynchronous upload of documents to verification provider (#133) - Allowed async upload and data extraction in the used document provider - Introduced a regular task to check process of document upload and data extraction at the provider side - Added scheduler lock support to prevent parallel run of regular tasks - Adapted and fixed unit tests to latest changes * Use token-based authentication for status, add process ID into requests for auditing purposes * Use encryption during initialization, unify token authentication to 1FA for all endpoints * Separate api and domain models to own project modules (#136) * Improve error handling * Fix securing of the userId value (#138) - Use Base32 and omit padding to pass more strict validations on the userId value * Find process ID before identity verification is initialized * Separate expirations for activation, verification and OTP, separate active states for onboarding process * OTP code verification during identity verification, refactoring of OTP services, improved services for usage during activation processing * Do not leak user ID in OTP verify response * Allow obtaining user ID during activation process * Add foreign keys for process ID, improve formatting, handle unknown built time * Add verification during document submit - Allow immediate verification of submitted documents - Keeping documents as UPLOAD_IN_PROGRESS until the verification finishes - Added a regular task to check verification results on submitted documents - Return rejection reasons from the verification result as submit errors * Allow multiple document submit - Wait for all document submits before switching the identity to verification pending - Prevent change of uploaded timestamp * Properly separate state transitions for OTP verification, separate exception handling for TOO_MANY_REQUESTS * Improve code quality based on code review * Send OTP when changing status, change send endpoint to resend, return 1 result when checking OTP detail * Always send OTP code after completed document verification, process document verification results later when OTP verification is enabled * Add error handling * Make the cross verification of a selfie photo with documents optional (#148) * Add request debug logging * Fix small issues in document verification entities (#149) - Set timestamp uploaded when not filled - Include also VERIFICATION_PENDING entitities during cleanup * Allow repeated presence check initialization (#151) - Temporary solution until the enrolled entity deletion is implemented * Allow multiple walkthroughs for identity verification, resolve DB consinstency issues * Enable encryption in identity cleanup endpoint, consistently check process ID wherever possible * Add new state into verification cleanup * Add custom simple validations on submitted documents (#153) * Add ownerId into process ID check * Add support for more ensured userId value for iProov calls (#152) * Resolve #156: Use consistent DocumentMetadata in responses (#157) * Fix mocked document verification provider for integration tests * Pair two-sided documents * Use our REST client for iProov calls * Update ZenID API specification * Add preconditions check to otp send call - expected phase/status is OTP_VERIFICATION/OTP_VERIFICATION_PENDING * Add identity verification link to document data - Added es_identity_verification column and foreign key in table es_document_data * Speed up mining of document data with query params - Speed up document mining with more filled query params - Fix rejection of a document with not recognised document side * Improve selection of the person photo for presence check * Fix merge issue with conflicting constructor definitions * Improve OTP verification and timeout configuration - In case timeout for SMS OTP is exceeded, the verification attempt should fail - Providing configured timeout value of the OPT resend period in the api/identity/status response - Refactored OTP code verification to cover more cases - Tracking OTP expiration time, using it for expiration check Co-authored-by: Lukáš Lukovský <937429+saalistaja@users.noreply.github.com> --- .gitignore | 4 + docs-private/Integration.md | 23 + docs-private/Testing.md | 28 + docs/sql/mysql/create-schema.sql | 131 +- docs/sql/oracle/create-schema.sql | 141 +- docs/sql/postgresql/create-schema.sql | 137 +- enrollment-server-api-model/pom.xml | 58 + .../model/request/ActivationCodeRequest.java | 2 +- .../model/request/DocumentStatusRequest.java | 42 + .../model/request/DocumentSubmitRequest.java | 49 + .../IdentityVerificationCleanupRequest.java | 32 + .../IdentityVerificationInitRequest.java | 32 + .../IdentityVerificationOtpSendRequest.java | 32 + .../IdentityVerificationOtpVerifyRequest.java | 33 + .../IdentityVerificationStatusRequest.java | 32 + .../request/OnboardingCleanupRequest.java | 32 + .../request/OnboardingOtpResendRequest.java | 32 + .../model/request/OnboardingStartRequest.java | 35 + .../request/OnboardingStatusRequest.java | 32 + .../request/PresenceCheckInitRequest.java | 32 + .../model/request/PushRegisterRequest.java | 2 +- .../response/ActivationCodeResponse.java | 2 +- .../response/DocumentStatusResponse.java | 37 + .../response/DocumentSubmitResponse.java | 35 + .../response/DocumentUploadResponse.java | 33 + .../IdentityVerificationStatusResponse.java | 38 + .../response/OnboardingStartResponse.java | 36 + .../response/OnboardingStatusResponse.java | 34 + .../api/model/response/OtpVerifyResponse.java | 37 + .../response/PresenceCheckInitResponse.java | 34 + .../response/data/ConfigurationDataDto.java | 34 + .../data/DocumentMetadataResponseDto.java | 65 + enrollment-server-domain-model/pom.xml | 57 + .../app/enrollmentserver/model/Document.java | 37 + .../model/DocumentMetadata.java | 34 + .../model/enumeration/CardSide.java | 36 + .../enumeration/DocumentProcessingPhase.java | 37 + .../model/enumeration/DocumentStatus.java | 76 + .../model/enumeration/DocumentType.java | 80 + .../DocumentVerificationStatus.java | 47 + .../IdentityVerificationPhase.java | 52 + .../IdentityVerificationStatus.java | 62 + .../model/enumeration/OnboardingStatus.java | 47 + .../model/enumeration/OtpStatus.java | 42 + .../model/enumeration/OtpType.java | 37 + .../enumeration/PresenceCheckStatus.java | 47 + .../integration/DocumentSubmitResult.java | 66 + .../DocumentVerificationResult.java | 36 + .../integration/DocumentsSubmitResult.java | 53 + .../DocumentsVerificationResult.java | 44 + .../model/integration/Image.java | 33 + .../model/integration/OwnerId.java | 86 + .../integration/PresenceCheckResult.java | 38 + .../model/integration/SessionInfo.java | 35 + .../model/integration/SubmittedDocument.java | 39 + enrollment-server/pom.xml | 133 +- ...ultraMockDocumentVerificationProvider.java | 202 ++ .../docverify/zenid/config/ZenidConfig.java | 150 + .../zenid/config/ZenidConfigProps.java | 80 + .../CustomOffsetDateTimeDeserializer.java | 52 + .../ZenidDocumentVerificationProvider.java | 577 ++++ .../zenid/service/ZenidRestApiService.java | 281 ++ .../EnrollmentServerApplication.java | 8 + .../activation/ActivationOtpService.java | 55 + .../activation/ActivationProcessService.java | 90 + .../IdentityVerificationConfig.java | 63 + .../configuration/OnboardingConfig.java | 53 + .../PowerAuthWebServiceConfiguration.java | 7 +- .../configuration/SchedulerConfig.java | 55 + .../configuration/WebApplicationConfig.java | 23 + .../api/ActivationCodeController.java | 12 +- .../api/IdentityVerificationController.java | 564 ++++ .../controller/api/OnboardingController.java | 186 ++ .../api/PushRegistrationController.java | 2 +- .../database/DocumentDataRepository.java | 44 + .../database/DocumentResultRepository.java | 65 + .../DocumentVerificationRepository.java | 101 + .../IdentityVerificationRepository.java | 54 + .../database/OnboardingOtpRepository.java | 60 + .../database/OnboardingProcessRepository.java | 67 + .../database/OperationTemplateRepository.java | 6 +- .../database/entity/DocumentDataEntity.java | 86 + .../database/entity/DocumentResultEntity.java | 113 + .../entity/DocumentVerificationEntity.java | 211 ++ .../entity/IdentityVerificationEntity.java | 120 + .../database/entity/OnboardingOtpEntity.java | 115 + .../entity/OnboardingProcessEntity.java | 99 + ...late.java => OperationTemplateEntity.java} | 14 +- .../DefaultExceptionHandler.java | 98 + .../DocumentSubmitException.java | 33 + .../DocumentVerificationException.java | 36 + .../IdentityVerificationException.java | 36 + ...IdentityVerificationNotFoundException.java | 33 + .../OnboardingOtpDeliveryException.java | 29 + .../OnboardingProcessException.java | 36 + .../OnboardingProviderException.java | 29 + .../errorhandling/PresenceCheckException.java | 36 + .../PresenceCheckNotEnabledException.java | 31 + .../RemoteCommunicationException.java | 36 + .../TooManyProcessesException.java | 29 + .../impl/service/ActivationCodeService.java | 4 +- .../impl/service/DataExtractionService.java | 117 + .../IdentityVerificationCreateService.java | 112 + .../IdentityVerificationFinishService.java | 95 + .../IdentityVerificationOtpService.java | 181 + .../IdentityVerificationResetService.java | 87 + .../service/IdentityVerificationService.java | 528 +++ .../IdentityVerificationStatusService.java | 274 ++ .../impl/service/MobileTokenService.java | 4 +- .../impl/service/OnboardingService.java | 337 ++ .../service/OperationTemplateService.java | 6 +- .../impl/service/OtpService.java | 263 ++ .../impl/service/PresenceCheckService.java | 285 ++ .../impl/service/PushRegistrationService.java | 2 +- .../converter/ActivationCodeConverter.java | 2 +- .../converter/MobileTokenConverter.java | 4 +- .../DocumentProcessingBatchService.java | 91 + .../document/DocumentProcessingService.java | 408 +++ .../document/DocumentStatusService.java | 101 + .../internal/JsonSerializationService.java | 68 + .../service/internal/OtpGeneratorService.java | 58 + .../VerificationProcessingBatchService.java | 104 + .../VerificationProcessingService.java | 187 ++ .../util/ConditionalOnPropertyNotEmpty.java | 4 +- .../impl/util/PowerAuthUtil.java | 46 + .../ActivationCodeRequestValidator.java | 2 +- .../PushRegisterRequestValidator.java | 2 +- .../DocumentVerificationProvider.java | 101 + .../provider/OnboardingProvider.java | 35 + .../provider/PresenceCheckProvider.java | 69 + .../task/DocumentSubmitSyncTask.java | 48 + .../DocumentSubmitVerificationSyncTask.java | 48 + .../app/presencecheck/iproov/IProovConst.java | 32 + .../iproov/config/IProovConfig.java | 71 + .../iproov/config/IProovConfigProps.java | 90 + .../provider/IProovPresenceCheckProvider.java | 280 ++ .../iproov/service/IProovRestApiService.java | 229 ++ .../app/presencecheck/mock/MockConst.java | 32 + .../WultraMockPresenceCheckProvider.java | 102 + .../src/main/resources/api/api-iproov.json | 1612 +++++++++ .../src/main/resources/api/api-iproov.yaml | 1314 ++++++++ .../src/main/resources/api/api-zenid.json | 2961 +++++++++++++++++ .../src/main/resources/api/api-zenid.yaml | 2511 ++++++++++++++ .../resources/application-async.properties | 1 + .../resources/application-test.properties | 19 + .../src/main/resources/application.properties | 69 +- .../main/resources/images/specimen_photo.jpg | Bin 0 -> 21816 bytes .../src/main/resources/templates/index.html | 3 +- ...tractDocumentVerificationProviderTest.java | 55 + ...aMockDocumentVerificationProviderTest.java | 167 + ...ZenidDocumentVerificationProviderTest.java | 237 ++ .../EnrollmentServerTestApplication.java | 28 + .../entity/OnboardingOtpEntityTest.java | 51 + .../service/PresenceCheckServiceTest.java | 80 + .../DocumentProcessingServiceTest.java | 74 + .../IProovPresenceCheckProviderTest.java | 132 + .../service/IProovRestApiServiceTest.java | 45 + .../WultraMockPresenceCheckProviderTest.java | 107 + .../java/com/wultra/app/test/TestUtil.java | 53 + .../application-external-service.properties | 7 + .../resources/application-mock.properties | 8 + .../resources/images/specimen_id_back.jpg | Bin 0 -> 47068 bytes .../resources/images/specimen_id_front.jpg | Bin 0 -> 54278 bytes .../test/resources/images/specimen_photo.jpg | Bin 0 -> 21816 bytes enrollment-server/tools/README.md | 14 + .../tools/iproov-verify-nextjs/.eslintrc.json | 3 + .../tools/iproov-verify-nextjs/.gitignore | 9 + .../tools/iproov-verify-nextjs/next.config.js | 3 + .../tools/iproov-verify-nextjs/package.json | 21 + .../tools/iproov-verify-nextjs/pages/_app.js | 7 + .../tools/iproov-verify-nextjs/pages/index.js | 67 + .../pages/verify-token.js | 32 + .../public/images/wultra-square.svg | 15 + .../styles/Home.module.css | 121 + .../iproov-verify-nextjs/styles/globals.css | 16 + pom.xml | 18 + 176 files changed, 20979 insertions(+), 47 deletions(-) create mode 100644 docs-private/Integration.md create mode 100644 docs-private/Testing.md create mode 100644 enrollment-server-api-model/pom.xml rename {enrollment-server/src/main/java/com/wultra/app/enrollmentserver => enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api}/model/request/ActivationCodeRequest.java (94%) create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/DocumentStatusRequest.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/DocumentSubmitRequest.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationCleanupRequest.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationInitRequest.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationOtpSendRequest.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationOtpVerifyRequest.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationStatusRequest.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingCleanupRequest.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingOtpResendRequest.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingStartRequest.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingStatusRequest.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/PresenceCheckInitRequest.java rename {enrollment-server/src/main/java/com/wultra/app/enrollmentserver => enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api}/model/request/PushRegisterRequest.java (95%) rename {enrollment-server/src/main/java/com/wultra/app/enrollmentserver => enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api}/model/response/ActivationCodeResponse.java (94%) create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/DocumentStatusResponse.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/DocumentSubmitResponse.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/DocumentUploadResponse.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/IdentityVerificationStatusResponse.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/OnboardingStartResponse.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/OnboardingStatusResponse.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/OtpVerifyResponse.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/PresenceCheckInitResponse.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/data/ConfigurationDataDto.java create mode 100644 enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/data/DocumentMetadataResponseDto.java create mode 100644 enrollment-server-domain-model/pom.xml create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/Document.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/DocumentMetadata.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/CardSide.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentProcessingPhase.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentStatus.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentType.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentVerificationStatus.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/IdentityVerificationPhase.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/IdentityVerificationStatus.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/OnboardingStatus.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/OtpStatus.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/OtpType.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/PresenceCheckStatus.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentSubmitResult.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentVerificationResult.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentsSubmitResult.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentsVerificationResult.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/Image.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/OwnerId.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/PresenceCheckResult.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java create mode 100644 enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SubmittedDocument.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/docverify/mock/provider/WultraMockDocumentVerificationProvider.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/docverify/zenid/config/ZenidConfig.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/docverify/zenid/config/ZenidConfigProps.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/docverify/zenid/model/deserializer/CustomOffsetDateTimeDeserializer.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/docverify/zenid/provider/ZenidDocumentVerificationProvider.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/docverify/zenid/service/ZenidRestApiService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/activation/ActivationOtpService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/activation/ActivationProcessService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/IdentityVerificationConfig.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/OnboardingConfig.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/SchedulerConfig.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/IdentityVerificationController.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/OnboardingController.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/DocumentDataRepository.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/DocumentResultRepository.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/DocumentVerificationRepository.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/IdentityVerificationRepository.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/OnboardingOtpRepository.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/OnboardingProcessRepository.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/DocumentDataEntity.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/DocumentResultEntity.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/DocumentVerificationEntity.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/IdentityVerificationEntity.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OnboardingOtpEntity.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OnboardingProcessEntity.java rename enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/{OperationTemplate.java => OperationTemplateEntity.java} (83%) create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/DocumentSubmitException.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/DocumentVerificationException.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/IdentityVerificationException.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/IdentityVerificationNotFoundException.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/OnboardingOtpDeliveryException.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/OnboardingProcessException.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/OnboardingProviderException.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/PresenceCheckException.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/PresenceCheckNotEnabledException.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/RemoteCommunicationException.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/TooManyProcessesException.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/DataExtractionService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationCreateService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationFinishService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationOtpService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationResetService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationStatusService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/OnboardingService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/OtpService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/PresenceCheckService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentProcessingBatchService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentProcessingService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentStatusService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/internal/JsonSerializationService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/internal/OtpGeneratorService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/verification/VerificationProcessingBatchService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/verification/VerificationProcessingService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/util/PowerAuthUtil.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/provider/DocumentVerificationProvider.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/provider/OnboardingProvider.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/provider/PresenceCheckProvider.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/task/DocumentSubmitSyncTask.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/enrollmentserver/task/DocumentSubmitVerificationSyncTask.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/IProovConst.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/config/IProovConfig.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/config/IProovConfigProps.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/provider/IProovPresenceCheckProvider.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/service/IProovRestApiService.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/presencecheck/mock/MockConst.java create mode 100644 enrollment-server/src/main/java/com/wultra/app/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java create mode 100644 enrollment-server/src/main/resources/api/api-iproov.json create mode 100644 enrollment-server/src/main/resources/api/api-iproov.yaml create mode 100644 enrollment-server/src/main/resources/api/api-zenid.json create mode 100644 enrollment-server/src/main/resources/api/api-zenid.yaml create mode 100644 enrollment-server/src/main/resources/application-async.properties create mode 100644 enrollment-server/src/main/resources/application-test.properties create mode 100644 enrollment-server/src/main/resources/images/specimen_photo.jpg create mode 100644 enrollment-server/src/test/java/com/wultra/app/docverify/AbstractDocumentVerificationProviderTest.java create mode 100644 enrollment-server/src/test/java/com/wultra/app/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java create mode 100644 enrollment-server/src/test/java/com/wultra/app/docverify/zenid/provider/ZenidDocumentVerificationProviderTest.java create mode 100644 enrollment-server/src/test/java/com/wultra/app/enrollmentserver/EnrollmentServerTestApplication.java create mode 100644 enrollment-server/src/test/java/com/wultra/app/enrollmentserver/database/entity/OnboardingOtpEntityTest.java create mode 100644 enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/PresenceCheckServiceTest.java create mode 100644 enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentProcessingServiceTest.java create mode 100644 enrollment-server/src/test/java/com/wultra/app/presencecheck/iproov/provider/IProovPresenceCheckProviderTest.java create mode 100644 enrollment-server/src/test/java/com/wultra/app/presencecheck/iproov/service/IProovRestApiServiceTest.java create mode 100644 enrollment-server/src/test/java/com/wultra/app/presencecheck/mock/provider/WultraMockPresenceCheckProviderTest.java create mode 100644 enrollment-server/src/test/java/com/wultra/app/test/TestUtil.java create mode 100644 enrollment-server/src/test/resources/application-external-service.properties create mode 100644 enrollment-server/src/test/resources/application-mock.properties create mode 100644 enrollment-server/src/test/resources/images/specimen_id_back.jpg create mode 100644 enrollment-server/src/test/resources/images/specimen_id_front.jpg create mode 100644 enrollment-server/src/test/resources/images/specimen_photo.jpg create mode 100644 enrollment-server/tools/README.md create mode 100644 enrollment-server/tools/iproov-verify-nextjs/.eslintrc.json create mode 100644 enrollment-server/tools/iproov-verify-nextjs/.gitignore create mode 100644 enrollment-server/tools/iproov-verify-nextjs/next.config.js create mode 100644 enrollment-server/tools/iproov-verify-nextjs/package.json create mode 100644 enrollment-server/tools/iproov-verify-nextjs/pages/_app.js create mode 100644 enrollment-server/tools/iproov-verify-nextjs/pages/index.js create mode 100644 enrollment-server/tools/iproov-verify-nextjs/pages/verify-token.js create mode 100644 enrollment-server/tools/iproov-verify-nextjs/public/images/wultra-square.svg create mode 100644 enrollment-server/tools/iproov-verify-nextjs/styles/Home.module.css create mode 100644 enrollment-server/tools/iproov-verify-nextjs/styles/globals.css diff --git a/.gitignore b/.gitignore index 5259b13df..078244a2b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ dist/ ### JRebel rebel.xml + +### Swagger +.swagger-codegen +.swagger-codegen-ignore diff --git a/docs-private/Integration.md b/docs-private/Integration.md new file mode 100644 index 000000000..c3917fd3b --- /dev/null +++ b/docs-private/Integration.md @@ -0,0 +1,23 @@ +# External providers + +## API schema +To update API schema use `https://editor.swagger.io/#/` to get the yaml version +1. File -> Import +2. File -> Save as YAML +3. Update the schema files in the `src/main/resources/api` + +## Presence check providers + +### iProov +The [iProov](https://www.iproov.com/) solution can be used for the presence check phase. + +There has to be enabled per service feature to get the user's selfie from the verified person check. + +[claim validation response](https://secure.iproov.me/docs.html#operation/userVerifyValidate) +- frame_available +``` +Present and True if there is frame available for returning to the integrator. + +Enabled on a per service provider basis. Contact support@iproov.com to request this functionality. +``` +- the jpeg is base64 encoded with escaped slashes (https://stackoverflow.com/questions/1580647/json-why-are-forward-slashes-escaped) diff --git a/docs-private/Testing.md b/docs-private/Testing.md new file mode 100644 index 000000000..f117f05d4 --- /dev/null +++ b/docs-private/Testing.md @@ -0,0 +1,28 @@ +# Testing guidelines and approaches + +## Integration tests on external services + +There are prepared basic integration tests on external services. All such tests +are [tagged](https://junit.org/junit5/docs/current/user-guide/#writing-tests-tagging-and-filtering) with `external-service`. +None of these tests is run during a standard build by default. Run maven command with `-Dgroups="external-service"` to include +also all tests on external services. + +Following subchapters list needed system variables to be defined before run of the tests. + +### iProov + +Following system variables need to be defined: +- IPROOV_API_KEY - api key value +- IPROOV_API_SECRET - api secret value +- IPROOV_ASSURANCE_TYPE - assurance type of the claim, accepts `genuine_presence` (default) or `liveness` values +- IPROOV_RISK_PROFILE - optional configuration of risk tolerance for an authentication attempt +- IPROOV_SERVICE_BASE_URL - e.g. `https://secure.iproov.me/api/v2` +- IPROOV_SERVICE_HOSTNAME - hostname value where the service runs, used in the `Host` header, e.g. `secure.iproov.me` + +### ZenID + +Following system variables need to be defined: +- ZENID_ASYNC_PROCESSING_ENABLED - allows asynchronous processing, accepts `true` or `false` values +- ZENID_NTLM_USERNAME - a username value for the ntlm authentication +- ZENID_NTLM_PASSWORD - a password value for the ntlm authentication +- ZENID_SERVICE_BASE_URL - hostname value where the service runs, used in the `Host` header, e.g. `secure.iproov.me` diff --git a/docs/sql/mysql/create-schema.sql b/docs/sql/mysql/create-schema.sql index bac5a01fb..b4d779796 100644 --- a/docs/sql/mysql/create-schema.sql +++ b/docs/sql/mysql/create-schema.sql @@ -1,5 +1,5 @@ /* - * PowerAuth Cloud + * PowerAuth Enrollment Server * Copyright (C) 2020 Wultra s.r.o. * * This program is free software: you can redistribute it and/or modify @@ -17,7 +17,7 @@ */ CREATE TABLE es_operation_template ( - id BIGINT NOT NULL PRIMARY KEY, + id BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT, placeholder VARCHAR(255) NOT NULL, language VARCHAR(8) NOT NULL, title VARCHAR(255) NOT NULL, @@ -25,4 +25,129 @@ CREATE TABLE es_operation_template ( attributes TEXT ) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -CREATE UNIQUE INDEX es_operation_template_placeholder ON es_operation_template(placeholder, language); \ No newline at end of file +CREATE UNIQUE INDEX es_operation_template_placeholder ON es_operation_template(placeholder, language); + +CREATE TABLE es_onboarding_process ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + identification_data TEXT NOT NULL, + user_id VARCHAR(256) NOT NULL, + activation_id VARCHAR(36), + status VARCHAR(32) NOT NULL, + error_detail VARCHAR(256), + timestamp_created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + timestamp_last_updated DATETIME, + timestamp_finished DATETIME +) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE INDEX onboarding_process_status ON es_onboarding_process (status); +CREATE INDEX onboarding_process_timestamp_1 ON es_onboarding_process (timestamp_created); +CREATE INDEX onboarding_process_timestamp_2 ON es_onboarding_process (timestamp_last_updated); + +CREATE TABLE es_onboarding_otp ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + process_id VARCHAR(36) NOT NULL, + otp_code VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + type VARCHAR(32) NOT NULL, + error_detail VARCHAR(256), + failed_attempts INTEGER, + timestamp_created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + timestamp_expiration DATETIME NOT NULL, + timestamp_last_updated DATETIME, + timestamp_verified DATETIME, + FOREIGN KEY (process_id) REFERENCES es_onboarding_process (id) +) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- MySQL creates indexes on foreign keys automatically +CREATE INDEX onboarding_otp_status ON es_onboarding_otp (status); +CREATE INDEX onboarding_otp_timestamp_1 ON es_onboarding_otp (timestamp_created); +CREATE INDEX onboarding_otp_timestamp_2 ON es_onboarding_otp (timestamp_last_updated); + +CREATE TABLE es_identity_verification ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + activation_id VARCHAR(36) NOT NULL, + user_id VARCHAR(256) NOT NULL, + process_id VARCHAR(36) NOT NULL, + status VARCHAR(32) NOT NULL, + phase VARCHAR(32) NOT NULL, + reject_reason VARCHAR(256), + error_detail VARCHAR(256), + session_info TEXT, + timestamp_created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + timestamp_last_updated DATETIME, + FOREIGN KEY (process_id) REFERENCES es_onboarding_process (id) +) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE INDEX identity_verif_activation ON es_identity_verification (activation_id); +CREATE INDEX identity_verif_user ON es_identity_verification (user_id); +CREATE INDEX identity_verif_status ON es_identity_verification (status); +CREATE INDEX identity_verif_timestamp_1 ON es_identity_verification (timestamp_created); +CREATE INDEX identity_verif_timestamp_2 ON es_identity_verification (timestamp_last_updated); + +CREATE TABLE es_document_verification ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + activation_id VARCHAR(36) NOT NULL, + identity_verification_id VARCHAR(36) NOT NULL, + type VARCHAR(32) NOT NULL, + side VARCHAR(5), + other_side_id VARCHAR(36), + provider_name VARCHAR(64), + status VARCHAR(32) NOT NULL, + filename VARCHAR(256) NOT NULL, + upload_id VARCHAR(36), + verification_id VARCHAR(36), + photo_id VARCHAR(256), + verification_score INTEGER, + reject_reason VARCHAR(256), + error_detail VARCHAR(256), + original_document_id VARCHAR(36), + used_for_verification TINYINT DEFAULT 0, + timestamp_created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + timestamp_uploaded DATETIME, + timestamp_verified DATETIME, + timestamp_disposed DATETIME, + timestamp_last_updated DATETIME, + FOREIGN KEY (identity_verification_id) REFERENCES es_identity_verification (id) +) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- MySQL creates indexes on foreign keys automatically +CREATE INDEX onboarding_verif_activation ON es_document_verification (activation_id); +CREATE INDEX onboarding_verif_status ON es_document_verification (status); +CREATE INDEX onboarding_verif_timestamp_1 ON es_document_verification (timestamp_created); +CREATE INDEX onboarding_verif_timestamp_2 ON es_document_verification (timestamp_last_updated); + +CREATE TABLE es_document_data ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + activation_id VARCHAR(36) NOT NULL, + identity_verification_id VARCHAR(36) NOT NULL, + filename VARCHAR(256) NOT NULL, + data BLOB NOT NULL, + timestamp_created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (identity_verification_id) REFERENCES es_identity_verification (id) +) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE INDEX document_data_activation ON es_document_data (activation_id); +CREATE INDEX document_data_timestamp ON es_document_data (timestamp_created); + +CREATE TABLE es_document_result ( + id BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT, + document_verification_id VARCHAR(36) NOT NULL, + phase VARCHAR(32) NOT NULL, + reject_reason TEXT, + verification_result TEXT, + error_detail TEXT, + extracted_data TEXT, + timestamp_created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (document_verification_id) REFERENCES es_document_verification (id) +) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- MySQL creates indexes on foreign keys automatically + +-- Scheduler lock table - https://github.com/lukas-krecan/ShedLock#configure-lockprovider +CREATE TABLE IF NOT EXISTS shedlock ( + name VARCHAR(64) NOT NULL, + lock_until TIMESTAMP(3) NOT NULL, + locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + locked_by VARCHAR(255) NOT NULL, + PRIMARY KEY (name) +); diff --git a/docs/sql/oracle/create-schema.sql b/docs/sql/oracle/create-schema.sql index 3f5bdfd00..7b9380f51 100644 --- a/docs/sql/oracle/create-schema.sql +++ b/docs/sql/oracle/create-schema.sql @@ -1,5 +1,5 @@ /* - * PowerAuth Cloud + * PowerAuth Enrollment Server * Copyright (C) 2020 Wultra s.r.o. * * This program is free software: you can redistribute it and/or modify @@ -16,6 +16,9 @@ * along with this program. If not, see . */ +CREATE SEQUENCE "es_document_result_seq" MINVALUE 1 MAXVALUE 9999999999999999999999999999 INCREMENT BY 10 START WITH 1 CACHE 20; +CREATE SEQUENCE "es_operation_template_seq" MINVALUE 1 MAXVALUE 9999999999999999999999999999 INCREMENT BY 10 START WITH 1 CACHE 20; + CREATE TABLE ES_OPERATION_TEMPLATE ( ID NUMBER(19) NOT NULL PRIMARY KEY, PLACEHOLDER VARCHAR2(255 CHAR) NOT NULL, @@ -26,3 +29,139 @@ CREATE TABLE ES_OPERATION_TEMPLATE ( ); CREATE UNIQUE INDEX ES_OPERATION_TEMPLATE_PLACEHOLDER ON ES_OPERATION_TEMPLATE(PLACEHOLDER, LANGUAGE); + +CREATE TABLE ES_ONBOARDING_PROCESS ( + ID VARCHAR2(36 CHAR) NOT NULL PRIMARY KEY, + IDENTIFICATION_DATA CLOB NOT NULL, + USER_ID VARCHAR2(256 CHAR) NOT NULL, + ACTIVATION_ID VARCHAR2(36 CHAR), + STATUS VARCHAR2(32 CHAR) NOT NULL, + ERROR_DETAIL VARCHAR2(256 CHAR), + TIMESTAMP_CREATED TIMESTAMP(6) NOT NULL, + TIMESTAMP_LAST_UPDATED TIMESTAMP(6), + TIMESTAMP_FINISHED TIMESTAMP(6) +); + +CREATE INDEX ONBOARDING_PROCESS_STATUS ON ES_ONBOARDING_PROCESS (STATUS); +CREATE INDEX ONBOARDING_PROCESS_TIMESTAMP_1 ON ES_ONBOARDING_PROCESS (TIMESTAMP_CREATED); +CREATE INDEX ONBOARDING_PROCESS_TIMESTAMP_2 ON ES_ONBOARDING_PROCESS (TIMESTAMP_LAST_UPDATED); + +CREATE TABLE ES_ONBOARDING_OTP ( + ID VARCHAR2(36 CHAR) NOT NULL PRIMARY KEY, + PROCESS_ID VARCHAR2(36 CHAR) NOT NULL, + OTP_CODE VARCHAR2(32 CHAR) NOT NULL, + STATUS VARCHAR2(32 CHAR) NOT NULL, + TYPE VARCHAR2(32 CHAR) NOT NULL, + ERROR_DETAIL VARCHAR2(256 CHAR), + FAILED_ATTEMPTS INTEGER, + TIMESTAMP_CREATED TIMESTAMP(6) NOT NULL, + TIMESTAMP_EXPIRATION TIMESTAMP(6) NOT NULL, + TIMESTAMP_LAST_UPDATED TIMESTAMP(6), + TIMESTAMP_VERIFIED TIMESTAMP(6), + FOREIGN KEY (PROCESS_ID) REFERENCES ES_ONBOARDING_PROCESS (ID) +); + +-- Oracle does not create indexes on foreign keys automatically +CREATE INDEX ONBOARDING_PROCESS ON ES_ONBOARDING_OTP (PROCESS_ID); +CREATE INDEX ONBOARDING_OTP_STATUS ON ES_ONBOARDING_OTP (STATUS); +CREATE INDEX ONBOARDING_OTP_TIMESTAMP_1 ON ES_ONBOARDING_OTP (TIMESTAMP_CREATED); +CREATE INDEX ONBOARDING_OTP_TIMESTAMP_2 ON ES_ONBOARDING_OTP (TIMESTAMP_LAST_UPDATED); + +CREATE TABLE ES_IDENTITY_VERIFICATION ( + ID VARCHAR2(36 CHAR) NOT NULL PRIMARY KEY, + ACTIVATION_ID VARCHAR2(36 CHAR) NOT NULL, + USER_ID VARCHAR2(256 CHAR) NOT NULL, + PROCESS_ID VARCHAR2(36 CHAR) NOT NULL, + STATUS VARCHAR2(32 CHAR) NOT NULL, + PHASE VARCHAR2(32 CHAR) NOT NULL, + REJECT_REASON VARCHAR2(256 CHAR), + ERROR_DETAIL VARCHAR2(256 CHAR), + SESSION_INFO CLOB, + TIMESTAMP_CREATED TIMESTAMP(6) NOT NULL, + TIMESTAMP_LAST_UPDATED TIMESTAMP(6), + FOREIGN KEY (PROCESS_ID) REFERENCES ES_ONBOARDING_PROCESS (ID) +); + +CREATE INDEX IDENTITY_VERIF_ACTIVATION ON ES_IDENTITY_VERIFICATION (ACTIVATION_ID); +CREATE INDEX IDENTITY_VERIF_USER ON ES_IDENTITY_VERIFICATION (USER_ID); +CREATE INDEX IDENTITY_VERIF_STATUS ON ES_IDENTITY_VERIFICATION (STATUS); +CREATE INDEX IDENTITY_VERIF_TIMESTAMP_1 ON ES_IDENTITY_VERIFICATION (TIMESTAMP_CREATED); +CREATE INDEX IDENTITY_VERIF_TIMESTAMP_2 ON ES_IDENTITY_VERIFICATION (TIMESTAMP_LAST_UPDATED); + +CREATE TABLE ES_DOCUMENT_VERIFICATION ( + ID VARCHAR2(36 CHAR) NOT NULL PRIMARY KEY, + ACTIVATION_ID VARCHAR2(36 CHAR) NOT NULL, + IDENTITY_VERIFICATION_ID VARCHAR2(36 CHAR) NOT NULL, + TYPE VARCHAR2(32 CHAR) NOT NULL, + SIDE VARCHAR2(5 CHAR), + OTHER_SIDE_ID VARCHAR2(36 CHAR), + PROVIDER_NAME VARCHAR2(64 CHAR), + STATUS VARCHAR2(32 CHAR) NOT NULL, + FILENAME VARCHAR2(256 CHAR) NOT NULL, + UPLOAD_ID VARCHAR2(36 CHAR), + VERIFICATION_ID VARCHAR2(36 CHAR), + PHOTO_ID VARCHAR2(256 CHAR), + VERIFICATION_SCORE INTEGER, + REJECT_REASON VARCHAR2(256 CHAR), + ERROR_DETAIL VARCHAR2(256 CHAR), + ORIGINAL_DOCUMENT_ID VARCHAR2(36 CHAR), + USED_FOR_VERIFICATION NUMBER(1) DEFAULT 0, + TIMESTAMP_CREATED TIMESTAMP(6) NOT NULL, + TIMESTAMP_UPLOADED TIMESTAMP(6), + TIMESTAMP_VERIFIED TIMESTAMP(6), + TIMESTAMP_DISPOSED TIMESTAMP(6), + TIMESTAMP_LAST_UPDATED TIMESTAMP(6), + FOREIGN KEY (IDENTITY_VERIFICATION_ID) REFERENCES ES_IDENTITY_VERIFICATION (ID) +); + +-- Oracle does not create indexes on foreign keys automatically +CREATE INDEX DOCUMENT_IDENT_VERIF ON ES_DOCUMENT_VERIFICATION (IDENTITY_VERIFICATION_ID); +CREATE INDEX DOCUMENT_VERIF_ACTIVATION ON ES_DOCUMENT_VERIFICATION (ACTIVATION_ID); +CREATE INDEX DOCUMENT_VERIF_STATUS ON ES_DOCUMENT_VERIFICATION (STATUS); +CREATE INDEX DOCUMENT_VERIF_TIMESTAMP_1 ON ES_DOCUMENT_VERIFICATION (TIMESTAMP_CREATED); +CREATE INDEX DOCUMENT_VERIF_TIMESTAMP_2 ON ES_DOCUMENT_VERIFICATION (TIMESTAMP_LAST_UPDATED); + +CREATE TABLE ES_DOCUMENT_DATA ( + ID VARCHAR2(36 CHAR) NOT NULL PRIMARY KEY, + ACTIVATION_ID VARCHAR2(36 CHAR) NOT NULL, + IDENTITY_VERIFICATION_ID VARCHAR2(36 CHAR) NOT NULL, + FILENAME VARCHAR2(256 CHAR) NOT NULL, + DATA BLOB NOT NULL, + TIMESTAMP_CREATED TIMESTAMP(6) NOT NULL, + FOREIGN KEY (IDENTITY_VERIFICATION_ID) REFERENCES ES_IDENTITY_VERIFICATION (ID) +); + +CREATE INDEX DOCUMENT_DATA_ACTIVATION ON ES_DOCUMENT_DATA (ACTIVATION_ID); +CREATE INDEX DOCUMENT_DATA_TIMESTAMP ON ES_DOCUMENT_DATA (TIMESTAMP_CREATED); + +CREATE TABLE ES_DOCUMENT_RESULT ( + ID NUMBER(19) NOT NULL PRIMARY KEY, + DOCUMENT_VERIFICATION_ID VARCHAR2(36 CHAR) NOT NULL, + PHASE VARCHAR2(32 CHAR) NOT NULL, + REJECT_REASON CLOB, + VERIFICATION_RESULT CLOB, + ERROR_DETAIL CLOB, + EXTRACTED_DATA CLOB, + TIMESTAMP_CREATED TIMESTAMP(6) NOT NULL, + FOREIGN KEY (DOCUMENT_VERIFICATION_ID) REFERENCES ES_DOCUMENT_VERIFICATION (ID) +); + +-- Oracle does not create indexes on foreign keys automatically +CREATE INDEX DOCUMENT_VERIF_RESULT ON ES_DOCUMENT_RESULT (DOCUMENT_VERIFICATION_ID); + +-- Scheduler lock table - https://github.com/lukas-krecan/ShedLock#configure-lockprovider +BEGIN EXECUTE IMMEDIATE ' + CREATE TABLE shedlock ( + name VARCHAR2(64 CHAR) NOT NULL, + lock_until TIMESTAMP(3) NOT NULL, + locked_at TIMESTAMP(3) NOT NULL, + locked_by VARCHAR2(255 CHAR) NOT NULL, + PRIMARY KEY (name) + )'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; +END; +/ diff --git a/docs/sql/postgresql/create-schema.sql b/docs/sql/postgresql/create-schema.sql index ac99875cf..a026be4b9 100644 --- a/docs/sql/postgresql/create-schema.sql +++ b/docs/sql/postgresql/create-schema.sql @@ -1,5 +1,5 @@ /* - * PowerAuth Cloud + * PowerAuth Enrollment Server * Copyright (C) 2020 Wultra s.r.o. * * This program is free software: you can redistribute it and/or modify @@ -16,6 +16,13 @@ * along with this program. If not, see . */ +-- +-- Create sequences. Maximum value for PostgreSQL is 9223372036854775807. +--- See: https://www.postgresql.org/docs/9.6/sql-createsequence.html +-- +CREATE SEQUENCE "es_document_result_seq" MINVALUE 1 MAXVALUE 9223372036854775807 INCREMENT BY 10 START WITH 1 CACHE 20; +CREATE SEQUENCE "es_operation_template_seq" MINVALUE 1 MAXVALUE 9223372036854775807 INCREMENT BY 10 START WITH 1 CACHE 20; + CREATE TABLE es_operation_template ( id BIGINT NOT NULL PRIMARY KEY, placeholder VARCHAR(255) NOT NULL, @@ -26,3 +33,131 @@ CREATE TABLE es_operation_template ( ); CREATE UNIQUE INDEX es_operation_template_placeholder ON es_operation_template(placeholder, language); + +CREATE TABLE es_onboarding_process ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + identification_data TEXT NOT NULL, + user_id VARCHAR(256) NOT NULL, + activation_id VARCHAR(36), + status VARCHAR(32) NOT NULL, + error_detail VARCHAR(256), + timestamp_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + timestamp_last_updated TIMESTAMP, + timestamp_finished TIMESTAMP +); + +CREATE INDEX onboarding_process_status ON es_onboarding_process (status); +CREATE INDEX onboarding_process_timestamp_1 ON es_onboarding_process (timestamp_created); +CREATE INDEX onboarding_process_timestamp_2 ON es_onboarding_process (timestamp_last_updated); + +CREATE TABLE es_onboarding_otp ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + process_id VARCHAR(36) NOT NULL, + otp_code VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + type VARCHAR(32) NOT NULL, + error_detail VARCHAR(256), + failed_attempts INTEGER, + timestamp_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + timestamp_expiration TIMESTAMP NOT NULL, + timestamp_last_updated TIMESTAMP, + timestamp_verified TIMESTAMP, + FOREIGN KEY (process_id) REFERENCES es_onboarding_process (id) +); + +-- PostgreSQL does not create indexes on foreign keys automatically +CREATE INDEX onboarding_process ON es_onboarding_otp (process_id); +CREATE INDEX onboarding_otp_status ON es_onboarding_otp (status); +CREATE INDEX onboarding_otp_timestamp_1 ON es_onboarding_otp (timestamp_created); +CREATE INDEX onboarding_otp_timestamp_2 ON es_onboarding_otp (timestamp_last_updated); + +CREATE TABLE es_identity_verification ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + activation_id VARCHAR(36) NOT NULL, + user_id VARCHAR(256) NOT NULL, + process_id VARCHAR(36) NOT NULL, + status VARCHAR(32) NOT NULL, + phase VARCHAR(32) NOT NULL, + reject_reason VARCHAR(256), + error_detail VARCHAR(256), + session_info TEXT, + timestamp_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + timestamp_last_updated TIMESTAMP, + FOREIGN KEY (process_id) REFERENCES es_onboarding_process (id) +); + +CREATE INDEX identity_verif_activation ON es_identity_verification (activation_id); +CREATE INDEX identity_verif_user ON es_identity_verification (user_id); +CREATE INDEX identity_verif_status ON es_identity_verification (status); +CREATE INDEX identity_verif_timestamp_1 ON es_identity_verification (timestamp_created); +CREATE INDEX identity_verif_timestamp_2 ON es_identity_verification (timestamp_last_updated); + +CREATE TABLE es_document_verification ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + activation_id VARCHAR(36) NOT NULL, + identity_verification_id VARCHAR(36) NOT NULL, + type VARCHAR(32) NOT NULL, + side VARCHAR(5), + other_side_id VARCHAR(36), + provider_name VARCHAR(64), + status VARCHAR(32) NOT NULL, + filename VARCHAR(256) NOT NULL, + upload_id VARCHAR(36), + verification_id VARCHAR(36), + photo_id VARCHAR(256), + verification_score INTEGER, + reject_reason VARCHAR(256), + error_detail VARCHAR(256), + original_document_id VARCHAR(36), + used_for_verification BOOLEAN DEFAULT FALSE, + timestamp_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + timestamp_uploaded TIMESTAMP, + timestamp_verified TIMESTAMP, + timestamp_disposed TIMESTAMP, + timestamp_last_updated TIMESTAMP, + FOREIGN KEY (identity_verification_id) REFERENCES es_identity_verification (id) +); + +-- PostgreSQL does not create indexes on foreign keys automatically +CREATE INDEX document_ident_verif ON es_document_verification (identity_verification_id); +CREATE INDEX document_verif_activation ON es_document_verification (activation_id); +CREATE INDEX document_verif_status ON es_document_verification (status); +CREATE INDEX document_verif_timestamp_1 ON es_document_verification (timestamp_created); +CREATE INDEX document_verif_timestamp_2 ON es_document_verification (timestamp_last_updated); + +CREATE TABLE es_document_data ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + activation_id VARCHAR(36) NOT NULL, + identity_verification_id VARCHAR(36) NOT NULL, + filename VARCHAR(256) NOT NULL, + data BYTEA NOT NULL, + timestamp_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (identity_verification_id) REFERENCES es_identity_verification (id) +); + +CREATE INDEX document_data_activation ON es_document_data (activation_id); +CREATE INDEX document_data_timestamp ON es_document_data (timestamp_created); + +CREATE TABLE es_document_result ( + id BIGINT NOT NULL PRIMARY KEY, + document_verification_id VARCHAR(36) NOT NULL, + phase VARCHAR(32) NOT NULL, + reject_reason TEXT, + verification_result TEXT, + error_detail TEXT, + extracted_data TEXT, + timestamp_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (document_verification_id) REFERENCES es_document_verification (id) +); + +-- PostgreSQL does not create indexes on foreign keys automatically +CREATE INDEX document_verif_result ON es_document_result (document_verification_id); + +-- Scheduler lock table - https://github.com/lukas-krecan/ShedLock#configure-lockprovider +CREATE TABLE IF NOT EXISTS shedlock ( + name VARCHAR(64) NOT NULL, + lock_until TIMESTAMP NOT NULL, + locked_at TIMESTAMP NOT NULL, + locked_by VARCHAR(255) NOT NULL, + PRIMARY KEY (name) +); diff --git a/enrollment-server-api-model/pom.xml b/enrollment-server-api-model/pom.xml new file mode 100644 index 000000000..5d0c997ab --- /dev/null +++ b/enrollment-server-api-model/pom.xml @@ -0,0 +1,58 @@ + + + + + 4.0.0 + + enrollment-server-api-model + API model of the enrollment server. + + enrollment-server-api-model + jar + + + com.wultra.security + enrollment-server-parent + 1.3.0-SNAPSHOT + ../pom.xml + + + + + + com.wultra.security + enrollment-server-domain-model + ${project.version} + + + + + + disable-java8-doclint + + [1.8,) + + + -Xdoclint:none + + + + + diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/request/ActivationCodeRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/ActivationCodeRequest.java similarity index 94% rename from enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/request/ActivationCodeRequest.java rename to enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/ActivationCodeRequest.java index ce3613cc4..1e7402143 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/request/ActivationCodeRequest.java +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/ActivationCodeRequest.java @@ -15,7 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.enrollmentserver.model.request; +package com.wultra.app.enrollmentserver.api.model.request; import lombok.Data; diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/DocumentStatusRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/DocumentStatusRequest.java new file mode 100644 index 000000000..fbab95644 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/DocumentStatusRequest.java @@ -0,0 +1,42 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.request; + +import lombok.Data; + +import java.util.List; + +/** + * Request class used when checking identity document verification status. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class DocumentStatusRequest { + + private String processId; + private List filter; + + @Data + public static class DocumentFilter { + + private String documentId; + + } + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/DocumentSubmitRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/DocumentSubmitRequest.java new file mode 100644 index 000000000..5b9a7ccf3 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/DocumentSubmitRequest.java @@ -0,0 +1,49 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.request; + +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import lombok.Data; + +import java.util.List; + +/** + * Request class used when submitting documents for identity verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class DocumentSubmitRequest { + + private String processId; + private byte[] data; + private boolean resubmit; + private List documents; + + @Data + public static class DocumentMetadata { + + private String filename; + private DocumentType type; + private CardSide side; + private String uploadId; + private String originalDocumentId; + + } +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationCleanupRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationCleanupRequest.java new file mode 100644 index 000000000..b227c8971 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationCleanupRequest.java @@ -0,0 +1,32 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.request; + +import lombok.Data; + +/** + * Request class used during cleanup of identity verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class IdentityVerificationCleanupRequest { + + private String processId; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationInitRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationInitRequest.java new file mode 100644 index 000000000..93b9c5ff9 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationInitRequest.java @@ -0,0 +1,32 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.request; + +import lombok.Data; + +/** + * Request class used when initializing identity verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class IdentityVerificationInitRequest { + + private String processId; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationOtpSendRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationOtpSendRequest.java new file mode 100644 index 000000000..fcad3d763 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationOtpSendRequest.java @@ -0,0 +1,32 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.request; + +import lombok.Data; + +/** + * Request class used when sending or resending an OTP code during identity verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class IdentityVerificationOtpSendRequest { + + private String processId; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationOtpVerifyRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationOtpVerifyRequest.java new file mode 100644 index 000000000..b8942d311 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationOtpVerifyRequest.java @@ -0,0 +1,33 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.request; + +import lombok.Data; + +/** + * Request class used when verifying an OTP code during identity verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class IdentityVerificationOtpVerifyRequest { + + private String processId; + private String otpCode; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationStatusRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationStatusRequest.java new file mode 100644 index 000000000..abf63c410 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/IdentityVerificationStatusRequest.java @@ -0,0 +1,32 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.request; + +import lombok.Data; + +import java.util.List; + +/** + * Request class used when checking identity verification status. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class IdentityVerificationStatusRequest { + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingCleanupRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingCleanupRequest.java new file mode 100644 index 000000000..523871c8f --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingCleanupRequest.java @@ -0,0 +1,32 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.request; + +import lombok.Data; + +/** + * Request class used for cleanup related to onboarding process. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class OnboardingCleanupRequest { + + private String processId; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingOtpResendRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingOtpResendRequest.java new file mode 100644 index 000000000..74e0bf485 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingOtpResendRequest.java @@ -0,0 +1,32 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.request; + +import lombok.Data; + +/** + * Request class used when resending an OTP code. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class OnboardingOtpResendRequest { + + private String processId; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingStartRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingStartRequest.java new file mode 100644 index 000000000..c2521419d --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingStartRequest.java @@ -0,0 +1,35 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.request; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * Request class used when starting the onboarding process. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class OnboardingStartRequest { + + private Map identification; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingStatusRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingStatusRequest.java new file mode 100644 index 000000000..d3765183f --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/OnboardingStatusRequest.java @@ -0,0 +1,32 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.request; + +import lombok.Data; + +/** + * Request class used when checking onboarding process status. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class OnboardingStatusRequest { + + private String processId; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/PresenceCheckInitRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/PresenceCheckInitRequest.java new file mode 100644 index 000000000..810840c54 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/PresenceCheckInitRequest.java @@ -0,0 +1,32 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.request; + +import lombok.Data; + +/** + * Request class used when initializing presence check. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class PresenceCheckInitRequest { + + private String processId; + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/request/PushRegisterRequest.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/PushRegisterRequest.java similarity index 95% rename from enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/request/PushRegisterRequest.java rename to enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/PushRegisterRequest.java index 69f1a5fb1..9268b24e8 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/request/PushRegisterRequest.java +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/request/PushRegisterRequest.java @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package com.wultra.app.enrollmentserver.model.request; +package com.wultra.app.enrollmentserver.api.model.request; import lombok.Data; diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/response/ActivationCodeResponse.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/ActivationCodeResponse.java similarity index 94% rename from enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/response/ActivationCodeResponse.java rename to enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/ActivationCodeResponse.java index 39d2d57ea..dfe61889a 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/response/ActivationCodeResponse.java +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/ActivationCodeResponse.java @@ -15,7 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.enrollmentserver.model.response; +package com.wultra.app.enrollmentserver.api.model.response; import lombok.Data; diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/DocumentStatusResponse.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/DocumentStatusResponse.java new file mode 100644 index 000000000..55d8017f8 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/DocumentStatusResponse.java @@ -0,0 +1,37 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.response; + +import com.wultra.app.enrollmentserver.api.model.response.data.DocumentMetadataResponseDto; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus; +import lombok.Data; + +import java.util.List; + +/** + * Response class used when checking identity document verification status. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class DocumentStatusResponse { + + private IdentityVerificationStatus status; + private List documents; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/DocumentSubmitResponse.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/DocumentSubmitResponse.java new file mode 100644 index 000000000..ecd7baedd --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/DocumentSubmitResponse.java @@ -0,0 +1,35 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.response; + +import com.wultra.app.enrollmentserver.api.model.response.data.DocumentMetadataResponseDto; +import lombok.Data; + +import java.util.List; + +/** + * Response class used when submitting documents for identity verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class DocumentSubmitResponse { + + private List documents; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/DocumentUploadResponse.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/DocumentUploadResponse.java new file mode 100644 index 000000000..c85ccd0b3 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/DocumentUploadResponse.java @@ -0,0 +1,33 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.response; + +import lombok.Data; + +/** + * Response class used when uploading documents for identity verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class DocumentUploadResponse { + + private String filename; + private String id; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/IdentityVerificationStatusResponse.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/IdentityVerificationStatusResponse.java new file mode 100644 index 000000000..46561ed25 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/IdentityVerificationStatusResponse.java @@ -0,0 +1,38 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.response; + +import com.wultra.app.enrollmentserver.api.model.response.data.ConfigurationDataDto; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus; +import lombok.Data; + +/** + * Response class used when checking identity verification status. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class IdentityVerificationStatusResponse { + + private ConfigurationDataDto config; + private String processId; + private IdentityVerificationStatus identityVerificationStatus; + private IdentityVerificationPhase identityVerificationPhase; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/OnboardingStartResponse.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/OnboardingStartResponse.java new file mode 100644 index 000000000..23e619120 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/OnboardingStartResponse.java @@ -0,0 +1,36 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.response; + +import com.wultra.app.enrollmentserver.model.enumeration.OnboardingStatus; +import lombok.Data; + +import java.util.Map; + +/** + * Response class used when starting the onboarding process. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class OnboardingStartResponse { + + private String processId; + private OnboardingStatus onboardingStatus; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/OnboardingStatusResponse.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/OnboardingStatusResponse.java new file mode 100644 index 000000000..c85ea24dd --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/OnboardingStatusResponse.java @@ -0,0 +1,34 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.response; + +import com.wultra.app.enrollmentserver.model.enumeration.OnboardingStatus; +import lombok.Data; + +/** + * Response class used when checking onboarding process status. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class OnboardingStatusResponse { + + private String processId; + private OnboardingStatus onboardingStatus; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/OtpVerifyResponse.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/OtpVerifyResponse.java new file mode 100644 index 000000000..0facf4b51 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/OtpVerifyResponse.java @@ -0,0 +1,37 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.response; + +import com.wultra.app.enrollmentserver.model.enumeration.OnboardingStatus; +import lombok.Data; + +/** + * Response class used when verifying an OTP code during onboarding. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class OtpVerifyResponse { + + private String processId; + private OnboardingStatus onboardingStatus; + private boolean expired; + private boolean verified; + private Integer remainingAttempts; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/PresenceCheckInitResponse.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/PresenceCheckInitResponse.java new file mode 100644 index 000000000..0b8e42739 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/PresenceCheckInitResponse.java @@ -0,0 +1,34 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.api.model.response; + +import lombok.Data; + +import java.util.Map; + +/** + * Response class used when initializing presence check. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class PresenceCheckInitResponse { + + private Map sessionAttributes; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/data/ConfigurationDataDto.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/data/ConfigurationDataDto.java new file mode 100644 index 000000000..570d69916 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/data/ConfigurationDataDto.java @@ -0,0 +1,34 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.enrollmentserver.api.model.response.data; + +import lombok.Data; + +/** + * Configuration data of the server useful for the client. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@Data +public class ConfigurationDataDto { + + /** + * OTP resend period (ISO 8601 format) + */ + private String otpResendPeriod; + +} diff --git a/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/data/DocumentMetadataResponseDto.java b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/data/DocumentMetadataResponseDto.java new file mode 100644 index 000000000..e69fda035 --- /dev/null +++ b/enrollment-server-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/response/data/DocumentMetadataResponseDto.java @@ -0,0 +1,65 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.enrollmentserver.api.model.response.data; + +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import lombok.Data; + +import java.util.List; + +/** + * Response class used for document metadata. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@Data +public class DocumentMetadataResponseDto { + + /** + * Filename specified during upload from mobile client + */ + private String filename; + + /** + * Identifier of the document + */ + private String id; + + /** + * Type of the document + */ + private DocumentType type; + + /** + * Side of a card the document was captured from + */ + private CardSide side; + + /** + * Processing status of the document + */ + private DocumentStatus status; + + /** + * Errors discovered during processing of the document + */ + private List errors; + +} diff --git a/enrollment-server-domain-model/pom.xml b/enrollment-server-domain-model/pom.xml new file mode 100644 index 000000000..696357090 --- /dev/null +++ b/enrollment-server-domain-model/pom.xml @@ -0,0 +1,57 @@ + + + + + 4.0.0 + + enrollment-server-domain-model + Domain model of the enrollment server. + + enrollment-server-domain-model + jar + + + com.wultra.security + enrollment-server-parent + 1.3.0-SNAPSHOT + ../pom.xml + + + + + io.getlime.security + powerauth-java-crypto + 1.2.0 + + + + + + disable-java8-doclint + + [1.8,) + + + -Xdoclint:none + + + + + diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/Document.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/Document.java new file mode 100644 index 000000000..f4c0bf8d7 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/Document.java @@ -0,0 +1,37 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * Class representing a document. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@ToString(callSuper = true) +@Data +@EqualsAndHashCode(callSuper = true) +public class Document extends DocumentMetadata { + + @ToString.Exclude + private byte[] data; + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/DocumentMetadata.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/DocumentMetadata.java new file mode 100644 index 000000000..487f87cc2 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/DocumentMetadata.java @@ -0,0 +1,34 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model; + +import lombok.Data; + +/** + * Class representing a document metadata. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@Data +public class DocumentMetadata { + + private String id; + + private String filename; + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/CardSide.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/CardSide.java new file mode 100644 index 000000000..0bb01565b --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/CardSide.java @@ -0,0 +1,36 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.enumeration; + +/** + * Card side - front or back. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public enum CardSide { + + /** + * Front side. + */ + FRONT, + + /** + * Back side. + */ + BACK +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentProcessingPhase.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentProcessingPhase.java new file mode 100644 index 000000000..03cb4033a --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentProcessingPhase.java @@ -0,0 +1,37 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.enumeration; + +/** + * Document processing phase. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public enum DocumentProcessingPhase { + + /** + * Document is being uploaded. + */ + UPLOAD, + + /** + * Document in being verified. + */ + VERIFICATION + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentStatus.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentStatus.java new file mode 100644 index 000000000..a2bca9d45 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentStatus.java @@ -0,0 +1,76 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.enumeration; + +import com.google.common.collect.ImmutableList; + +import java.util.List; + +/** + * Enumeration representing document verification status. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public enum DocumentStatus { + + /** + * Document has been verified, and it is accepted as a valid document. + * Document skipped from verification process (e.g. selfie photo) can also end at this state. + */ + ACCEPTED, + + /** + * Document has been disposed by resubmit of new version into the identity verification system. + */ + DISPOSED, + + /** + * Document upload is in progress into the identity verification system. + */ + UPLOAD_IN_PROGRESS, + + /** + * Document is waiting for verification. + */ + VERIFICATION_PENDING, + + /** + * Document is currently being verified in the identity verification system. + */ + VERIFICATION_IN_PROGRESS, + + /** + * Document has been rejected. + */ + REJECTED, + + /** + * An unrecoverable error occurred during document analysis. + */ + FAILED; + + /** + * All not finished statuses + */ + public static final List ALL_NOT_FINISHED = ImmutableList.of( + UPLOAD_IN_PROGRESS, + VERIFICATION_PENDING, + VERIFICATION_IN_PROGRESS + ); + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentType.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentType.java new file mode 100644 index 000000000..2f871a8b4 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentType.java @@ -0,0 +1,80 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.enumeration; + +import java.util.Arrays; +import java.util.List; + +/** + * Verified document type. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public enum DocumentType { + + /** + * Identity card. + */ + ID_CARD { + + @Override + public boolean isTwoSided() { + return true; + } + + }, + + /** + * Passport. + */ + PASSPORT, + + /** + * Driving license. + */ + DRIVING_LICENSE, + + /** + * Selfie photo. + */ + SELFIE_PHOTO, + + /** + * Selfie video. + */ + SELFIE_VIDEO, + + /** + * Unknown document. + */ + UNKNOWN; + + /** + * Document types ordered by the preference to provide a person photo (the most preferred type is the first) + */ + public static final List PREFERRED_SOURCE_OF_PERSON_PHOTO = Arrays.asList( + DocumentType.ID_CARD, + DocumentType.PASSPORT, + DocumentType.DRIVING_LICENSE + ); + + public boolean isTwoSided() { + return false; + } + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentVerificationStatus.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentVerificationStatus.java new file mode 100644 index 000000000..b077a57cf --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/DocumentVerificationStatus.java @@ -0,0 +1,47 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.enumeration; + +/** + * Documents verification status enumeration. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public enum DocumentVerificationStatus { + + /** + * Documents are being verified, asynchronous processing is required. + */ + IN_PROGRESS, + + /** + * Documents have been verified and accepted. + */ + ACCEPTED, + + /** + * Documents have been rejected. + */ + REJECTED, + + /** + * An unrecoverable error occurred during analysis of documents. + */ + FAILED + +} \ No newline at end of file diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/IdentityVerificationPhase.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/IdentityVerificationPhase.java new file mode 100644 index 000000000..045a9b010 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/IdentityVerificationPhase.java @@ -0,0 +1,52 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.enumeration; + +/** + * Identity verification phase enumeration. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public enum IdentityVerificationPhase { + + /** + * Documents upload is in progress. + */ + DOCUMENT_UPLOAD, + + /** + * User presence is being verified. + */ + PRESENCE_CHECK, + + /** + * Document verification is in progress. + */ + DOCUMENT_VERIFICATION, + + /** + * OTP code verification is in progress. + */ + OTP_VERIFICATION, + + /** + * The identity verification is in the final state. + */ + COMPLETED + +} \ No newline at end of file diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/IdentityVerificationStatus.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/IdentityVerificationStatus.java new file mode 100644 index 000000000..da3c6c533 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/IdentityVerificationStatus.java @@ -0,0 +1,62 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.enumeration; + +/** + * Identity verification status enumeration. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public enum IdentityVerificationStatus { + + /** + * Default state before initialization of identity verification. + */ + NOT_INITIALIZED, + + /** + * Upload or verification of submitted documents is in progress. + */ + IN_PROGRESS, + + /** + * All submitted documents are waiting for verification. + */ + VERIFICATION_PENDING, + + /** + * OTP code verification is pending. + */ + OTP_VERIFICATION_PENDING, + + /** + * All submitted documents have been verified and accepted as valid documents and OTP has been verified. + */ + ACCEPTED, + + /** + * One or more documents have been rejected. + */ + REJECTED, + + /** + * An unrecoverable error occurred during document analysis. + */ + FAILED + +} \ No newline at end of file diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/OnboardingStatus.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/OnboardingStatus.java new file mode 100644 index 000000000..3edaa166f --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/OnboardingStatus.java @@ -0,0 +1,47 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.enumeration; + +/** + * Enumeration representing onboarding process status. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public enum OnboardingStatus { + + /** + * Activation is in progress. + */ + ACTIVATION_IN_PROGRESS, + + /** + * User verification after successful activation is in progress. + */ + VERIFICATION_IN_PROGRESS, + + /** + * Onboarding process is finished. + */ + FINISHED, + + /** + * Onboarding process is failed. + */ + FAILED + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/OtpStatus.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/OtpStatus.java new file mode 100644 index 000000000..4cf4071f6 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/OtpStatus.java @@ -0,0 +1,42 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.enumeration; + +/** + * Enumeration representing an OTP code status. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public enum OtpStatus { + + /** + * OTP code is active. + */ + ACTIVE, + + /** + * OTP code is verified. + */ + VERIFIED, + + /** + * OTP code verification failed. + */ + FAILED + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/OtpType.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/OtpType.java new file mode 100644 index 000000000..49868f0e2 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/OtpType.java @@ -0,0 +1,37 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.enumeration; + +/** + * Enumeration representing OTP types. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public enum OtpType { + + /** + * OTP code is used during activation. + */ + ACTIVATION, + + /** + * OTP code is used during user verification. + */ + USER_VERIFICATION + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/PresenceCheckStatus.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/PresenceCheckStatus.java new file mode 100644 index 000000000..8fa319931 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/enumeration/PresenceCheckStatus.java @@ -0,0 +1,47 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.enumeration; + +/** + * Presence check status enumeration. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public enum PresenceCheckStatus { + + /** + * User presence is being checked. + */ + IN_PROGRESS, + + /** + * User presence check was accepted. + */ + ACCEPTED, + + /** + * User presence check was rejected. + */ + REJECTED, + + /** + * User presence check failed due to an unrecoverable error. + */ + FAILED + +} \ No newline at end of file diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentSubmitResult.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentSubmitResult.java new file mode 100644 index 000000000..c8ae4ec86 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentSubmitResult.java @@ -0,0 +1,66 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.integration; + +import lombok.Data; + +/** + * Result of submission of a single identity-related document. + * + * @author Roman Strobl, roman.strobl@wultra.com + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@Data +public class DocumentSubmitResult { + + /** + * Simple JSON to cover the case of no extracted data. + */ + public static final String NO_DATA_EXTRACTED = "{}"; + + /** + * Identification of the document in our database + */ + private String documentId; + + /** + * Remotely generated document upload identifier + */ + private String uploadId; + + /** + * A reason why document was rejected in case the document was not accepted + */ + private String rejectReason; + + /** + * Validation result in JSON format + */ + private String validationResult; + + /** + * Error detail used in case the document processing failed + */ + private String errorDetail; + + /** + * Extracted data from document in JSON format. A document submit in progress contains null extracted data. + */ + private String extractedData; + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentVerificationResult.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentVerificationResult.java new file mode 100644 index 000000000..92e81440d --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentVerificationResult.java @@ -0,0 +1,36 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.integration; + +import lombok.Data; + +/** + * Result of verification of a single identity-related document. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class DocumentVerificationResult { + + private String uploadId; + private String rejectReason; + private String verificationResult; + private String errorDetail; + private String extractedData; + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentsSubmitResult.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentsSubmitResult.java new file mode 100644 index 000000000..ba846fbaf --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentsSubmitResult.java @@ -0,0 +1,53 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.integration; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * Result of submission of multiple identity-related documents. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class DocumentsSubmitResult { + + /** + * List of document upload results + */ + private List results = new ArrayList<>(); + + /** + * Overall reason why documents were not accepted + */ + private String rejectReason; + + /** + * Overall error in case of a common upload error + */ + private String errorDetail; + + /** + * Identifier of extracted photograph in case submitted documents contained an ID card + */ + private String extractedPhotoId; + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentsVerificationResult.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentsVerificationResult.java new file mode 100644 index 000000000..7bd3bac32 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/DocumentsVerificationResult.java @@ -0,0 +1,44 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.integration; + +import com.wultra.app.enrollmentserver.model.enumeration.DocumentVerificationStatus; +import lombok.Data; + +import java.util.List; + +/** + * Result of verification of multiple identity-related documents. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class DocumentsVerificationResult { + + private String verificationId; + private DocumentVerificationStatus status; + private List results; + private Integer verificationScore; + private String rejectReason; + private String errorDetail; + + public boolean isAccepted() { + return DocumentVerificationStatus.ACCEPTED.equals(status); + } + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/Image.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/Image.java new file mode 100644 index 000000000..3c195f114 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/Image.java @@ -0,0 +1,33 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.integration; + +import lombok.Data; + +/** + * An image used during identity verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class Image { + + private String filename; + private byte[] data; + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/OwnerId.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/OwnerId.java new file mode 100644 index 000000000..dc5df8145 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/OwnerId.java @@ -0,0 +1,86 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.integration; + +import com.google.common.io.BaseEncoding; +import io.getlime.security.powerauth.crypto.lib.util.Hash; +import lombok.AccessLevel; +import lombok.Data; +import lombok.Setter; +import lombok.ToString; + +import java.util.Date; + +/** + * Identification of an owner of an identity-related document. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +@ToString(of = {"activationId", "userId"}) +public class OwnerId { + + /** + * Maximum allowed length of the user identification + */ + public static final int USER_ID_MAX_LENGTH = 255; + + /** + * Activation identifier. + */ + private String activationId; + + /** + * User ID (user ID who requested the activation). + */ + private String userId; + + /** + * Secured userId value which can be used safely at external providers + *

+ * An userId can typically contain a sensitive data (e.g. e-mail address, phone number) + *

+ */ + @Setter(AccessLevel.NONE) + private String userIdSecured; + + /** + * Timestamp of the identification context + */ + private Date timestamp = new Date(); + + /** + * @return Securely hashed user identification. + * This can be used to hide the original possibly sensitive identity value. + */ + public String getUserIdSecured() { + if (userId == null) { + throw new IllegalStateException("Missing userId value"); + } + if (userIdSecured == null) { + userIdSecured = BaseEncoding.base32() + .omitPadding() + .encode(Hash.sha256(userId)); + if (userIdSecured.length() > USER_ID_MAX_LENGTH) { + userIdSecured = userIdSecured.substring(0, USER_ID_MAX_LENGTH); + } + } + return userIdSecured; + } + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/PresenceCheckResult.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/PresenceCheckResult.java new file mode 100644 index 000000000..edd383957 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/PresenceCheckResult.java @@ -0,0 +1,38 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.integration; + +import com.wultra.app.enrollmentserver.model.enumeration.PresenceCheckStatus; +import lombok.Data; + +/** + * Result of presence check. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class PresenceCheckResult { + + private PresenceCheckStatus status; + private String rejectReason; + private String verificationResult; + private String errorDetail; + private String extractedData; + private Image photo; + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java new file mode 100644 index 000000000..1dfec196f --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java @@ -0,0 +1,35 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.integration; + +import lombok.Data; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Information about a presence check session. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class SessionInfo { + + private Map sessionAttributes = new LinkedHashMap<>(); + +} diff --git a/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SubmittedDocument.java b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SubmittedDocument.java new file mode 100644 index 000000000..1028c0002 --- /dev/null +++ b/enrollment-server-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SubmittedDocument.java @@ -0,0 +1,39 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.model.integration; + +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import lombok.Data; +import lombok.ToString; + +/** + * An identity-related document submitted for verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +@ToString(of = {"documentId", "side", "type"}) +public class SubmittedDocument { + + private String documentId; + private DocumentType type; + private CardSide side; + private Image photo; + +} diff --git a/enrollment-server/pom.xml b/enrollment-server/pom.xml index 93384f406..d56ac0327 100644 --- a/enrollment-server/pom.xml +++ b/enrollment-server/pom.xml @@ -37,6 +37,18 @@ + + + com.wultra.security + enrollment-server-domain-model + ${project.version} + + + com.wultra.security + enrollment-server-api-model + ${project.version} + + com.wultra.security @@ -53,6 +65,11 @@ powerauth-push-client 1.3.0-SNAPSHOT + + io.getlime.core + rest-client-base + 1.5.0-SNAPSHOT + @@ -62,6 +79,11 @@ + + org.springframework.boot + spring-boot-configuration-processor + true + org.springframework.boot spring-boot-starter-thymeleaf @@ -84,6 +106,28 @@ provided + + + net.javacrumbs.shedlock + shedlock-spring + ${shedlock-spring.version} + + + net.javacrumbs.shedlock + shedlock-provider-jdbc-template + ${shedlock-spring.version} + + + + + org.apache.httpcomponents + httpclient + + + org.apache.httpcomponents + httpmime + + com.h2database @@ -97,11 +141,12 @@ commons-text 1.9 + + - org.projectlombok - lombok - 1.18.22 - provided + org.springframework.boot + spring-boot-starter-test + test @@ -115,6 +160,11 @@ springdoc-openapi-security 1.6.5
+ + io.swagger.core.v3 + swagger-annotations + ${swagger-annotations.version} +
@@ -132,6 +182,81 @@ + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + 9 + 9 + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + external-service + + + + io.swagger.codegen.v3 + swagger-codegen-maven-plugin + ${maven-swagger-codegen-plugin.version} + + + com.github.jknack + handlebars + ${maven-swagger-codegen-handlebars.version} + + + + + swagger-definitions-iproov + + generate + + + false + ${basedir}/src/main/resources/api/api-iproov.yaml + spring + spring-boot + false + true + false + false + + java8 + true + com.wultra.app.presencecheck.iproov.model.api + false + + + + + swagger-definitions-zenid + + generate + + + false + ${basedir}/src/main/resources/api/api-zenid.yaml + spring + spring-boot + false + true + false + false + + java8 + true + com.wultra.app.docverify.zenid.model.api + false + + + + + diff --git a/enrollment-server/src/main/java/com/wultra/app/docverify/mock/provider/WultraMockDocumentVerificationProvider.java b/enrollment-server/src/main/java/com/wultra/app/docverify/mock/provider/WultraMockDocumentVerificationProvider.java new file mode 100644 index 000000000..3ceea8a7a --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/docverify/mock/provider/WultraMockDocumentVerificationProvider.java @@ -0,0 +1,202 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.docverify.mock.provider; + +import com.google.common.base.Ascii; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.wultra.app.enrollmentserver.database.entity.DocumentResultEntity; +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.errorhandling.DocumentVerificationException; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentVerificationStatus; +import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.enrollmentserver.provider.DocumentVerificationProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Mock implementation of the {@link DocumentVerificationProvider} + */ +@ConditionalOnProperty(value = "enrollment-server.document-verification.provider", havingValue = "mock") +@Component +public class WultraMockDocumentVerificationProvider implements DocumentVerificationProvider { + + private static final Logger logger = LoggerFactory.getLogger(WultraMockDocumentVerificationProvider.class); + + private static final List DOCUMENT_TYPES_WITH_EXTRACTED_PHOTO = + List.of(DocumentType.DRIVING_LICENSE, DocumentType.ID_CARD, DocumentType.PASSPORT); + + private final Cache> verificationUploadIds; + + private final Cache submittedDocs; + + public WultraMockDocumentVerificationProvider() { + logger.warn("Using mocked version of {}", DocumentVerificationProvider.class.getName()); + + submittedDocs = CacheBuilder.newBuilder() + .expireAfterWrite(Duration.ofHours(1)) + .build(); + + verificationUploadIds = CacheBuilder.newBuilder() + .expireAfterWrite(Duration.ofHours(1)) + .build(); + } + + @Override + public DocumentsSubmitResult checkDocumentUpload(OwnerId id, DocumentVerificationEntity document) throws DocumentVerificationException { + DocumentsSubmitResult result = new DocumentsSubmitResult(); + if (DOCUMENT_TYPES_WITH_EXTRACTED_PHOTO.contains(document.getType())) { + // set extracted photo id only on a relevant documents submit + result.setExtractedPhotoId("extracted-photo-id"); + } + List results; + DocumentSubmitResult docSubmitResult = submittedDocs.getIfPresent(document.getUploadId()); + if (docSubmitResult == null) { + results = Collections.emptyList(); + } else { + results = List.of(docSubmitResult); + if (document.getFilename().contains("random")) { + docSubmitResult.setExtractedData(DocumentSubmitResult.NO_DATA_EXTRACTED); + } else if (document.getSide() != null && !document.getFilename().contains(document.getSide().name().toLowerCase())) { + docSubmitResult.setRejectReason("Different document side than expected"); + } else if (document.getType() != null && !document.getFilename().contains(document.getType().name().toLowerCase())) { + docSubmitResult.setRejectReason("Different document type than expected"); + } else if (docSubmitResult.getExtractedData() == null) { + docSubmitResult.setExtractedData("{\"extracted\": { \"data\": \"" + document.getUploadId() + "\" } }"); + } + } + result.setResults(results); + + logger.info("Mock - check document upload, {}", id); + return result; + } + + @Override + public DocumentsSubmitResult submitDocuments(OwnerId id, List documents) throws DocumentVerificationException { + List submitResults = documents.stream() + .map(doc -> toDocumentSubmitResult(doc.getDocumentId())) + .collect(Collectors.toList());; + + DocumentsSubmitResult result = new DocumentsSubmitResult(); + if (documents.stream().anyMatch(doc -> DOCUMENT_TYPES_WITH_EXTRACTED_PHOTO.contains(doc.getType()))) { + // set extracted photo id only on a relevant documents submit + result.setExtractedPhotoId("extracted-photo-id"); + } + result.setResults(submitResults); + submitResults.forEach(submitResult -> { + submittedDocs.put(submitResult.getUploadId(), submitResult); + }); + + logger.info("Mock - submitted documents, {}", id); + return result; + } + + @Override + public DocumentsVerificationResult verifyDocuments(OwnerId id, List uploadIds) throws DocumentVerificationException { + String verificationId = UUID.randomUUID().toString(); + + DocumentsVerificationResult result = new DocumentsVerificationResult(); + result.setStatus(DocumentVerificationStatus.IN_PROGRESS); + result.setVerificationId(verificationId); + + verificationUploadIds.put(verificationId, uploadIds); + + logger.info("Mock - verifying documents uploadIds={}, {}", uploadIds, id); + return result; + } + + @Override + public DocumentsVerificationResult getVerificationResult(OwnerId id, String verificationId) throws DocumentVerificationException { + DocumentsVerificationResult result = new DocumentsVerificationResult(); + List uploadIds = verificationUploadIds.getIfPresent(verificationId); + if (uploadIds == null) { + result.setStatus(DocumentVerificationStatus.FAILED); + result.setErrorDetail("not existing verificationId: " + verificationId); + } else { + List verificationResults = uploadIds.stream() + .map(uploadId -> { + DocumentVerificationResult verificationResult = new DocumentVerificationResult(); + verificationResult.setExtractedData("{\"extracted\": \"data-" + uploadId + "\"}"); + verificationResult.setUploadId(uploadId); + verificationResult.setVerificationResult("{\"verificationResult\": \"data-" + uploadId + "\"}"); + return verificationResult; + }) + .collect(Collectors.toList()); + + result.setResults(verificationResults); + result.setStatus(DocumentVerificationStatus.ACCEPTED); + result.setVerificationId(verificationId); + } + + logger.info("Mock - getting verification result verificationId={}, {}", verificationId, id); + return result; + } + + @Override + public Image getPhoto(String photoId) throws DocumentVerificationException { + Image photo = new Image(); + photo.setData(new byte[]{}); + photo.setFilename("id_photo.jpg"); + + logger.info("Mock - getting photoId={} from document verification", photoId); + return photo; + } + + @Override + public void cleanupDocuments(OwnerId id, List uploadIds) throws DocumentVerificationException { + logger.info("Mock - cleaned up documents uploadIds={}, {}", uploadIds, id); + } + + @Override + public List parseRejectionReasons(DocumentResultEntity docResult) throws DocumentVerificationException { + if (docResult.getVerificationResult() != null && docResult.getVerificationResult().contains("rejected")) { + return List.of("Rejection reason"); + } else { + return Collections.emptyList(); + } + } + + private DocumentSubmitResult toDocumentSubmitResult(String docId) { + if (docId == null) { + // document from the submit request has no documentId, generate one + docId = UUID.randomUUID().toString(); + } + DocumentSubmitResult submitResult = new DocumentSubmitResult(); + submitResult.setDocumentId(docId); + submitResult.setExtractedData(null); + String uploadedDocId; + if (docId.startsWith("upload")) { + uploadedDocId = docId; + } else { + uploadedDocId = Ascii.truncate("uploaded-" + docId, 36, "..."); + } + submitResult.setUploadId(uploadedDocId); + submitResult.setValidationResult("{\"validationResult\": { \"data\": \"" + docId + "\" } }"); + return submitResult; + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/config/ZenidConfig.java b/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/config/ZenidConfig.java new file mode 100644 index 000000000..5a38c057f --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/config/ZenidConfig.java @@ -0,0 +1,150 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.docverify.zenid.config; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.wultra.app.docverify.zenid.model.deserializer.CustomOffsetDateTimeDeserializer; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.NTCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.config.AuthSchemes; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.function.Supplier; + +/** + * ZenID configuration. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@ConditionalOnProperty(value = "enrollment-server.document-verification.provider", havingValue = "zenid") +@ComponentScan(basePackages = {"com.wultra.app.docverify"}) +@Configuration +public class ZenidConfig { + + /** + * Definition of HTTP client with NTLM authentication support + * + * @param configProps Configuration properties + * @return Instance of an HTTP client with NTLM authentication support + */ + public CloseableHttpClient httpClient(ZenidConfigProps configProps) { + String user = configProps.getNtlmUsername(); + String password = configProps.getNtlmPassword(); + String domain = configProps.getNtlmDomain(); + + CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials(AuthScope.ANY, new NTCredentials(user, password, null, domain)); + + RequestConfig requestConfig = RequestConfig.custom() + .setAuthenticationEnabled(true) + .setProxyPreferredAuthSchemes(Collections.singletonList(AuthSchemes.NTLM)) + .setTargetPreferredAuthSchemes(Collections.singletonList(AuthSchemes.NTLM)) + .build(); + + return HttpClientBuilder + .create() + .setDefaultCredentialsProvider(credentialsProvider) + .setDefaultRequestConfig(requestConfig) + .build(); + } + + /** + * @return Object mapper bean specific to ZenID json format + */ + @Bean("objectMapperZenid") + public ObjectMapper objectMapperZenid() { + ObjectMapper mapper = new ObjectMapper(); + JavaTimeModule javaTimeModule = new JavaTimeModule(); + // Add custom deserialization to support also the ISO DATE format data where ISO DATE TIME expected (ZenID bug?) + javaTimeModule.addDeserializer(OffsetDateTime.class, new CustomOffsetDateTimeDeserializer()); + mapper.registerModule(javaTimeModule) + .disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .enable(SerializationFeature.WRITE_DATES_WITH_ZONE_ID); + return mapper; + } + + @Bean + public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter(@Qualifier("objectMapperZenid") ObjectMapper mapper) { + return new MappingJackson2HttpMessageConverter(mapper); + } + + /** + * Prepares REST template specific to ZenID + * @param configProps Configuration properties + * @param builder REST template builder + * @return REST template for ZenID service API calls + */ + @Bean("restTemplateZenid") + public RestTemplate restTemplateZenid( + ZenidConfigProps configProps, + RestTemplateBuilder builder) { + ZenidRequestFactorySupplier supplier = new ZenidRequestFactorySupplier(configProps); + + return builder + .requestFactory(supplier) + .rootUri(configProps.getServiceBaseUrl()) + .build(); + } + + /** + * Supplier of the HTTP client request factory based on HTTP client with NTLM authentication + */ + class ZenidRequestFactorySupplier implements Supplier { + + private final ZenidConfigProps configProps; + + public ZenidRequestFactorySupplier(ZenidConfigProps configProps) { + this.configProps = configProps; + } + + @Override + public ClientHttpRequestFactory get() { + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory( + httpClient(configProps) + ); + // Prevent usage of streamed request which is not repeatable and + // cannot be used in a standard not-preemptive NTLM auth + requestFactory.setBufferRequestBody(true); + return requestFactory; + } + + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/config/ZenidConfigProps.java b/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/config/ZenidConfigProps.java new file mode 100644 index 000000000..bea0946db --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/config/ZenidConfigProps.java @@ -0,0 +1,80 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.docverify.zenid.config; + +import com.wultra.app.docverify.zenid.model.api.ZenidSharedMineAllResult; +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * Zenid configuration properties. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@ConditionalOnProperty(value = "enrollment-server.document-verification.provider", havingValue = "zenid") +@Configuration +@ConfigurationProperties(prefix = "enrollment-server.document-verification.zenid") +@Getter @Setter +public class ZenidConfigProps { + + /** + * // TODO consider removing this config option + * Enabled/disabled additional doc submit validations + */ + private boolean additionalDocSubmitValidationsEnabled; + + /** + * Enabled/disabled asynchronous processing + */ + private boolean asyncProcessingEnabled; + + /** + * Identifies expected document country + */ + private ZenidSharedMineAllResult.DocumentCountryEnum documentCountry; + + /** + * NTLM domain to authenticate within + */ + private String ntlmDomain; + + /** + * NTLM password + */ + private String ntlmPassword; + + /** + * NTLM username + */ + private String ntlmUsername; + + /** + * Service base URL + */ + private String serviceBaseUrl; + + public void setNtlmDomain(String ntlmDomain) { + // prevent blank value which is invalid and potentially hard to catch + this.ntlmDomain = StringUtils.isNotBlank(ntlmDomain) ? ntlmDomain : null; + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/model/deserializer/CustomOffsetDateTimeDeserializer.java b/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/model/deserializer/CustomOffsetDateTimeDeserializer.java new file mode 100644 index 000000000..82b0d7cac --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/model/deserializer/CustomOffsetDateTimeDeserializer.java @@ -0,0 +1,52 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.docverify.zenid.model.deserializer; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * Custom {@link java.time.OffsetDateTime} deserializer which allows also iso date format + * + *

+ * The ZenID returns simple ISO data on some date elements which are expected to be date-time (e.g. BirthDate) + *

+ */ +public class CustomOffsetDateTimeDeserializer extends JsonDeserializer { + + @Override + public OffsetDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException, JsonProcessingException { + if (parser.getText() != null && parser.getText().length() == 10) { // yyyy-MM-dd + return LocalDate.parse(parser.getText(), DateTimeFormatter.ISO_DATE) + .atStartOfDay(ZoneId.of("UTC")) + .toOffsetDateTime(); + } + + return InstantDeserializer.OFFSET_DATE_TIME.deserialize(parser, context); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/provider/ZenidDocumentVerificationProvider.java b/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/provider/ZenidDocumentVerificationProvider.java new file mode 100644 index 000000000..df997ec29 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/provider/ZenidDocumentVerificationProvider.java @@ -0,0 +1,577 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.docverify.zenid.provider; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.wultra.app.docverify.zenid.config.ZenidConfigProps; +import com.wultra.app.docverify.zenid.model.api.*; +import com.wultra.app.docverify.zenid.service.ZenidRestApiService; +import com.wultra.app.enrollmentserver.database.DocumentVerificationRepository; +import com.wultra.app.enrollmentserver.database.entity.DocumentResultEntity; +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.errorhandling.DocumentVerificationException; +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentVerificationStatus; +import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.enrollmentserver.provider.DocumentVerificationProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; + +import javax.annotation.Nullable; +import java.util.*; + +/** + * Implementation of the {@link DocumentVerificationProvider} with ZenID (https://zenid.trask.cz/) + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@ConditionalOnProperty(value = "enrollment-server.document-verification.provider", havingValue = "zenid") +@Component +public class ZenidDocumentVerificationProvider implements DocumentVerificationProvider { + + private static final Logger logger = LoggerFactory.getLogger(ZenidDocumentVerificationProvider.class); + + private final ZenidConfigProps zenidConfigProps; + + private final ObjectMapper objectMapper; + + private final DocumentVerificationRepository documentVerificationRepository; + + private final ZenidRestApiService zenidApiService; + + /** + * Service constructor. + * + * @param zenidConfigProps ZenID configuration properties. + * @param objectMapper Object mapper. + * @param documentVerificationRepository Document verification repository. + * @param zenidApiService ZenID API service. + */ + @Autowired + public ZenidDocumentVerificationProvider( + ZenidConfigProps zenidConfigProps, + @Qualifier("objectMapperZenid") + ObjectMapper objectMapper, + DocumentVerificationRepository documentVerificationRepository, + ZenidRestApiService zenidApiService) { + this.zenidConfigProps = zenidConfigProps; + this.objectMapper = objectMapper; + this.documentVerificationRepository = documentVerificationRepository; + this.zenidApiService = zenidApiService; + } + + @Override + public DocumentsSubmitResult checkDocumentUpload(OwnerId id, DocumentVerificationEntity document) throws DocumentVerificationException { + DocumentsSubmitResult result = new DocumentsSubmitResult(); + ResponseEntity responseEntity; + + try { + responseEntity = zenidApiService.syncSample(id, document.getUploadId()); + } catch (RestClientException e) { + logger.warn("Failed REST call to check " + document + " upload in ZenID, " + id, e); + throw new DocumentVerificationException("Unable to check document upload due to a REST call failure"); + } catch (Exception e) { + logger.error("Unexpected error when checking " + document + " upload in ZenID, " + id, e); + throw new DocumentVerificationException("Unexpected error when checking document upload"); + } + + if (responseEntity.getBody() == null) { + logger.error("Missing response body when checking " + document + " upload in ZenID, " + id); + throw new DocumentVerificationException("Unexpected error when checking document upload"); + } + + if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) { + logger.error("Failed to check " + document + " upload in ZenID, statusCode={}, responseBody='{}', {}", + responseEntity.getStatusCode(), responseEntity.getBody(), id); + throw new DocumentVerificationException("Unable to check document upload due to a service error"); + } + + ZenidWebUploadSampleResponse response = responseEntity.getBody(); + DocumentSubmitResult documentSubmitResult = + createDocumentSubmitResult(id, document.getUploadId(), document.toString(), response); + if (response.getMinedData() != null) { + checkForMinedPhoto(id, document.getUploadId(), result, response.getMinedData()); + } + + if (zenidConfigProps.isAdditionalDocSubmitValidationsEnabled() && response.getMinedData() != null) { + checkAdditionalValidations(id, document.getType(), document.getSide(), documentSubmitResult, response.getMinedData()); + } + + result.setResults(List.of(documentSubmitResult)); + + return result; + } + + @Override + public DocumentsSubmitResult submitDocuments(OwnerId id, List documents) throws DocumentVerificationException { + DocumentsSubmitResult result = new DocumentsSubmitResult(); + + String sessionId = UUID.randomUUID().toString(); + for (SubmittedDocument document : documents) { + ResponseEntity responseEntity; + + try { + responseEntity = zenidApiService.uploadSample(id, sessionId, document); + } catch (RestClientException e) { + logger.warn("Failed REST call to submit documents to ZenID, " + id, e); + throw new DocumentVerificationException("Unable to submit documents due to a REST call failure"); + } catch (Exception e) { + logger.error("Unexpected error when submitting documents to ZenID, " + id, e); + throw new DocumentVerificationException("Unexpected error when submitting documents"); + } + + if (responseEntity.getBody() == null) { + logger.error("Missing response body when submitting documents to ZenID, " + id); + throw new DocumentVerificationException("Unexpected error when submitting documents"); + } + + if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) { + logger.error("Failed to submit documents to ZenID, statusCode={}, responseBody='{}', {}", + responseEntity.getStatusCode(), responseEntity.getBody(), id); + throw new DocumentVerificationException("Unable to submit documents due to a service error"); + } + + ZenidWebUploadSampleResponse response = responseEntity.getBody(); + DocumentSubmitResult documentSubmitResult = + createDocumentSubmitResult(id, document.getDocumentId(), document.toString(), response); + if (response.getMinedData() != null) { + checkForMinedPhoto(id, document.getDocumentId(), result, response.getMinedData()); + } + if (zenidConfigProps.isAdditionalDocSubmitValidationsEnabled() && response.getMinedData() != null) { + checkAdditionalValidations(id, document.getType(), document.getSide(), documentSubmitResult, response.getMinedData()); + } + result.getResults().add(documentSubmitResult); + } + return result; + } + + @Override + public DocumentsVerificationResult verifyDocuments(OwnerId id, List uploadIds) throws DocumentVerificationException { + ResponseEntity responseEntity; + try { + responseEntity = zenidApiService.investigateSamples(uploadIds); + } catch (RestClientException e) { + logger.warn("Failed REST call to verify documents " + uploadIds + " in ZenID", e); + throw new DocumentVerificationException("Unable to verify documents due to a REST call failure"); + } catch (Exception e) { + logger.error("Unexpected error when verifying documents " + uploadIds + " in ZenID", e); + throw new DocumentVerificationException("Unexpected error when verifying documents"); + } + + if (responseEntity.getBody() == null) { + logger.error("Missing response body when verifying documents " + uploadIds + " in ZenID, " + id); + throw new DocumentVerificationException("Unexpected error when verifying documents"); + } + + if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) { + logger.error("Failed to verify documents {} in ZenID, statusCode={}, responseBody='{}', {}", + uploadIds, responseEntity.getStatusCode(), responseEntity.getBody(), id); + throw new DocumentVerificationException("Unable to verify documents due to a service error"); + } + + return toResult(id, responseEntity.getBody(), uploadIds); + } + + @Override + public DocumentsVerificationResult getVerificationResult(OwnerId id, String verificationId) throws DocumentVerificationException { + ResponseEntity responseEntity; + try { + responseEntity = zenidApiService.getInvestigation(verificationId); + } catch (RestClientException e) { + logger.warn("Failed REST call to get a verification result for verificationId=" + verificationId + " from ZenID", e); + throw new DocumentVerificationException("Unable to get a verification result due to a REST call failure"); + } catch (Exception e) { + logger.error("Unexpected error when getting a verification result for verificationId=" + verificationId + " from ZenID", e); + throw new DocumentVerificationException("Unexpected error when getting a verification result"); + } + + if (responseEntity.getBody() == null) { + logger.error("Missing response body when getting a verification result for verificationId=" + verificationId + " from ZenID, " + id); + throw new DocumentVerificationException("Unexpected error when getting a verification result for verificationId=" + verificationId + " from ZenID, " + id); + } + + if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) { + logger.error("Failed to get a verification result for verificationId={} from ZenID, statusCode={}, responseBody='{}', {}", + verificationId, responseEntity.getStatusCode(), responseEntity.getBody(), id); + throw new DocumentVerificationException("Unable to get a verification result"); + } + + List uploadIds = documentVerificationRepository.findAllUploadIds(verificationId); + return toResult(id, responseEntity.getBody(), uploadIds); + } + + @Override + public Image getPhoto(String photoId) throws DocumentVerificationException { + ResponseEntity responseEntity; + try { + responseEntity = zenidApiService.getImage(photoId); + } catch (RestClientException e) { + logger.warn("Failed REST call to get a photoId=" + photoId + " from ZenID", e); + throw new DocumentVerificationException("Unable to get a photo due to a REST call failure"); + } catch (Exception e) { + logger.error("Unexpected error when getting a photo=" + photoId + " from ZenID", e); + throw new DocumentVerificationException("Unexpected error when getting a photo"); + } + + if (responseEntity.getBody() == null) { + logger.error("Missing response body when getting a photoId={} from ZenID", photoId); + throw new DocumentVerificationException("Unexpected error when getting a photo"); + } + + if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) { + logger.error("Failed to get a photo photoId={} from ZenID, statusCode={}, responseBody='{}'", + photoId, responseEntity.getStatusCode(), responseEntity.getBody()); + throw new DocumentVerificationException("Unable to get a photo due to a service error"); + } + + String filename = getContentDispositionFilename(responseEntity.getHeaders()); + + Image image = new Image(); + image.setData(responseEntity.getBody()); + image.setFilename(filename); + return image; + } + + @Override + public void cleanupDocuments(OwnerId id, List uploadIds) throws DocumentVerificationException { + for (String uploadId : uploadIds) { + ResponseEntity responseEntity; + try { + responseEntity = zenidApiService.deleteSample(uploadId); + } catch (RestClientException e) { + logger.warn("Failed REST call to cleanup documents from ZenID, " + id, e); + throw new DocumentVerificationException("Unable to cleanup documents due to a REST call failure"); + } catch (Exception e) { + logger.error("Unexpected error when cleaning up documents from ZenID, " + id, e); + throw new DocumentVerificationException("Unexpected error when cleaning up documents"); + } + + if (responseEntity.getBody() == null) { + logger.error("Missing response body when cleaning up documents from ZenID, " + id); + throw new DocumentVerificationException("Unexpected error when cleaning up documents"); + } + + if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) { + logger.error("Failed to cleanup a document uploadId={} from ZenID, statusCode={}, responseBody='{}', {}", + uploadId, responseEntity.getStatusCode(), responseEntity.getBody(), id); + throw new DocumentVerificationException("Unable to cleanup documents due to a service error"); + } + + ZenidWebDeleteSampleResponse response = responseEntity.getBody(); + if (ZenidWebDeleteSampleResponse.ErrorCodeEnum.UNKNOWNSAMPLEID.equals(response.getErrorCode())) { + logger.info("Cleanup of an unknown document with uploadId={}", uploadId); + } else if (response.getErrorCode() != null) { + logger.error("Failed to cleanup uploadId={} from ZenID, errorCode={}, errorText={}", + uploadId, response.getErrorCode(), response.getErrorText()); + throw new DocumentVerificationException("Failed to cleanup a document with uploadId=" + uploadId); + } + } + logger.info("{} Cleaned up uploaded documents {} from ZenID.", id, uploadIds); + } + + @Override + public List parseRejectionReasons(DocumentResultEntity docResult) throws DocumentVerificationException { + if (docResult.getVerificationResult() == null) { + logger.warn("Missing the verification result in {} to parse rejected errors from", docResult); + return Collections.emptyList(); + } + List validations; + try { + validations = objectMapper.readValue(docResult.getVerificationResult(), new TypeReference<>() { }); + } catch (JsonProcessingException e) { + logger.error("Unexpected error when parsing verification result data from " + docResult, e); + throw new DocumentVerificationException("Unexpected error when parsing verification result data"); + } + + final List errors = new ArrayList<>(); + validations.forEach(validation -> { + if (validation.isOk()) { + return; + } + validation.getIssues().forEach(issue -> { + errors.add(issue.getIssueDescription()); + }); + }); + + return errors; + } + + private DocumentSubmitResult createDocumentSubmitResult(OwnerId id, + String documentId, + String uploadContext, + ZenidWebUploadSampleResponse response) + throws DocumentVerificationException { + DocumentSubmitResult documentSubmitResult = new DocumentSubmitResult(); + documentSubmitResult.setDocumentId(documentId); + documentSubmitResult.setUploadId(response.getSampleID()); + + if (response.getMinedData() != null) { + String extractedData = toExtractedData(id, response.getMinedData()); + documentSubmitResult.setExtractedData(extractedData); + } + + if (ZenidWebUploadSampleResponse.StateEnum.DONE.equals(response.getState())) { + logger.debug("Document upload of {} is done in ZenID, {}", uploadContext, id); + if (documentSubmitResult.getExtractedData() == null) { + logger.info("No data extracted from {} in ZenID, defaulting to empty json data, {}", uploadContext, id); + documentSubmitResult.setExtractedData(DocumentSubmitResult.NO_DATA_EXTRACTED); + } + } else if (ZenidWebUploadSampleResponse.StateEnum.NOTDONE.equals(response.getState())) { + logger.debug("Document upload of {} is still in progress in ZenID, {}", uploadContext, id); + } else if (ZenidWebUploadSampleResponse.StateEnum.REJECTED.equals(response.getState())) { + logger.debug("Document upload of {} is rejected in ZenID, {}", uploadContext, id); + documentSubmitResult.setRejectReason(response.getErrorText()); + } else { + logger.warn("Document upload of {} failed in ZenID: {}, {}", uploadContext, response.getState(), id); + throw new DocumentVerificationException("Unable to upload a document"); + } + + if (response.getErrorCode() != null) { + documentSubmitResult.setErrorDetail("ZenID error: " + response.getErrorCode() + + (response.getErrorText() != null ? ", " + response.getErrorText() : "")); + } + return documentSubmitResult; + } + + private void checkAdditionalValidations(OwnerId id, + DocumentType documentType, + CardSide cardSide, + DocumentSubmitResult documentSubmitResult, + ZenidSharedMineAllResult minedData) { + if (DocumentType.SELFIE_PHOTO.equals(documentType)) { + logger.debug("Not performing additional validations for selfie photo"); + return; + } + DocumentType zenIdDocType = minedData.getDocumentRole() == null ? + null : toDocumentType(minedData.getDocumentRole()); + if (documentType != zenIdDocType) { + logger.info("Received different document type {} ({}) than expected {} from ZenID, {}", + zenIdDocType, minedData.getDocumentRole(), documentType, id); + documentSubmitResult.setRejectReason( + String.format("Different document type %s than expected %s", zenIdDocType, documentType) + ); + } + if (documentSubmitResult.getRejectReason() == null) { + CardSide zenIdCardSide = minedData.getPageCode() == null ? + null : toCardSide(minedData.getPageCode()); + if (zenIdCardSide == null && cardSide != null) { + documentSubmitResult.setRejectReason(String.format("Not recognized document side %s", cardSide)); + } else if (zenIdCardSide != null && cardSide != null && cardSide != zenIdCardSide) { + documentSubmitResult.setRejectReason( + String.format("Different document side %s than expected %s", zenIdCardSide, cardSide) + ); + } + } + } + + private void checkForMinedPhoto(OwnerId id, + String documentId, + DocumentsSubmitResult result, + ZenidSharedMineAllResult minedData) { + // Photo hash of the person is optionally present at /MinedData/Photo/ImageData/ImageHash + ZenidSharedMinedPhoto photo = minedData.getPhoto(); + if (photo != null && photo.getImageData() != null && photo.getImageData().getImageHash() != null) { + logger.info("Extracted a photoId from submitted documentId={} to ZenID, " + id, documentId); + result.setExtractedPhotoId(photo.getImageData().getImageHash().getAsText()); + } + } + + private String getContentDispositionFilename(HttpHeaders headers) { + String filename = headers.getContentDisposition().getFilename(); + if (filename == null) { + MediaType contentType = headers.getContentType(); + if (contentType != null) { + filename = "unknown." + contentType.getSubtype().toLowerCase(); + } else { + throw new IllegalStateException("Unable to resolve filename"); + } + } + return filename; + } + + private DocumentsVerificationResult toResult(OwnerId id, ZenidWebInvestigateResponse response, List knownSampleIds) + throws DocumentVerificationException { + DocumentsVerificationResult result = new DocumentsVerificationResult(); + result.setVerificationId(String.valueOf(response.getInvestigationID())); + + if (response.getErrorCode() != null) { + result.setErrorDetail("ZenID error: " + response.getErrorCode() + + (response.getErrorText() != null ? ", " + response.getErrorText() : "")); + } else { + Map> sampleIdsValidations = new HashMap<>(); + // Only sampleIds with failed validations are in the response, prefill with all known sampleIds + knownSampleIds.forEach(sampleId -> { + sampleIdsValidations.put(sampleId, new ArrayList<>()); + }); + List globalValidations = new ArrayList<>(); + for (ZenidWebInvestigationValidatorResponse validatorResult : response.getValidatorResults()) { + if (validatorResult.getIssues().isEmpty()) { + // no issues - some kind of global validation + ZenidWebInvestigationValidatorResponse validationData = copyOf(validatorResult); + globalValidations.add(validationData); + } else { + for (ZenidWebInvestigationIssueResponse issueItem : validatorResult.getIssues()) { + if (issueItem.getSampleID() == null) { + // missing sampleId - some kind of global validation + ZenidWebInvestigationValidatorResponse validationData = copyOf(validatorResult); + validationData.addIssuesItem(issueItem); + globalValidations.add(validationData); + } else { + // with sampleId - validation on a specific document + ZenidWebInvestigationValidatorResponse validationData = copyOf(validatorResult); + validationData.addIssuesItem(issueItem); + sampleIdsValidations.computeIfAbsent(issueItem.getSampleID(), (sampleId) -> new ArrayList<>()) + .add(validationData); + } + } + } + } + + List verificationResults = new ArrayList<>(); + + String extractedData = toExtractedData(id, response.getMinedData()); + for (String sampleId : sampleIdsValidations.keySet()) { + // TODO Consider using an object instead of simple array (call for a standard json) + List validations = new ArrayList<>(sampleIdsValidations.get(sampleId)); + + DocumentVerificationResult verificationResult = new DocumentVerificationResult(); + verificationResult.setExtractedData(extractedData); + verificationResult.setUploadId(sampleId); + + // Find a first failed validation, use its description as the rejected reason for the document + Optional failedValidation = validations.stream() + .filter(validation -> !validation.isOk()) + // Sort the validations by difference between the actual score and the accepted score value + .max(Comparator.comparingInt((value -> value.getAcceptScore() - value.getScore()))); + if (failedValidation.isPresent()) { + String rejectReason = failedValidation.get().getIssues().get(0).getIssueDescription(); + verificationResult.setRejectReason(rejectReason); + } + + validations.addAll(globalValidations); + String verificationResultData; + try { + verificationResultData = objectMapper.writeValueAsString(validations); + } catch (JsonProcessingException e) { + logger.error("Unexpected error when processing verification result data, " + id, e); + throw new DocumentVerificationException("Unexpected error when processing verification result data"); + } + + verificationResult.setVerificationResult(verificationResultData); + verificationResults.add(verificationResult); + } + result.setResults(verificationResults); + } + + // TODO compute verification score + // result.setVerificationScore(); + + DocumentVerificationStatus verificationStatus = toStatus(response.getState()); + if (result.getResults() != null) { + // Check the results if there is no rejected validation + Optional optionalFailedVerification = result.getResults() + .stream() + .filter(value -> value.getRejectReason() != null) + .findAny(); + if (optionalFailedVerification.isPresent()) { + verificationStatus = DocumentVerificationStatus.REJECTED; + result.setRejectReason(optionalFailedVerification.get().getRejectReason()); + } + } + result.setStatus(verificationStatus); + return result; + } + + private ZenidWebInvestigationValidatorResponse copyOf(ZenidWebInvestigationValidatorResponse value) { + ZenidWebInvestigationValidatorResponse result = new ZenidWebInvestigationValidatorResponse(); + result.setAcceptScore(value.getAcceptScore()); + result.setCode(value.getCode()); + result.setName(value.getName()); + result.setScore(value.getScore()); + result.setOk(value.isOk()); + return result; + } + + private String toExtractedData(OwnerId id, ZenidSharedMineAllResult minedData) throws DocumentVerificationException { + String extractedData; + try { + extractedData = objectMapper.writeValueAsString(minedData); + } catch (JsonProcessingException e) { + logger.error("Unexpected error when processing extracted data, " + id, e); + throw new DocumentVerificationException("Unexpected error when processing extracted data"); + } + return extractedData; + } + + private DocumentVerificationStatus toStatus(ZenidWebInvestigateResponse.StateEnum stateEnum) { + switch (stateEnum) { + case DONE: + return DocumentVerificationStatus.ACCEPTED; + case ERROR: + return DocumentVerificationStatus.FAILED; + case NOTDONE: + case OPERATOR: + return DocumentVerificationStatus.IN_PROGRESS; + case REJECTED: + return DocumentVerificationStatus.REJECTED; + default: + throw new IllegalStateException("Unknown investigation status in ZenID: " + stateEnum); + } + } + + private DocumentType toDocumentType(ZenidSharedMineAllResult.DocumentRoleEnum documentRoleEnum) { + switch (documentRoleEnum) { + case DRV: + return DocumentType.DRIVING_LICENSE; + case IDC: + return DocumentType.ID_CARD; + case PAS: + return DocumentType.PASSPORT; + default: + return DocumentType.UNKNOWN; + } + } + + @Nullable + private CardSide toCardSide(@Nullable ZenidSharedMineAllResult.PageCodeEnum pageCodeEnum) { + if (pageCodeEnum == null) { + return null; + } + switch (pageCodeEnum) { + case F: + return CardSide.FRONT; + case B: + return CardSide.BACK; + default: + throw new IllegalStateException("Unexpected side page code value: " + pageCodeEnum); + } + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/service/ZenidRestApiService.java b/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/service/ZenidRestApiService.java new file mode 100644 index 000000000..55c78b89e --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/docverify/zenid/service/ZenidRestApiService.java @@ -0,0 +1,281 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.docverify.zenid.service; + +import com.google.common.base.Preconditions; +import com.wultra.app.docverify.zenid.config.ZenidConfigProps; +import com.wultra.app.docverify.zenid.model.api.ZenidSharedMineAllResult; +import com.wultra.app.docverify.zenid.model.api.ZenidWebDeleteSampleResponse; +import com.wultra.app.docverify.zenid.model.api.ZenidWebInvestigateResponse; +import com.wultra.app.docverify.zenid.model.api.ZenidWebUploadSampleResponse; +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.model.integration.SubmittedDocument; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import javax.annotation.Nullable; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Implementation of the REST service to ZenID (https://zenid.trask.cz/) + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@ConditionalOnProperty(value = "enrollment-server.document-verification.provider", havingValue = "zenid") +@Service +public class ZenidRestApiService { + + /** + * Configuration properties. + */ + private final ZenidConfigProps configProps; + + /** + * REST template for ZenID calls. + */ + private final RestTemplate restTemplate; + + /** + * Service constructor. + * + * @param configProps Configuration properties. + * @param restTemplate REST template for ZenID calls. + */ + @Autowired + public ZenidRestApiService( + ZenidConfigProps configProps, + @Qualifier("restTemplateZenid") RestTemplate restTemplate) { + this.configProps = configProps; + this.restTemplate = restTemplate; + } + + /** + * Uploads photo data as a sample DocumentPicture + * + * @param ownerId Owner identification. + * @param sessionId Session id which allows to link several uploads together. + * @param document Submitted document. + * @return Response entity with the upload result + */ + public ResponseEntity uploadSample(OwnerId ownerId, String sessionId, SubmittedDocument document) { + Preconditions.checkNotNull(document.getPhoto(), "Missing photo in " + document); + + String apiPath = buildApiUploadPath(ownerId, sessionId, document); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", new ByteArrayResource(document.getPhoto().getData()) { + @Override + public String getFilename() { + return document.getPhoto().getFilename(); + } + }); + + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); + headers.set(HttpHeaders.CONTENT_TYPE, MediaType.MULTIPART_FORM_DATA_VALUE); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + return restTemplate.postForEntity(apiPath, requestEntity, ZenidWebUploadSampleResponse.class); + } + + /** + * Synchronizes submitted document result of a previous upload. + * + * @param ownerId Owner identification. + * @param documentId Submitted document id. + * @return Response entity with the upload result + */ + public ResponseEntity syncSample(OwnerId ownerId, String documentId) { + String apiPath = "/api/sample/" + documentId; + + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); + + HttpEntity> requestEntity = new HttpEntity<>(headers); + + return restTemplate.exchange(apiPath, HttpMethod.GET, requestEntity, ZenidWebUploadSampleResponse.class); + } + + /** + * Investigates uploaded samples + * + * @param sampleIds Ids of previously uploaded samples. + * @return Response entity with the investigation result + */ + public ResponseEntity investigateSamples(List sampleIds) { + Preconditions.checkArgument(sampleIds.size() > 0, "Missing sample ids for investigation"); + + String apiPath = "/api/investigateSamples"; + + String querySampleIds = sampleIds.stream() + .map(sampleId -> "sampleIDs=" + sampleId) + .collect(Collectors.joining("&")); + apiPath += "?" + querySampleIds; + + apiPath += "&async=" + String.valueOf(configProps.isAsyncProcessingEnabled()).toLowerCase(); + + HttpEntity entity = createDefaultRequestEntity(); + return restTemplate.exchange(apiPath, HttpMethod.GET, entity, ZenidWebInvestigateResponse.class); + } + + /** + * Deletes an uploaded sample + * + * @param sampleId Id of previously uploaded sample. + * @return Response entity with the deletion result + */ + public ResponseEntity deleteSample(String sampleId) { + String apiPath = String.format("/api/deleteSample?sampleId=%s", sampleId); + HttpEntity entity = createDefaultRequestEntity(); + return restTemplate.exchange(apiPath, HttpMethod.GET, entity, ZenidWebDeleteSampleResponse.class); + } + + /** + * Gets image data belonging to the specified hash + * @param imageHash Image hash + * @return Response entity with the image data + */ + public ResponseEntity getImage(String imageHash) { + String apiPath = String.format("/History/Image/%s", imageHash); + HttpEntity entity = createAcceptOctetStreamRequestEntity(); + return restTemplate.exchange(apiPath, HttpMethod.GET, entity, byte[].class); + } + + /** + * Provides result of an investigation. + * + *

+ * Only failed validation results are returned. All document samples without a validation constraint + * are considered as passed. + *

+ * + * @param investigationId Id of a previously run investigation + * @return Response entity with the investigation result + */ + public ResponseEntity getInvestigation(String investigationId) { + String apiPath = String.format("/api/investigation/%s", investigationId); + HttpEntity entity = createDefaultRequestEntity(); + return restTemplate.exchange(apiPath, HttpMethod.GET, entity, ZenidWebInvestigateResponse.class); + } + + private String buildApiUploadPath(OwnerId ownerId, String sessionId, SubmittedDocument document) { + StringBuilder apiPathBuilder = new StringBuilder("/api/sample") + .append("?") + .append("async=").append(String.valueOf(configProps.isAsyncProcessingEnabled()).toLowerCase()) + .append("&expectedSampleType=").append(toSampleType(document.getType())) + .append("&customData=").append(ownerId.getActivationId()) + .append("&uploadSessionID=").append(sessionId); + + apiPathBuilder.append("&country=").append(configProps.getDocumentCountry()); + + ZenidSharedMineAllResult.DocumentCodeEnum documentCode = toDocumentCode(document.getType()); + if (documentCode != null) { + apiPathBuilder.append("&documentCode=").append(documentCode); + } + + if (document.getSide() != null) { + apiPathBuilder.append("&pageCode=").append(toPageCodeEnum(document.getSide())); + } + + ZenidSharedMineAllResult.DocumentRoleEnum documentRole = toDocumentRole(document.getType()); + if (documentRole != null) { + apiPathBuilder.append("&role=").append(documentRole); + } + + return apiPathBuilder.toString(); + } + + private HttpEntity createDefaultRequestEntity() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); + return new HttpEntity<>(headers); + } + + private HttpEntity createAcceptOctetStreamRequestEntity() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.ACCEPT, MediaType.APPLICATION_OCTET_STREAM_VALUE); + return new HttpEntity<>(headers); + } + + private @Nullable ZenidSharedMineAllResult.DocumentCodeEnum toDocumentCode(DocumentType documentType) { + switch (documentType) { + case DRIVING_LICENSE: + return ZenidSharedMineAllResult.DocumentCodeEnum.DRV; + case PASSPORT: + return ZenidSharedMineAllResult.DocumentCodeEnum.PAS; +// case ID_CARD: +// // Not supported more than one version of a document +// return List.of(ZenidSharedMineAllResult.DocumentCodeEnum.IDC1, ZenidSharedMineAllResult.DocumentCodeEnum.IDC2); + default: + return null; + } + } + + private @Nullable ZenidSharedMineAllResult.DocumentRoleEnum toDocumentRole(DocumentType documentType) { + switch (documentType) { + case DRIVING_LICENSE: + return ZenidSharedMineAllResult.DocumentRoleEnum.DRV; + case ID_CARD: + return ZenidSharedMineAllResult.DocumentRoleEnum.IDC; + case PASSPORT: + return ZenidSharedMineAllResult.DocumentRoleEnum.PAS; + default: + return null; + } + } + + @Nullable + private ZenidSharedMineAllResult.PageCodeEnum toPageCodeEnum(@Nullable CardSide cardSide) { + if (cardSide == null) { + return null; + } + switch (cardSide) { + case FRONT: + return ZenidSharedMineAllResult.PageCodeEnum.F; + case BACK: + return ZenidSharedMineAllResult.PageCodeEnum.B; + default: + throw new IllegalStateException("Unexpected card side value: " + cardSide); + } + } + + private ZenidWebUploadSampleResponse.SampleTypeEnum toSampleType(DocumentType type) { + switch (type) { + case ID_CARD: + case DRIVING_LICENSE: + case PASSPORT: + return ZenidWebUploadSampleResponse.SampleTypeEnum.DOCUMENTPICTURE; + case SELFIE_PHOTO: + return ZenidWebUploadSampleResponse.SampleTypeEnum.SELFIE; + default: + throw new IllegalStateException("Not supported documentType: " + type); + } + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/EnrollmentServerApplication.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/EnrollmentServerApplication.java index d19928dfc..b722619d8 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/EnrollmentServerApplication.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/EnrollmentServerApplication.java @@ -18,13 +18,21 @@ package com.wultra.app.enrollmentserver; +import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.security.Security; @SpringBootApplication +@EnableConfigurationProperties +@EnableScheduling public class EnrollmentServerApplication { public static void main(String[] args) { + Security.addProvider(new BouncyCastleProvider()); SpringApplication.run(EnrollmentServerApplication.class, args); } diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/activation/ActivationOtpService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/activation/ActivationOtpService.java new file mode 100644 index 000000000..b4ba8af88 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/activation/ActivationOtpService.java @@ -0,0 +1,55 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.activation; + +import com.wultra.app.enrollmentserver.api.model.response.OtpVerifyResponse; +import com.wultra.app.enrollmentserver.errorhandling.OnboardingProcessException; +import com.wultra.app.enrollmentserver.impl.service.OtpService; +import com.wultra.app.enrollmentserver.model.enumeration.OtpType; +import org.springframework.stereotype.Service; + +/** + * Service used for verifying OTP codes during activation. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Service +public class ActivationOtpService { + + private final OtpService otpService; + + /** + * Service constructors. + * @param otpService OTP service. + */ + public ActivationOtpService(OtpService otpService) { + this.otpService = otpService; + } + + /** + * Verify an OTP code during activation. + * @param processId Onboarding process identifier. + * @param otpCode OTP code. + * @throws OnboardingProcessException Thrown when onboarding process or OTP code is not found. + * @return OTP verification response. + */ + public OtpVerifyResponse verifyOtpCode(String processId, String otpCode) throws OnboardingProcessException { + return otpService.verifyOtpCode(processId, otpCode, OtpType.ACTIVATION); + } + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/activation/ActivationProcessService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/activation/ActivationProcessService.java new file mode 100644 index 000000000..aaf05035c --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/activation/ActivationProcessService.java @@ -0,0 +1,90 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.activation; + +import com.wultra.app.enrollmentserver.database.entity.OnboardingProcessEntity; +import com.wultra.app.enrollmentserver.errorhandling.OnboardingProcessException; +import com.wultra.app.enrollmentserver.impl.service.OnboardingService; +import com.wultra.app.enrollmentserver.model.enumeration.OnboardingStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.Date; + +/** + * Service used for updating the onboarding process status during activation. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Service +public class ActivationProcessService { + + private static final Logger logger = LoggerFactory.getLogger(ActivationProcessService.class); + + private final OnboardingService onboardingService; + + /** + * Service constructor. + * @param onboardingService Onboarding service. + */ + public ActivationProcessService(OnboardingService onboardingService) { + this.onboardingService = onboardingService; + } + + /** + * Update an onboarding process during activation. + * @param processId Process identifier. + * @param activationId Activation identifier. + * @param status Onboarding process status to set. + * @throws OnboardingProcessException Thrown when onboarding process is not found. + */ + public void updateProcess(String processId, String userId, String activationId, OnboardingStatus status) throws OnboardingProcessException { + OnboardingProcessEntity process = onboardingService.findProcess(processId); + checkUserIdForProcess(process, userId); + process.setActivationId(activationId); + process.setStatus(status); + process.setTimestampLastUpdated(new Date()); + onboardingService.updateProcess(process); + } + + /** + * Get user identifier for an onboarding process. + * @param processId Onboarding process identifier. + * @return User identifier. + * @throws OnboardingProcessException Thrown when onboarding process is not found. + */ + public String getUserId(String processId) throws OnboardingProcessException { + OnboardingProcessEntity process = onboardingService.findProcess(processId); + return process.getUserId(); + } + + /** + * Check user identifier for an onboarding process. + * @param process Onboarding process. + * @param userId User identifier. + * @throws OnboardingProcessException Thrown when onboarding process is not found or the user ID does not match the process. + */ + private void checkUserIdForProcess(OnboardingProcessEntity process, String userId) throws OnboardingProcessException { + if (!process.getUserId().equals(userId)) { + logger.warn("User ID does not match to onboarding process: {}, {} ", process.getId(), userId); + throw new OnboardingProcessException(); + } + } + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/IdentityVerificationConfig.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/IdentityVerificationConfig.java new file mode 100644 index 000000000..d4db2bf79 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/IdentityVerificationConfig.java @@ -0,0 +1,63 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.configuration; + +import lombok.Data; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +/** + * Identity verification configuration. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Configuration +@Data +public class IdentityVerificationConfig { + + @Value("${enrollment-server.document-verification.provider:mock}") + private String documentVerificationProvider; + + @Value("${enrollment-server.document-verification.verificationOnSubmitEnabled:true}") + private boolean documentVerificationOnSubmitEnabled; + + @Value("${enrollment-server.document-verification.cleanupEnabled:false}") + private boolean documentVerificationCleanupEnabled; + + @Value("${enrollment-server.presence-check.enabled:true}") + private boolean presenceCheckEnabled; + + @Value("${enrollment-server.presence-check.verifySelfieWithDocumentsEnabled:false}") + private boolean verifySelfieWithDocumentsEnabled; + + @Value("${enrollment-server.presence-check.provider:mock}") + private String presenceCheckProvider; + + @Value("${enrollment-server.presence-check.cleanupEnabled:false}") + private boolean presenceCheckCleanupEnabled; + + @Value("${enrollment-server.identity-verification.data-retention.hours:1}") + private int dataRetentionTime; + + @Value("${enrollment-server.onboarding-process.verification.expiration.seconds:300}") + private int verificationExpirationTime; + + @Value("${enrollment-server.identity-verification.otp.enabled:true}") + private boolean verificationOtpEnabled; + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/OnboardingConfig.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/OnboardingConfig.java new file mode 100644 index 000000000..9fc15104d --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/OnboardingConfig.java @@ -0,0 +1,53 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.configuration; + +import lombok.Data; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +/** + * Identity verification configuration. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Configuration +@Data +public class OnboardingConfig { + + @Value("${enrollment-server.identity-verification.otp.length:8}") + private int otpLength; + + @Value("${enrollment-server.onboarding-process.activation.expiration.seconds:300}") + private int activationExpirationTime; + + @Value("${enrollment-server.onboarding-process.otp.expiration:PT30S}") + private Duration otpExpirationTime; + + @Value("${enrollment-server.onboarding-process.otp.max-failed-attempts:5}") + private int otpMaxFailedAttempts; + + @Value("${enrollment-server.onboarding-process.otp.resend-period:PT30S}") + private Duration otpResendPeriod; + + @Value("${enrollment-server.onboarding-process.max-processes-per-day:5}") + private int maxProcessCountPerDay; + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/PowerAuthWebServiceConfiguration.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/PowerAuthWebServiceConfiguration.java index 4bae7020d..c0ee60811 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/PowerAuthWebServiceConfiguration.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/PowerAuthWebServiceConfiguration.java @@ -37,7 +37,12 @@ */ @Configuration @ConfigurationProperties("ext") -@ComponentScan(basePackages = {"io.getlime.security.powerauth","com.wultra.security.powerauth"}) +@ComponentScan(basePackages = { + "com.wultra.app.docverify", + "com.wultra.app.presencecheck", + "com.wultra.security.powerauth", + "io.getlime.security.powerauth", +}) public class PowerAuthWebServiceConfiguration { private static final Logger logger = LoggerFactory.getLogger(PowerAuthWebServiceConfiguration.class); diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/SchedulerConfig.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/SchedulerConfig.java new file mode 100644 index 000000000..a41ab1dcf --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/SchedulerConfig.java @@ -0,0 +1,55 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.enrollmentserver.configuration; + +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider; +import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.EnableScheduling; + +import javax.sql.DataSource; + +/** + * Scheduler configuration. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@EnableScheduling +@EnableSchedulerLock(defaultLockAtLeastFor = "15s", defaultLockAtMostFor = "1m") +@Configuration +public class SchedulerConfig { + + /** + * Defines a bean with the lock provider for https://github.com/lukas-krecan/ShedLock + * @param dataSource Data source + * @return Scheduler lock provider + */ + @Bean + public LockProvider lockProviderDefaultDataSource(DataSource dataSource) { + return new JdbcTemplateLockProvider( + JdbcTemplateLockProvider.Configuration.builder() + .usingDbTime() + .withJdbcTemplate(new JdbcTemplate(dataSource)) + .build() + ); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/WebApplicationConfig.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/WebApplicationConfig.java index 90010c511..e4c967ca3 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/WebApplicationConfig.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/configuration/WebApplicationConfig.java @@ -18,6 +18,7 @@ package com.wultra.app.enrollmentserver.configuration; +import com.fasterxml.jackson.databind.ObjectMapper; import io.getlime.security.powerauth.rest.api.spring.annotation.support.PowerAuthAnnotationInterceptor; import io.getlime.security.powerauth.rest.api.spring.annotation.support.PowerAuthEncryptionArgumentResolver; import io.getlime.security.powerauth.rest.api.spring.annotation.support.PowerAuthWebArgumentResolver; @@ -25,6 +26,9 @@ import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.web.filter.CommonsRequestLoggingFilter; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -73,4 +77,23 @@ public void addArgumentResolvers(List argumentRes argumentResolvers.add(powerAuthEncryptionArgumentResolver()); } + /** + * Global primary object mapper + * @param builder Jackson's object mapper builder + * @return Object mapper bean + */ + @Bean + @Primary + public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) { + return builder.build(); + } + + @Bean + public CommonsRequestLoggingFilter requestLoggingFilter() { + CommonsRequestLoggingFilter loggingFilter = new CommonsRequestLoggingFilter(); + loggingFilter.setIncludeQueryString(true); + loggingFilter.setIncludeHeaders(true); + return loggingFilter; + } + } diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/ActivationCodeController.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/ActivationCodeController.java index 90280a253..2f4eb091a 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/ActivationCodeController.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/ActivationCodeController.java @@ -20,8 +20,8 @@ import com.wultra.app.enrollmentserver.errorhandling.ActivationCodeException; import com.wultra.app.enrollmentserver.errorhandling.InvalidRequestObjectException; import com.wultra.app.enrollmentserver.impl.service.ActivationCodeService; -import com.wultra.app.enrollmentserver.model.request.ActivationCodeRequest; -import com.wultra.app.enrollmentserver.model.response.ActivationCodeResponse; +import com.wultra.app.enrollmentserver.api.model.request.ActivationCodeRequest; +import com.wultra.app.enrollmentserver.api.model.response.ActivationCodeResponse; import io.getlime.core.rest.model.base.request.ObjectRequest; import io.getlime.core.rest.model.base.response.ObjectResponse; import io.getlime.security.powerauth.crypto.lib.encryptor.ecies.model.EciesScope; @@ -32,6 +32,7 @@ import io.getlime.security.powerauth.rest.api.spring.authentication.PowerAuthApiAuthentication; import io.getlime.security.powerauth.rest.api.spring.encryption.EciesEncryptionContext; import io.getlime.security.powerauth.rest.api.spring.exception.PowerAuthAuthenticationException; +import io.getlime.security.powerauth.rest.api.spring.exception.PowerAuthEncryptionException; import io.swagger.v3.oas.annotations.Parameter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -76,6 +77,7 @@ public ActivationCodeController(ActivationCodeService activationCodeService) { * @param apiAuthentication Authentication object with user and app details. * @return New activation code, activation code signature and activation ID. * @throws PowerAuthAuthenticationException In case user authentication fails. + * @throws PowerAuthEncryptionException In case request decryption fails. * @throws InvalidRequestObjectException In case the object validation fails. * @throws ActivationCodeException In case fetching the activation code fails. */ @@ -87,7 +89,7 @@ public ActivationCodeController(ActivationCodeService activationCodeService) { }) public ObjectResponse requestActivationCode(@EncryptedRequestBody ObjectRequest request, @Parameter(hidden = true) EciesEncryptionContext eciesContext, - @Parameter(hidden = true) PowerAuthApiAuthentication apiAuthentication) throws PowerAuthAuthenticationException, InvalidRequestObjectException, ActivationCodeException { + @Parameter(hidden = true) PowerAuthApiAuthentication apiAuthentication) throws PowerAuthAuthenticationException, InvalidRequestObjectException, ActivationCodeException, PowerAuthEncryptionException { // Check if the authentication object is present if (apiAuthentication == null) { logger.error("Unable to verify device registration when fetching activation code"); @@ -97,12 +99,12 @@ public ObjectResponse requestActivationCode(@EncryptedRe // Check if the request was correctly decrypted if (eciesContext == null) { logger.error("ECIES encryption failed when fetching activation code"); - throw new PowerAuthAuthenticationException("ECIES decryption failed when fetching activation code"); + throw new PowerAuthEncryptionException("ECIES decryption failed when fetching activation code"); } if (request == null || request.getRequestObject() == null) { logger.error("Invalid request received when fetching activation code"); - throw new PowerAuthAuthenticationException("Invalid request received when fetching activation code"); + throw new PowerAuthEncryptionException("Invalid request received when fetching activation code"); } // Request the activation code details. diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/IdentityVerificationController.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/IdentityVerificationController.java new file mode 100644 index 000000000..1bdc77fb4 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/IdentityVerificationController.java @@ -0,0 +1,564 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.controller.api; + +import com.wultra.app.enrollmentserver.api.model.request.*; +import com.wultra.app.enrollmentserver.api.model.response.*; +import com.wultra.app.enrollmentserver.api.model.response.data.ConfigurationDataDto; +import com.wultra.app.enrollmentserver.api.model.response.data.DocumentMetadataResponseDto; +import com.wultra.app.enrollmentserver.configuration.IdentityVerificationConfig; +import com.wultra.app.enrollmentserver.configuration.OnboardingConfig; +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.database.entity.IdentityVerificationEntity; +import com.wultra.app.enrollmentserver.database.entity.OnboardingProcessEntity; +import com.wultra.app.enrollmentserver.errorhandling.*; +import com.wultra.app.enrollmentserver.impl.service.*; +import com.wultra.app.enrollmentserver.impl.service.document.DocumentProcessingService; +import com.wultra.app.enrollmentserver.impl.util.PowerAuthUtil; +import com.wultra.app.enrollmentserver.model.DocumentMetadata; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import io.getlime.core.rest.model.base.request.ObjectRequest; +import io.getlime.core.rest.model.base.response.ObjectResponse; +import io.getlime.core.rest.model.base.response.Response; +import io.getlime.security.powerauth.crypto.lib.encryptor.ecies.model.EciesScope; +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.annotation.PowerAuthToken; +import io.getlime.security.powerauth.rest.api.spring.authentication.PowerAuthApiAuthentication; +import io.getlime.security.powerauth.rest.api.spring.encryption.EciesEncryptionContext; +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Optional; + +/** + * Controller publishing REST services for identity document verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@ConditionalOnProperty( + value = "enrollment-server.identity-verification.enabled", + havingValue = "true" +) +@RestController +@RequestMapping(value = "api/identity") +public class IdentityVerificationController { + + private static final Logger logger = LoggerFactory.getLogger(IdentityVerificationController.class); + + private final IdentityVerificationConfig identityVerificationConfig; + + private final DocumentProcessingService documentProcessingService; + private final IdentityVerificationService identityVerificationService; + private final IdentityVerificationStatusService identityVerificationStatusService; + private final IdentityVerificationOtpService identityVerificationOtpService; + private final PresenceCheckService presenceCheckService; + private final OnboardingService onboardingService; + + /** + * Configuration data for client integration + */ + private final ConfigurationDataDto integrationConfigDto; + + /** + * Controller constructor. + * @param identityVerificationConfig Configuration of identity verification. + * @param onboardingConfig Configuration of onboarding. + * @param documentProcessingService Document processing service. + * @param identityVerificationService Identity verification service. + * @param identityVerificationStatusService Identity verification status service. + * @param identityVerificationOtpService Identity OTP verification service. + * @param onboardingService Onboarding service. + * @param presenceCheckService Presence check service. + */ + @Autowired + public IdentityVerificationController( + IdentityVerificationConfig identityVerificationConfig, + OnboardingConfig onboardingConfig, + DocumentProcessingService documentProcessingService, + IdentityVerificationService identityVerificationService, + IdentityVerificationStatusService identityVerificationStatusService, + IdentityVerificationOtpService identityVerificationOtpService, + OnboardingService onboardingService, + PresenceCheckService presenceCheckService) { + this.identityVerificationConfig = identityVerificationConfig; + + this.documentProcessingService = documentProcessingService; + this.identityVerificationService = identityVerificationService; + this.identityVerificationStatusService = identityVerificationStatusService; + this.identityVerificationOtpService = identityVerificationOtpService; + this.onboardingService = onboardingService; + this.presenceCheckService = presenceCheckService; + + this.integrationConfigDto = new ConfigurationDataDto(); + integrationConfigDto.setOtpResendPeriod(onboardingConfig.getOtpResendPeriod().toString()); + } + + /** + * Initialize identity verification. + * @param request Initialize identity verification request. + * @param apiAuthentication PowerAuth authentication. + * @return Response. + * @throws PowerAuthAuthenticationException Thrown when request authentication fails. + * @throws IdentityVerificationException Thrown when identity verification initialization fails. + * @throws RemoteCommunicationException Thrown when communication with PowerAuth server fails. + * @throws OnboardingProcessException Thrown when onboarding process is invalid. + */ + @RequestMapping(value = "init", method = RequestMethod.POST) + @PowerAuthEncryption(scope = EciesScope.ACTIVATION_SCOPE) + @PowerAuth(resourceId = "/api/identity/init", signatureType = { + PowerAuthSignatureTypes.POSSESSION + }) + public Response initializeIdentityVerification(@EncryptedRequestBody ObjectRequest request, + @Parameter(hidden = true) PowerAuthApiAuthentication apiAuthentication) + throws PowerAuthAuthenticationException, IdentityVerificationException, RemoteCommunicationException, OnboardingProcessException { + // Check if the authentication object is present + if (apiAuthentication == null) { + logger.error("Unable to verify device registration when initializing identity verification"); + throw new PowerAuthAuthenticationException("Unable to verify device registration when initializing identity verification"); + } + + if (request == null || request.getRequestObject() == null) { + logger.error("Invalid request received when initializing identity verification"); + throw new PowerAuthAuthenticationException("Invalid request received when initializing identity verification"); + } + + // Initialize identity verification + final OwnerId ownerId = PowerAuthUtil.getOwnerId(apiAuthentication); + final String processId = request.getRequestObject().getProcessId(); + + onboardingService.verifyProcessId(ownerId, processId); + + identityVerificationService.initializeIdentityVerification(ownerId, processId); + return new Response(); + } + + /** + * Submit identity-related documents for verification. + * @param request Document submit request. + * @param eciesContext ECIES context. + * @param apiAuthentication PowerAuth authentication. + * @return Document submit response. + * @throws PowerAuthAuthenticationException Thrown when request authentication fails. + * @throws PowerAuthEncryptionException Thrown when request decryption fails. + * @throws IdentityVerificationException Thrown when identity verification status fails. + * @throws RemoteCommunicationException Thrown when communication with PowerAuth server fails. + * @throws OnboardingProcessException Thrown when onboarding process is invalid. + * @throws OnboardingOtpDeliveryException Thrown when OTP could not be sent when changing status. + */ + @RequestMapping(value = "status", method = RequestMethod.POST) + @PowerAuthEncryption(scope = EciesScope.ACTIVATION_SCOPE) + @PowerAuthToken(signatureType = { + PowerAuthSignatureTypes.POSSESSION + }) + public ObjectResponse checkIdentityVerificationStatus(@EncryptedRequestBody ObjectRequest request, + @Parameter(hidden = true) EciesEncryptionContext eciesContext, + @Parameter(hidden = true) PowerAuthApiAuthentication apiAuthentication) + throws PowerAuthAuthenticationException, PowerAuthEncryptionException, IdentityVerificationException, RemoteCommunicationException, OnboardingProcessException, OnboardingOtpDeliveryException { + // Check if the authentication object is present + if (apiAuthentication == null) { + logger.error("Unable to verify device registration when checking identity verification status"); + throw new PowerAuthTokenInvalidException("Unable to verify device registration when checking identity verification status"); + } + + // Check if the request was correctly decrypted + if (eciesContext == null) { + logger.error("ECIES encryption failed when checking identity verification status"); + throw new PowerAuthEncryptionException("ECIES decryption failed when checking identity verification status"); + } + + if (request == null || request.getRequestObject() == null) { + logger.error("Invalid request received when checking identity verification status"); + throw new PowerAuthEncryptionException("Invalid request received when checking identity verification status"); + } + + final OwnerId ownerId = PowerAuthUtil.getOwnerId(apiAuthentication); + + // Check verification status + final IdentityVerificationStatusResponse response = + identityVerificationStatusService.checkIdentityVerificationStatus(request.getRequestObject(), ownerId); + response.setConfig(integrationConfigDto); + + return new ObjectResponse<>(response); + } + + /** + * Submit identity-related documents for verification. + * @param request Document submit request. + * @param eciesContext ECIES context. + * @return Document submit response. + * @throws PowerAuthAuthenticationException Thrown when request authentication fails. + * @throws PowerAuthEncryptionException Thrown when request decryption fails. + * @throws DocumentSubmitException Thrown when document submission fails. + * @throws OnboardingProcessException Thrown when onboarding process is invalid. + */ + @RequestMapping(value = "document/submit", method = RequestMethod.POST) + @PowerAuthEncryption(scope = EciesScope.ACTIVATION_SCOPE) + @PowerAuthToken(signatureType = { + PowerAuthSignatureTypes.POSSESSION + }) + public ObjectResponse submitDocuments(@EncryptedRequestBody ObjectRequest request, + @Parameter(hidden = true) EciesEncryptionContext eciesContext, + @Parameter(hidden = true) PowerAuthApiAuthentication apiAuthentication) + throws PowerAuthAuthenticationException, PowerAuthEncryptionException, DocumentSubmitException, OnboardingProcessException { + // Check if the authentication object is present + if (apiAuthentication == null) { + logger.error("Unable to verify device registration when checking document verification status"); + throw new PowerAuthTokenInvalidException("Unable to verify device registration when checking document verification status"); + } + // Check if the request was correctly decrypted + if (eciesContext == null) { + logger.error("ECIES encryption failed when submitting documents for verification"); + throw new PowerAuthEncryptionException("ECIES encryption failed when submitting documents for verification"); + } + + if (request == null || request.getRequestObject() == null) { + logger.error("Invalid request received when submitting documents for verification"); + throw new PowerAuthEncryptionException("Invalid request received when submitting documents for verification"); + } + + // Extract user ID from onboarding process for current activation + final OwnerId ownerId = extractOwnerId(eciesContext); + final String processId = request.getRequestObject().getProcessId(); + + onboardingService.verifyProcessId(ownerId, processId); + + // Submit documents for verification + final List docVerificationEntities = + identityVerificationService.submitDocuments(request.getRequestObject(), ownerId); + + final DocumentSubmitResponse response = new DocumentSubmitResponse(); + final List respsMetadata = + identityVerificationService.createDocsMetadata(docVerificationEntities); + response.setDocuments(respsMetadata); + + return new ObjectResponse<>(response); + } + + /** + * Upload a single document related to identity verification. This endpoint is used for upload of large documents. + * @param requestData Binary request data. + * @param eciesContext ECIES context. + * @return Document upload response. + * @throws PowerAuthAuthenticationException Thrown when request authentication fails. + * @throws PowerAuthEncryptionException Thrown when request decryption fails. + * @throws DocumentVerificationException Thrown when document is invalid. + * @throws OnboardingProcessException Thrown when finished onboarding process is not found. + */ + @RequestMapping(value = "document/upload", method = RequestMethod.POST) + @PowerAuthEncryption(scope = EciesScope.ACTIVATION_SCOPE) + @PowerAuthToken(signatureType = { + PowerAuthSignatureTypes.POSSESSION + }) + public ObjectResponse uploadDocument(@EncryptedRequestBody byte[] requestData, + @Parameter(hidden = true) EciesEncryptionContext eciesContext, + @Parameter(hidden = true) PowerAuthApiAuthentication apiAuthentication) + throws IdentityVerificationNotFoundException, PowerAuthAuthenticationException, PowerAuthEncryptionException, DocumentVerificationException, OnboardingProcessException { + // Check if the authentication object is present + if (apiAuthentication == null) { + logger.error("Unable to verify device registration when checking document verification status"); + throw new PowerAuthTokenInvalidException("Unable to verify device registration when checking document verification status"); + } + // Check if the request was correctly decrypted + if (eciesContext == null) { + logger.error("ECIES encryption failed when uploading document for verification"); + throw new PowerAuthEncryptionException("ECIES encryption failed when uploading document for verification"); + } + + if (requestData == null) { + logger.error("Invalid request received when uploading document for verification"); + throw new PowerAuthEncryptionException("Invalid request received when uploading document for verification"); + } + + // Extract user ID from finished onboarding process for current activation + final OnboardingProcessEntity onboardingProcess = onboardingService.findExistingProcessWithVerificationInProgress(eciesContext.getActivationId()); + final OwnerId ownerId = new OwnerId(); + ownerId.setActivationId(onboardingProcess.getActivationId()); + ownerId.setUserId(onboardingProcess.getUserId()); + + IdentityVerificationEntity idVerification = findIdentityVerification(ownerId); + + final DocumentMetadata uploadedDocument = documentProcessingService.uploadDocument(idVerification, requestData, ownerId); + + final DocumentUploadResponse response = new DocumentUploadResponse(); + response.setFilename(uploadedDocument.getFilename()); + response.setId(uploadedDocument.getId()); + + return new ObjectResponse<>(response); + } + + /** + * Check status of document verification related to identity. + * @param request Document status request. + * @param eciesContext ECIES context. + * @param apiAuthentication PowerAuth authentication. + * @return Document status response. + * @throws PowerAuthAuthenticationException Thrown when request authentication fails. + * @throws PowerAuthEncryptionException Thrown when request decryption fails. + * @throws IdentityVerificationException Thrown when identity verification fails. + * @throws OnboardingProcessException Thrown when onboarding process identifier is invalid. + */ + @RequestMapping(value = "document/status", method = RequestMethod.POST) + @PowerAuthEncryption(scope = EciesScope.ACTIVATION_SCOPE) + @PowerAuthToken(signatureType = { + PowerAuthSignatureTypes.POSSESSION + }) + public ObjectResponse checkDocumentStatus(@EncryptedRequestBody ObjectRequest request, + @Parameter(hidden = true) EciesEncryptionContext eciesContext, + @Parameter(hidden = true) PowerAuthApiAuthentication apiAuthentication) + throws PowerAuthAuthenticationException, PowerAuthEncryptionException, IdentityVerificationException, OnboardingProcessException { + // Check if the authentication object is present + if (apiAuthentication == null) { + logger.error("Unable to verify device registration when checking document verification status"); + throw new PowerAuthTokenInvalidException("Unable to verify device registration when checking document verification status"); + } + + // Check if the request was correctly decrypted + if (eciesContext == null) { + logger.error("ECIES encryption failed when checking document verification status"); + throw new PowerAuthEncryptionException("ECIES encryption failed when checking document verification status"); + } + + if (request == null || request.getRequestObject() == null) { + logger.error("Invalid request received when checking document verification status"); + throw new PowerAuthEncryptionException("Invalid request received when checking document verification status"); + } + + final OwnerId ownerId = PowerAuthUtil.getOwnerId(apiAuthentication); + final String processId = request.getRequestObject().getProcessId(); + + onboardingService.verifyProcessId(ownerId, processId); + + // Process upload document request + final DocumentStatusResponse response = identityVerificationService.checkIdentityVerificationStatus(request.getRequestObject(), ownerId); + return new ObjectResponse<>(response); + } + + /** + * Submit identity-related documents for verification. + * @param request Presence check initialization request. + * @param eciesContext ECIES context. + * @param apiAuthentication PowerAuth authentication. + * @return Document submit response. + * @throws PowerAuthAuthenticationException Thrown when request authentication fails. + * @throws PowerAuthEncryptionException Thrown when request decryption fails. + * @throws OnboardingProcessException Thrown when onboarding process identifier is invalid. + */ + @RequestMapping(value = "presence-check/init", method = RequestMethod.POST) + @PowerAuthEncryption(scope = EciesScope.ACTIVATION_SCOPE) + @PowerAuth(resourceId = "/api/identity/presence-check/init", signatureType = { + PowerAuthSignatureTypes.POSSESSION + }) + public ObjectResponse initPresenceCheck(@EncryptedRequestBody ObjectRequest request, + @Parameter(hidden = true) EciesEncryptionContext eciesContext, + @Parameter(hidden = true) PowerAuthApiAuthentication apiAuthentication) + throws PowerAuthAuthenticationException, DocumentVerificationException, PresenceCheckException, + PresenceCheckNotEnabledException, PowerAuthEncryptionException, OnboardingProcessException { + + // Check if the authentication object is present + if (apiAuthentication == null) { + logger.error("Unable to verify device registration when initializing presence check"); + throw new PowerAuthAuthenticationException("Unable to verify device registration when initializing presence check"); + } + + // Check if the request was correctly decrypted + if (eciesContext == null) { + logger.error("ECIES encryption failed when initializing presence check"); + throw new PowerAuthEncryptionException("ECIES encryption failed when initializing presence check"); + } + + if (request == null || request.getRequestObject() == null) { + logger.error("Invalid request received when initializing presence check"); + throw new PowerAuthEncryptionException("Invalid request received when initializing presence check"); + } + + if (!identityVerificationConfig.isPresenceCheckEnabled()) { + throw new PresenceCheckNotEnabledException(); + } + + final OwnerId ownerId = PowerAuthUtil.getOwnerId(apiAuthentication); + final String processId = request.getRequestObject().getProcessId(); + + onboardingService.verifyProcessId(ownerId, processId); + + final SessionInfo sessionInfo = presenceCheckService.init(ownerId, processId); + + final PresenceCheckInitResponse response = new PresenceCheckInitResponse(); + response.setSessionAttributes(sessionInfo.getSessionAttributes()); + return new ObjectResponse<>(response); + } + + /** + * Resend OTP code to the user. + * @param request Presence check initialization request. + * @param eciesContext ECIES context. + * @return Send OTP response. + * @throws PowerAuthEncryptionException Thrown when request decryption fails. + * @throws OnboardingProcessException Thrown when OTP code could not be generated. + * @throws OnboardingOtpDeliveryException Thrown when OTP code could not be sent. + */ + @RequestMapping(value = "otp/resend", method = RequestMethod.POST) + @PowerAuthEncryption(scope = EciesScope.ACTIVATION_SCOPE) + public Response resendOtp(@EncryptedRequestBody ObjectRequest request, + @Parameter(hidden = true) EciesEncryptionContext eciesContext) + throws IdentityVerificationException, PowerAuthEncryptionException, OnboardingProcessException, OnboardingOtpDeliveryException { + + // Check if the request was correctly decrypted + if (eciesContext == null) { + logger.error("ECIES encryption failed when sending OTP during identity verification"); + throw new PowerAuthEncryptionException("ECIES encryption failed when sending OTP during identity verification"); + } + + if (request == null || request.getRequestObject() == null) { + logger.error("Invalid request received when sending OTP during identity verification"); + throw new PowerAuthEncryptionException("Invalid request received when sending OTP during identity verification"); + } + + final OwnerId ownerId = extractOwnerId(eciesContext); + final String processId = request.getRequestObject().getProcessId(); + onboardingService.verifyProcessId(ownerId, processId); + + IdentityVerificationEntity identityVerification = findIdentityVerification(ownerId); + identityVerificationOtpService.resendOtp(ownerId, identityVerification); + return new Response(); + } + + /** + * Verify an OTP code received from the user. + * @param request Presence check initialization request. + * @param eciesContext ECIES context. + * @return Send OTP response. + * @throws PowerAuthEncryptionException Thrown when request decryption fails. + * @throws OnboardingProcessException Thrown when onboarding process is not found. + */ + @RequestMapping(value = "otp/verify", method = RequestMethod.POST) + @PowerAuthEncryption(scope = EciesScope.ACTIVATION_SCOPE) + public ObjectResponse verifyOtp(@EncryptedRequestBody ObjectRequest request, + @Parameter(hidden = true) EciesEncryptionContext eciesContext) + throws PowerAuthEncryptionException, OnboardingProcessException { + + // Check if the request was correctly decrypted + if (eciesContext == null) { + logger.error("ECIES encryption failed when verifying OTP during identity verification"); + throw new PowerAuthEncryptionException("ECIES encryption failed when sending OTP during identity verification"); + } + + if (request == null || request.getRequestObject() == null) { + logger.error("Invalid request received when verifying OTP during identity verification"); + throw new PowerAuthEncryptionException("Invalid request received when sending OTP during identity verification"); + } + + final OwnerId ownerId = extractOwnerId(eciesContext); + final String processId = request.getRequestObject().getProcessId(); + + onboardingService.verifyProcessId(ownerId, processId); + + final String otpCode = request.getRequestObject().getOtpCode(); + return new ObjectResponse<>(identityVerificationOtpService.verifyOtpCode(processId, otpCode)); + } + + /** + * Cleanup documents related to identity verification. + * @param apiAuthentication PowerAuth authentication. + * @return Document status response. + * @throws PowerAuthAuthenticationException Thrown when PowerAuth signature verification fails. + * @throws PowerAuthEncryptionException Thrown when request decryption fails. + * @throws DocumentVerificationException Thrown when document cleanup fails + * @throws PresenceCheckException Thrown when presence check cleanup fails. + * @throws RemoteCommunicationException Thrown when communication with PowerAuth server fails. + * @throws OnboardingProcessException Thrown when onboarding process identifier is invalid. + */ + @RequestMapping(value = "cleanup", method = RequestMethod.POST) + @PowerAuthEncryption(scope = EciesScope.ACTIVATION_SCOPE) + @PowerAuth(resourceId = "/api/identity/cleanup", signatureType = { + PowerAuthSignatureTypes.POSSESSION + }) + public Response cleanup(@EncryptedRequestBody ObjectRequest request, + @Parameter(hidden = true) EciesEncryptionContext eciesContext, + @Parameter(hidden = true) PowerAuthApiAuthentication apiAuthentication) + throws PowerAuthAuthenticationException, PowerAuthEncryptionException, DocumentVerificationException, PresenceCheckException, RemoteCommunicationException, OnboardingProcessException { + // Check if the authentication object is present + if (apiAuthentication == null) { + logger.error("Unable to verify device registration when performing document cleanup"); + throw new PowerAuthAuthenticationException("Unable to verify device registration when performing document cleanup"); + } + + // Check if the request was correctly decrypted + if (eciesContext == null) { + logger.error("ECIES encryption failed when performing document cleanup"); + throw new PowerAuthEncryptionException("ECIES encryption failed when performing document cleanup"); + } + + if (request == null || request.getRequestObject() == null) { + logger.error("Invalid request received when performing document cleanup"); + throw new PowerAuthEncryptionException("Invalid request received when performing document cleanup"); + } + + final OwnerId ownerId = PowerAuthUtil.getOwnerId(apiAuthentication); + final String processId = request.getRequestObject().getProcessId(); + + onboardingService.verifyProcessId(ownerId, processId); + + // Process cleanup request + identityVerificationService.cleanup(ownerId); + if (identityVerificationConfig.isPresenceCheckEnabled()) { + presenceCheckService.cleanup(ownerId); + } else { + logger.debug("Skipped presence check cleanup, not enabled"); + } + + return new Response(); + } + + /** + * Extract owner identification from an ECIES context. + * @param eciesContext ECIES context. + * @return Owner identification. + */ + private OwnerId extractOwnerId(EciesEncryptionContext eciesContext) throws OnboardingProcessException { + final OnboardingProcessEntity onboardingProcess = onboardingService.findExistingProcessWithVerificationInProgress(eciesContext.getActivationId()); + final OwnerId ownerId = new OwnerId(); + ownerId.setActivationId(onboardingProcess.getActivationId()); + ownerId.setUserId(onboardingProcess.getUserId()); + return ownerId; + } + + private IdentityVerificationEntity findIdentityVerification(OwnerId ownerId) throws IdentityVerificationNotFoundException { + Optional identityVerificationOptional = identityVerificationService.findBy(ownerId); + + if (!identityVerificationOptional.isPresent()) { + logger.error("No identity verification entity found, {}", ownerId); + throw new IdentityVerificationNotFoundException("Not existing identity verification"); + } + return identityVerificationOptional.get(); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/OnboardingController.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/OnboardingController.java new file mode 100644 index 000000000..a1dfe880d --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/OnboardingController.java @@ -0,0 +1,186 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.controller.api; + +import com.wultra.app.enrollmentserver.errorhandling.OnboardingOtpDeliveryException; +import com.wultra.app.enrollmentserver.errorhandling.OnboardingProcessException; +import com.wultra.app.enrollmentserver.errorhandling.TooManyProcessesException; +import com.wultra.app.enrollmentserver.impl.service.OnboardingService; +import com.wultra.app.enrollmentserver.api.model.request.OnboardingCleanupRequest; +import com.wultra.app.enrollmentserver.api.model.request.OnboardingStartRequest; +import com.wultra.app.enrollmentserver.api.model.request.OnboardingStatusRequest; +import com.wultra.app.enrollmentserver.api.model.request.OnboardingOtpResendRequest; +import com.wultra.app.enrollmentserver.api.model.response.OnboardingStartResponse; +import com.wultra.app.enrollmentserver.api.model.response.OnboardingStatusResponse; +import io.getlime.core.rest.model.base.request.ObjectRequest; +import io.getlime.core.rest.model.base.response.ObjectResponse; +import io.getlime.core.rest.model.base.response.Response; +import io.getlime.security.powerauth.crypto.lib.encryptor.ecies.model.EciesScope; +import io.getlime.security.powerauth.rest.api.spring.encryption.EciesEncryptionContext; +import io.getlime.security.powerauth.rest.api.spring.annotation.EncryptedRequestBody; +import io.getlime.security.powerauth.rest.api.spring.annotation.PowerAuthEncryption; +import io.getlime.security.powerauth.rest.api.spring.exception.PowerAuthEncryptionException; +import io.swagger.v3.oas.annotations.Parameter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +/** + * Controller publishing REST services for the onboarding process. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@ConditionalOnProperty( + value = "enrollment-server.onboarding-process.enabled", + havingValue = "true" +) +@RestController +@RequestMapping(value = "api/onboarding") +public class OnboardingController { + + private static final Logger logger = LoggerFactory.getLogger(OnboardingController.class); + + private final OnboardingService onboardingService; + + /** + * Controller constructor. + * @param onboardingService Onboarding service. + */ + @Autowired + public OnboardingController(OnboardingService onboardingService) { + this.onboardingService = onboardingService; + } + + /** + * Start an onboarding process. + * + * @param request Start onboarding process request. + * @param eciesContext ECIES context. + * @return Start onboarding process response. + * @throws PowerAuthEncryptionException Thrown when request is invalid. + * @throws OnboardingProcessException Thrown in case onboarding process fails. + * @throws OnboardingOtpDeliveryException Thrown in case onboarding OTP delivery fails. + * @throws TooManyProcessesException Thrown in case too many onboarding processes are started. + */ + @RequestMapping(value = "start", method = RequestMethod.POST) + @PowerAuthEncryption(scope = EciesScope.APPLICATION_SCOPE) + public ObjectResponse startOnboarding(@EncryptedRequestBody ObjectRequest request, + @Parameter(hidden = true) EciesEncryptionContext eciesContext) throws OnboardingProcessException, OnboardingOtpDeliveryException, PowerAuthEncryptionException, TooManyProcessesException { + // Check if the request was correctly decrypted + if (eciesContext == null) { + logger.error("ECIES encryption failed during onboarding"); + throw new PowerAuthEncryptionException("ECIES decryption failed during onboarding"); + } + + if (request == null || request.getRequestObject() == null) { + logger.error("Invalid request received during onboarding"); + throw new PowerAuthEncryptionException("Invalid request received during onboarding"); + } + + OnboardingStartResponse response = onboardingService.startOnboarding(request.getRequestObject()); + return new ObjectResponse<>(response); + } + + /** + * Resend an onboarding OTP code. + * + * @param request Resend an OTP code request. + * @param eciesContext ECIES context. + * @return Response. + * @throws PowerAuthEncryptionException Thrown when request decryption fails. + * @throws OnboardingProcessException Thrown when onboarding process fails. + * @throws OnboardingOtpDeliveryException Thrown when onboarding OTP delivery fails. + */ + @RequestMapping(value = "otp/resend", method = RequestMethod.POST) + @PowerAuthEncryption(scope = EciesScope.APPLICATION_SCOPE) + public Response resendOtp(@EncryptedRequestBody ObjectRequest request, + @Parameter(hidden = true) EciesEncryptionContext eciesContext) throws PowerAuthEncryptionException, OnboardingProcessException, OnboardingOtpDeliveryException { + // Check if the request was correctly decrypted + if (eciesContext == null) { + logger.error("ECIES encryption failed during onboarding"); + throw new PowerAuthEncryptionException("ECIES decryption failed while resending OTP code"); + } + + if (request == null || request.getRequestObject() == null) { + logger.error("Invalid request received during onboarding"); + throw new PowerAuthEncryptionException("Invalid request received while resending OTP code"); + } + + return onboardingService.resendOtp(request.getRequestObject()); + } + + /** + * Get onboarding process status. + * + * @param request Onboarding status request. + * @param eciesContext ECIES context. + * @return Onboarding status response. + * @throws PowerAuthEncryptionException Thrown when request decryption fails. + * @throws OnboardingProcessException Thrown when onboarding process is not found. + */ + @RequestMapping(value = "status", method = RequestMethod.POST) + @PowerAuthEncryption(scope = EciesScope.APPLICATION_SCOPE) + public ObjectResponse getStatus(@EncryptedRequestBody ObjectRequest request, + @Parameter(hidden = true) EciesEncryptionContext eciesContext) throws PowerAuthEncryptionException, OnboardingProcessException { + // Check if the request was correctly decrypted + if (eciesContext == null) { + logger.error("ECIES encryption failed during onboarding"); + throw new PowerAuthEncryptionException("ECIES decryption failed while getting status"); + } + + if (request == null || request.getRequestObject() == null) { + logger.error("Invalid request received during onboarding"); + throw new PowerAuthEncryptionException("Invalid request received while getting status"); + } + + OnboardingStatusResponse response = onboardingService.getStatus(request.getRequestObject()); + return new ObjectResponse<>(response); + } + + /** + * Perform cleanup related to an onboarding process. + * + * @param request Onboarding cleanup request. + * @param eciesContext ECIES context. + * @return Onboarding cleanup response. + * @throws PowerAuthEncryptionException Thrown when request decryption fails. + * @throws OnboardingProcessException Thrown when onboarding process is not found. + */ + @RequestMapping(value = "cleanup", method = RequestMethod.POST) + @PowerAuthEncryption(scope = EciesScope.APPLICATION_SCOPE) + public Response performCleanup(@EncryptedRequestBody ObjectRequest request, + @Parameter(hidden = true) EciesEncryptionContext eciesContext) throws PowerAuthEncryptionException, OnboardingProcessException { + // Check if the request was correctly decrypted + if (eciesContext == null) { + logger.error("ECIES encryption failed during onboarding"); + throw new PowerAuthEncryptionException("ECIES decryption failed during onboarding"); + } + + if (request == null || request.getRequestObject() == null) { + logger.error("Invalid request received during onboarding"); + throw new PowerAuthEncryptionException("Invalid request received during onboarding"); + } + + return onboardingService.performCleanup(request.getRequestObject()); + } + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/PushRegistrationController.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/PushRegistrationController.java index db2895760..f56e6c513 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/PushRegistrationController.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/controller/api/PushRegistrationController.java @@ -19,7 +19,7 @@ import com.wultra.app.enrollmentserver.impl.service.PushRegistrationService; import com.wultra.app.enrollmentserver.impl.util.ConditionalOnPropertyNotEmpty; -import com.wultra.app.enrollmentserver.model.request.PushRegisterRequest; +import com.wultra.app.enrollmentserver.api.model.request.PushRegisterRequest; import com.wultra.app.enrollmentserver.errorhandling.InvalidRequestObjectException; import com.wultra.app.enrollmentserver.errorhandling.PushRegistrationFailedException; import io.getlime.core.rest.model.base.request.ObjectRequest; diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/DocumentDataRepository.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/DocumentDataRepository.java new file mode 100644 index 000000000..070f17835 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/DocumentDataRepository.java @@ -0,0 +1,44 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.database; + +import com.wultra.app.enrollmentserver.database.entity.DocumentDataEntity; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.Date; + +/** + * Repository for document data records. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Repository +public interface DocumentDataRepository extends CrudRepository { + + @Modifying + int deleteAllByActivationId(String activationId); + + @Modifying + @Query("DELETE FROM DocumentDataEntity d WHERE d.timestampCreated < :dateCleanup") + int cleanupDocumentData(Date dateCleanup); + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/DocumentResultRepository.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/DocumentResultRepository.java new file mode 100644 index 000000000..15d6069d7 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/DocumentResultRepository.java @@ -0,0 +1,65 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.database; + +import com.wultra.app.enrollmentserver.database.entity.DocumentResultEntity; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentProcessingPhase; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.stream.Stream; + +/** + * Repository for document verification result records. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Repository +public interface DocumentResultRepository extends CrudRepository { + + /** + * @return All not finished document uploads (upload is in progress and no extracted data filled) + */ + @Query("SELECT doc FROM DocumentResultEntity doc WHERE" + + " doc.documentVerification.status = com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus.UPLOAD_IN_PROGRESS" + + " AND doc.extractedData IS NULL " + + " ORDER BY doc.timestampCreated ASC") + Stream streamAllInProgressDocumentSubmits(); + + /** + * @return All not finished document submit verifications (upload is in progress and verification id exists) + */ + @Query("SELECT doc FROM DocumentResultEntity doc WHERE" + + " doc.documentVerification.status = com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus.UPLOAD_IN_PROGRESS" + + " AND doc.documentVerification.verificationId IS NOT NULL" + + " ORDER BY doc.timestampCreated ASC") + Stream streamAllInProgressDocumentSubmitVerifications(); + + /** + * @return All document results for the specified document verification and processing phase + */ + @Query("SELECT doc FROM DocumentResultEntity doc WHERE" + + " doc.documentVerification.id = :docVerificationId" + + " AND doc.phase = :phase" + + " ORDER BY doc.timestampCreated DESC") + List findLatestResults(String docVerificationId, DocumentProcessingPhase phase); + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/DocumentVerificationRepository.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/DocumentVerificationRepository.java new file mode 100644 index 000000000..22647965f --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/DocumentVerificationRepository.java @@ -0,0 +1,101 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.database; + +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.database.entity.IdentityVerificationEntity; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Date; +import java.util.List; + +/** + * Repository for document verification records. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Repository +public interface DocumentVerificationRepository extends JpaRepository { + + @Modifying + @Query("UPDATE DocumentVerificationEntity d " + + "SET d.status = com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus.FAILED, " + + " d.usedForVerification = false, " + + " d.timestampLastUpdated = :timestamp " + + "WHERE d.activationId = :activationId " + + "AND d.status IN (:statuses)") + int failVerifications(String activationId, Date timestamp, List statuses); + + @Modifying + @Query("UPDATE DocumentVerificationEntity d " + + "SET d.status = com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus.FAILED, " + + " d.usedForVerification = false, " + + " d.errorDetail = :errorMessage, " + + " d.timestampLastUpdated = :timestamp " + + "WHERE d.timestampLastUpdated < :cleanupDate " + + "AND d.status IN (:statuses)") + int failObsoleteVerifications(Date cleanupDate, Date timestamp, String errorMessage, List statuses); + + @Modifying + @Query("UPDATE DocumentVerificationEntity d " + + "SET d.otherSideId = :otherSideId " + + "WHERE d.id = :id") + int setOtherDocumentSide(String id, String otherSideId); + + @Modifying + @Query("UPDATE DocumentVerificationEntity d " + + "SET d.status = com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus.VERIFICATION_PENDING, " + + " d.timestampLastUpdated = :timestamp " + + "WHERE d.identityVerification = :identityVerification") + int setVerificationPending(IdentityVerificationEntity identityVerification, Date timestamp); + + @Query("SELECT d " + + "FROM DocumentVerificationEntity d " + + "WHERE d.identityVerification = :identityVerification " + + "AND d.status IN (:statuses)") + List findAllDocumentVerifications(IdentityVerificationEntity identityVerification, List statuses); + + @Query("SELECT d " + + "FROM DocumentVerificationEntity d " + + "WHERE d.identityVerification = :identityVerification " + + "AND d.usedForVerification = true") + List findAllUsedForVerification(IdentityVerificationEntity identityVerification); + + /** + * Find all upload identifiers related to a verification. + * @param verificationId Identification of the verification at the provider side. + * @return List of remote uploadIds related to the specified verification id + */ + @Query("SELECT d.uploadId " + + "FROM DocumentVerificationEntity d " + + "WHERE d.verificationId = :verificationId") + List findAllUploadIds(String verificationId); + + @Query("SELECT d " + + "FROM DocumentVerificationEntity d " + + "WHERE d.identityVerification = :identityVerification " + + "AND d.photoId IS NOT NULL " + + "AND d.usedForVerification = true") + List findAllWithPhoto(IdentityVerificationEntity identityVerification); + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/IdentityVerificationRepository.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/IdentityVerificationRepository.java new file mode 100644 index 000000000..ef2231b88 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/IdentityVerificationRepository.java @@ -0,0 +1,54 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.database; + +import com.wultra.app.enrollmentserver.database.entity.IdentityVerificationEntity; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.Date; +import java.util.Optional; + +/** + * Repository for identity verification records. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Repository +public interface IdentityVerificationRepository extends CrudRepository { + + @Modifying + @Query("UPDATE IdentityVerificationEntity i " + + "SET i.status = com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus.FAILED, " + + " i.timestampLastUpdated = :timestamp " + + "WHERE i.activationId = :activationId " + + "AND i.status IN (" + + "com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus.IN_PROGRESS, " + + "com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus.VERIFICATION_PENDING, " + + "com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus.OTP_VERIFICATION_PENDING" + + ")") + int failRunningVerifications(String activationId, Date timestamp); + + Optional findFirstByActivationIdOrderByTimestampCreatedDesc( + String activationId + ); + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/OnboardingOtpRepository.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/OnboardingOtpRepository.java new file mode 100644 index 000000000..1b3750ffb --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/OnboardingOtpRepository.java @@ -0,0 +1,60 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.database; + +import com.wultra.app.enrollmentserver.database.entity.OnboardingOtpEntity; +import com.wultra.app.enrollmentserver.model.enumeration.OtpType; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.Date; +import java.util.Optional; + +/** + * Repository for onboarding OTP codes. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Repository +public interface OnboardingOtpRepository extends CrudRepository { + + @Query("SELECT o FROM OnboardingOtpEntity o WHERE o.process.id = :processId AND o.type = :type AND o.timestampCreated = " + + "(SELECT MAX(o2.timestampCreated) FROM OnboardingOtpEntity o2 WHERE o2.process.id = :processId AND o2.type = :type)") + Optional findLastOtp(String processId, OtpType type); + + @Modifying + @Query("UPDATE OnboardingOtpEntity o SET o.status = com.wultra.app.enrollmentserver.model.enumeration.OtpStatus.FAILED, " + + "o.timestampLastUpdated = CURRENT_TIMESTAMP, " + + "o.errorDetail = 'expired' " + + "WHERE o.status = com.wultra.app.enrollmentserver.model.enumeration.OtpStatus.ACTIVE " + + "AND o.timestampCreated < :dateCreatedBefore") + void terminateOldOtps(Date dateCreatedBefore); + + @Query("SELECT SUM(o.failedAttempts) FROM OnboardingOtpEntity o WHERE o.process.id = :processId AND o.type = :type") + int getFailedAttemptsByProcess(String processId, OtpType type); + + @Query("SELECT MAX(o.timestampCreated) FROM OnboardingOtpEntity o WHERE o.process.id = :processId AND o.type = :type") + Date getNewestOtpCreatedTimestamp(String processId, OtpType type); + + @Query("SELECT COUNT(o.id) FROM OnboardingOtpEntity o WHERE o.process.id = :processId AND o.type = :type") + int getOtpCount(String processId, OtpType type); + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/OnboardingProcessRepository.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/OnboardingProcessRepository.java new file mode 100644 index 000000000..8d0cf49f2 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/OnboardingProcessRepository.java @@ -0,0 +1,67 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.database; + +import com.wultra.app.enrollmentserver.database.entity.OnboardingProcessEntity; +import com.wultra.app.enrollmentserver.model.enumeration.OnboardingStatus; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.Date; +import java.util.Optional; + +/** + * Repository for onboarding processes. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Repository +public interface OnboardingProcessRepository extends CrudRepository { + + Optional findById(String processId); + + @Query("SELECT p FROM OnboardingProcessEntity p WHERE p.status = :status " + + "AND p.userId = :userId " + + "ORDER BY p.timestampCreated DESC") + Optional findExistingProcessForUser(String userId, OnboardingStatus status); + + @Query("SELECT p FROM OnboardingProcessEntity p WHERE p.status = :status " + + "AND p.activationId = :activationId " + + "ORDER BY p.timestampCreated DESC") + Optional findExistingProcessForActivation(String activationId, OnboardingStatus status); + + @Query("SELECT p FROM OnboardingProcessEntity p " + + "WHERE p.activationId = :activationId " + + "ORDER BY p.timestampCreated DESC") + Optional findProcessByActivationId(String activationId); + + @Query("SELECT count(p) FROM OnboardingProcessEntity p WHERE p.userId = :userId AND p.timestampCreated > :dateAfter") + int countProcessesAfterTimestamp(String userId, Date dateAfter); + + @Modifying + @Query("UPDATE OnboardingProcessEntity p SET p.status = com.wultra.app.enrollmentserver.model.enumeration.OnboardingStatus.FAILED, " + + "p.timestampLastUpdated = CURRENT_TIMESTAMP, " + + "p.errorDetail = 'expired' " + + "WHERE p.status = :status " + + "AND p.timestampCreated < :dateCreatedBefore") + void terminateOldProcesses(Date dateCreatedBefore, OnboardingStatus status); + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/OperationTemplateRepository.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/OperationTemplateRepository.java index 848313487..ba62ab4fc 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/OperationTemplateRepository.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/OperationTemplateRepository.java @@ -18,7 +18,7 @@ package com.wultra.app.enrollmentserver.database; -import com.wultra.app.enrollmentserver.database.entity.OperationTemplate; +import com.wultra.app.enrollmentserver.database.entity.OperationTemplateEntity; import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; @@ -30,8 +30,8 @@ * @author Petr Dvorak, petr@wultra.com */ @Repository -public interface OperationTemplateRepository extends CrudRepository { +public interface OperationTemplateRepository extends CrudRepository { - Optional findFirstByLanguageAndPlaceholder(String language, String placeholder); + Optional findFirstByLanguageAndPlaceholder(String language, String placeholder); } diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/DocumentDataEntity.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/DocumentDataEntity.java new file mode 100644 index 000000000..051e617ea --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/DocumentDataEntity.java @@ -0,0 +1,86 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.database.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; +import java.io.Serializable; +import java.util.Date; +import java.util.Objects; + +/** + * Entity representing document data. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Getter +@Setter +@ToString +@NoArgsConstructor +@Entity +@Table(name = "es_document_data") +public class DocumentDataEntity implements Serializable { + + private static final long serialVersionUID = 7685715667785423079L; + + @Id + @GeneratedValue(generator = "uuid") + @GenericGenerator(name = "uuid", strategy = "uuid2") + @Column(name = "id", nullable = false) + private String id; + + @Column(name = "activation_id", nullable = false) + private String activationId; + + /** + * Identifier of the related identity verification entity + */ + @ManyToOne + @JoinColumn(name = "identity_verification_id", referencedColumnName = "id", updatable = false, nullable = false) + private IdentityVerificationEntity identityVerification; + + @Column(name = "filename", nullable = false) + private String filename; + + @Column(name = "data", nullable = false) + private byte[] data; + + @Column(name = "timestamp_created", nullable = false) + private Date timestampCreated; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof DocumentDataEntity)) return false; + DocumentDataEntity that = (DocumentDataEntity) o; + return filename.equals(that.filename) && timestampCreated.equals(that.timestampCreated); + } + + @Override + public int hashCode() { + return Objects.hash(filename, timestampCreated); + } + +} + diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/DocumentResultEntity.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/DocumentResultEntity.java new file mode 100644 index 000000000..58e1ee92d --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/DocumentResultEntity.java @@ -0,0 +1,113 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.database.entity; + +import com.wultra.app.enrollmentserver.model.enumeration.DocumentProcessingPhase; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import javax.persistence.*; +import java.io.Serializable; +import java.util.Date; +import java.util.Objects; + +/** + * Entity representing a document verification record. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Getter +@Setter +@ToString(of = {"id", "phase", "documentVerification"}) +@NoArgsConstructor +@Entity +@Table(name = "es_document_result") +public class DocumentResultEntity implements Serializable { + + private static final long serialVersionUID = -760284276164288362L; + + /** + * Autogenerated identifier + */ + @Id + @SequenceGenerator(name = "es_document_result", sequenceName = "es_document_result_seq", allocationSize = 10) + @GeneratedValue(strategy = GenerationType.AUTO, generator = "es_document_result") + @Column(name = "id", nullable = false) + private Long id; + + /** + * Identifier of the related document verification entity + */ + @ManyToOne + @JoinColumn(name = "document_verification_id", referencedColumnName = "id", nullable = false) + private DocumentVerificationEntity documentVerification; + + /** + * Phase of processing + */ + @Enumerated(EnumType.STRING) + @Column(name = "phase", nullable = false) + private DocumentProcessingPhase phase; + + /** + * Reason why the document was rejected + */ + @Column(name = "reject_reason") + private String rejectReason; + + /** + * JSON serialized document with the verification result + */ + @Column(name = "verification_result") + private String verificationResult; + + /** + * JSON serialized errors which occurred during document processing + */ + @Column(name = "error_detail") + private String errorDetail; + + /** + * JSON serialized data extracted from the uploaded document + */ + @Column(name = "extracted_data") + private String extractedData; + + /** + * Timestamp when the entity was created + */ + @Column(name = "timestamp_created", nullable = false) + private Date timestampCreated; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof DocumentResultEntity)) return false; + DocumentResultEntity that = (DocumentResultEntity) o; + return documentVerification.equals(that.documentVerification) && phase == that.phase && timestampCreated.equals(that.timestampCreated); + } + + @Override + public int hashCode() { + return Objects.hash(documentVerification, phase, timestampCreated); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/DocumentVerificationEntity.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/DocumentVerificationEntity.java new file mode 100644 index 000000000..c902d2e9d --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/DocumentVerificationEntity.java @@ -0,0 +1,211 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.database.entity; + +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; +import java.io.Serializable; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +/** + * Entity representing a document verification record. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Getter +@Setter +@ToString(of = {"id", "type", "uploadId"}) +@NoArgsConstructor +@Entity +@Table(name = "es_document_verification") +public class DocumentVerificationEntity implements Serializable { + + private static final long serialVersionUID = -8237002126712707796L; + + /** + * Autogenerated identifier + */ + @Id + @GeneratedValue(generator = "uuid") + @GenericGenerator(name = "uuid", strategy = "uuid2") + @Column(name = "id", nullable = false) + private String id; + + /** + * Activation identifier + */ + @Column(name = "activation_id", nullable = false) + private String activationId; + + /** + * Identifier of the related identity verification entity + */ + @ManyToOne + @JoinColumn(name = "identity_verification_id", referencedColumnName = "id", nullable = false) + private IdentityVerificationEntity identityVerification; + + /** + * Type of the document + */ + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private DocumentType type; + + /** + * Typically FRONT, BACK where relevant or null + */ + @Enumerated(EnumType.STRING) + @Column(name = "side") + private CardSide side; + + /** + * Identifier of document with opposite side + */ + @Column(name = "other_side_id") + private String otherSideId; + + /** + * Name of provider which performed the verification + */ + @Column(name = "provider_name") + private String providerName; + + /** + * Status of the document processing + */ + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private DocumentStatus status; + + /** + * Filename specified during upload from mobile client + */ + @Column(name = "filename", nullable = false) + private String filename; + + /** + * Upload identifier in remote document verification system + */ + @Column(name = "upload_id") + private String uploadId; + + /** + * Verification identifier in remote document verification system + */ + @Column(name = "verification_id") + private String verificationId; + + /** + * Identifier of extracted customer photograph from ID card + */ + @Column(name = "photo_id") + private String photoId; + + /** + * Overall score achieved during document verification and fraud detection (0 - 100) + */ + @Column(name = "verification_score") + private Integer verificationScore; + + /** + * Overall reason for the document rejection + */ + @Column(name = "reject_reason") + private String rejectReason; + + /** + * Overall error detail in case a generic error occurred + */ + @Column(name = "error_detail") + private String errorDetail; + + /** + * Identifier of an entity which was replaced by this entity + */ + @Column(name = "original_document_id") + private String originalDocumentId; + + /** + * Whether the document is being used for customer verification or it has been replaced by another record + */ + @Column(name = "used_for_verification") + private boolean usedForVerification; + + /** + * Timestamp when the entity was created + */ + @Column(name = "timestamp_created", nullable = false) + private Date timestampCreated; + + /** + * Timestamp when the document was uploaded to document verification system + */ + @Column(name = "timestamp_uploaded") + private Date timestampUploaded; + + /** + * Timestamp when the document was verified in document verification system + */ + @Column(name = "timestamp_verified") + private Date timestampVerified; + + /** + * Timestamp when the document was disposed in document verification system + */ + @Column(name = "timestamp_disposed") + private Date timestampDisposed; + + /** + * Timestamp when the entity was last updated + */ + @Column(name = "timestamp_last_updated") + private Date timestampLastUpdated; + + /** + * Document results from different phases of processing (upload, verification) starting with the latest entity + */ + @OneToMany(mappedBy = "documentVerification", cascade = CascadeType.ALL) + @OrderBy("timestampCreated desc") + private Set results = new LinkedHashSet<>(); + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof DocumentVerificationEntity)) return false; + DocumentVerificationEntity that = (DocumentVerificationEntity) o; + return type == that.type && side == that.side && filename.equals(that.filename) && timestampCreated.equals(that.timestampCreated); + } + + @Override + public int hashCode() { + return Objects.hash(type, side, filename, timestampCreated); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/IdentityVerificationEntity.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/IdentityVerificationEntity.java new file mode 100644 index 000000000..5985ffe95 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/IdentityVerificationEntity.java @@ -0,0 +1,120 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.database.entity; + +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; +import java.io.Serializable; +import java.util.*; + +/** + * Entity representing identity verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Getter +@Setter +@ToString(of = {"id", "activationId", "phase"}) +@NoArgsConstructor +@Entity +@Table(name = "es_identity_verification") +public class IdentityVerificationEntity implements Serializable { + + private static final long serialVersionUID = 6307591849271145826L; + + @Id + @GeneratedValue(generator = "uuid") + @GenericGenerator(name = "uuid", strategy = "uuid2") + @Column(name = "id", nullable = false) + private String id; + + @Column(name = "activation_id", nullable = false) + private String activationId; + + @Column(name = "user_id", nullable = false) + private String userId; + + @Column(name = "process_id", nullable = false) + private String processId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private IdentityVerificationStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "phase", nullable = false) + private IdentityVerificationPhase phase; + + @Column(name = "reject_reason") + private String rejectReason; + + @Column(name = "error_detail") + private String errorDetail; + + @Column(name = "session_info") + private String sessionInfo; + + @Column(name = "timestamp_created", nullable = false) + private Date timestampCreated; + + @Column(name = "timestamp_last_updated") + private Date timestampLastUpdated; + + @OneToMany(mappedBy = "identityVerification", cascade = CascadeType.ALL) + @OrderBy("timestampCreated") + private Set documentVerifications = new LinkedHashSet<>(); + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof IdentityVerificationEntity)) return false; + IdentityVerificationEntity that = (IdentityVerificationEntity) o; + return activationId.equals(that.activationId) && timestampCreated.equals(that.timestampCreated); + } + + @Override + public int hashCode() { + return Objects.hash(activationId, timestampCreated); + } + + /** + * Checks if the presence check was initialized or not + *

+ * Any of the statuses [{@link IdentityVerificationStatus#FAILED}, + * {@link IdentityVerificationStatus#IN_PROGRESS}, {@link IdentityVerificationStatus#REJECTED}] + * means an already initialized presence check. + *

+ * @return true when the presence check is already initialized + */ + @Transient + public boolean isPresenceCheckInitialized() { + return IdentityVerificationPhase.PRESENCE_CHECK.equals(phase) && + List.of(IdentityVerificationStatus.FAILED, + IdentityVerificationStatus.IN_PROGRESS, + IdentityVerificationStatus.REJECTED).contains(status); + } + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OnboardingOtpEntity.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OnboardingOtpEntity.java new file mode 100644 index 000000000..603e5d20e --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OnboardingOtpEntity.java @@ -0,0 +1,115 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.database.entity; + +import com.wultra.app.enrollmentserver.model.enumeration.OtpStatus; +import com.wultra.app.enrollmentserver.model.enumeration.OtpType; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; +import java.io.Serializable; +import java.util.Date; +import java.util.Objects; + +/** + * Entity representing an onboarding OTP code. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Getter +@Setter +@ToString +@NoArgsConstructor +@Entity +@Table(name = "es_onboarding_otp") +public class OnboardingOtpEntity implements Serializable { + + private static final long serialVersionUID = -5626187612981527923L; + + public static final String ERROR_CANCELED = "canceled"; + + public static final String ERROR_EXPIRED = "expired"; + + public static final String ERROR_MAX_FAILED_ATTEMPTS = "maxFailedAttempts"; + + @Id + @GeneratedValue(generator = "uuid") + @GenericGenerator(name = "uuid", strategy = "uuid2") + @Column(name = "id", nullable = false) + private String id; + + @ManyToOne + @JoinColumn(name = "process_id", referencedColumnName = "id", nullable = false) + private OnboardingProcessEntity process; + + @Column(name = "otp_code", nullable = false) + private String otpCode; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OtpStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private OtpType type; + + @Column(name = "error_detail") + private String errorDetail; + + @Column(name = "failed_attempts") + private int failedAttempts; + + @Column(name = "timestamp_created", nullable = false) + private Date timestampCreated; + + @Column(name = "timestamp_expiration", nullable = false) + private Date timestampExpiration; + + @Column(name = "timestamp_last_updated") + private Date timestampLastUpdated; + + @Column(name = "timestamp_verified") + private Date timestampVerified; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof OnboardingOtpEntity)) return false; + OnboardingOtpEntity that = (OnboardingOtpEntity) o; + return process.equals(that.process) && type.equals(that.type) && timestampCreated.equals(that.timestampCreated); + } + + @Override + public int hashCode() { + return Objects.hash(process, type, timestampCreated); + } + + /** + * @return true when the OTP has expired, false otherwise + */ + @Transient + public boolean hasExpired() { + return timestampCreated.after(timestampExpiration); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OnboardingProcessEntity.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OnboardingProcessEntity.java new file mode 100644 index 000000000..2b098bf5a --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OnboardingProcessEntity.java @@ -0,0 +1,99 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.database.entity; + +import com.wultra.app.enrollmentserver.model.enumeration.OnboardingStatus; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; +import java.io.Serializable; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +/** + * Entity representing an onboarding process. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Getter +@Setter +@ToString +@NoArgsConstructor +@Entity +@Table(name = "es_onboarding_process") +public class OnboardingProcessEntity implements Serializable { + + private static final long serialVersionUID = -438495244269415158L; + + @Id + @GeneratedValue(generator = "uuid") + @GenericGenerator(name = "uuid", strategy = "uuid2") + @Column(name = "id", nullable = false) + private String id; + + @Column(name = "identification_data", nullable = false) + private String identificationData; + + @Column(name = "user_id") + private String userId; + + @Column(name = "activation_id") + private String activationId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OnboardingStatus status; + + @Column(name = "error_detail") + private String errorDetail; + + @Column(name = "timestamp_created", nullable = false) + private Date timestampCreated; + + @Column(name = "timestamp_last_updated") + private Date timestampLastUpdated; + + @Column(name = "timestamp_finished") + private Date timestampFinished; + + @OneToMany(mappedBy = "process", cascade = CascadeType.ALL) + @OrderBy("timestampCreated") + @ToString.Exclude + private Set otps = new LinkedHashSet<>(); + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof OnboardingProcessEntity)) return false; + OnboardingProcessEntity that = (OnboardingProcessEntity) o; + return identificationData.equals(that.identificationData) && timestampCreated.equals(that.timestampCreated); + } + + @Override + public int hashCode() { + return Objects.hash(identificationData, timestampCreated); + } +} + diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplateEntity.java similarity index 83% rename from enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java rename to enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplateEntity.java index 4a22f9956..075ddcec3 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplate.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/database/entity/OperationTemplateEntity.java @@ -20,10 +20,7 @@ import lombok.*; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.Table; +import javax.persistence.*; import java.io.Serializable; import java.util.Objects; @@ -36,14 +33,15 @@ @Setter @ToString @NoArgsConstructor -@RequiredArgsConstructor @Entity @Table(name = "es_operation_template") -public class OperationTemplate implements Serializable { +public class OperationTemplateEntity implements Serializable { private static final long serialVersionUID = 5914420785283118800L; @Id + @SequenceGenerator(name = "es_operation_template", sequenceName = "es_operation_template_seq", allocationSize = 10) + @GeneratedValue(strategy = GenerationType.AUTO, generator = "es_operation_template") @Column(name = "id", nullable = false) private Long id; @@ -65,8 +63,8 @@ public class OperationTemplate implements Serializable { @Override public boolean equals(Object o) { if (this == o) return true; - if (!(o instanceof OperationTemplate)) return false; - OperationTemplate that = (OperationTemplate) o; + if (!(o instanceof OperationTemplateEntity)) return false; + OperationTemplateEntity that = (OperationTemplateEntity) o; return id.equals(that.id) && Objects.equals(placeholder, that.placeholder) && Objects.equals(language, that.language) diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/DefaultExceptionHandler.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/DefaultExceptionHandler.java index fea4f9dc4..ba6851db1 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/DefaultExceptionHandler.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/DefaultExceptionHandler.java @@ -28,6 +28,8 @@ import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; +import javax.servlet.http.HttpServletRequest; + /** * Exception handler for RESTful API issues. * @@ -134,4 +136,100 @@ public class DefaultExceptionHandler { return new ErrorResponse("ACTIVATION_CODE_FAILED", "Unable to fetch activation code."); } + /** + * Handling of presence check exceptions. + * @param ex Exception. + * @return Response with error details. + */ + @ExceptionHandler(PresenceCheckException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public @ResponseBody ErrorResponse handlePresenceCheckException(PresenceCheckException ex) { + logger.warn("Presence check failed", ex); + return new ErrorResponse("PRESENCE_CHECK_FAILED", "Presence check failed."); + } + + /** + * Handling of identity verification exceptions. + * @param ex Exception. + * @return Response with error details. + */ + @ExceptionHandler(IdentityVerificationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public @ResponseBody ErrorResponse handleIdentityVerificationException(IdentityVerificationException ex) { + logger.warn("Identity verification failed", ex); + return new ErrorResponse("IDENTITY_VERIFICATION_FAILED", "Identity verification failed."); + } + + /** + * Handling of not enabled presence check exceptions. + * @param ex Exception. + * @return Response with error details. + */ + @ExceptionHandler(PresenceCheckNotEnabledException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public @ResponseBody ErrorResponse handlePresenceCheckNotEnabledException( + PresenceCheckNotEnabledException ex, HttpServletRequest request) { + logger.warn("Calling a service on a not enabled presence check service, requestUri: " + request.getRequestURI(), ex); + return new ErrorResponse("PRESENCE_CHECK_NOT_ENABLED", "Presence check is not enabled."); + } + + /** + * Handling of document verification exceptions. + * @param ex Exception. + * @return Response with error details. + */ + @ExceptionHandler(DocumentVerificationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public @ResponseBody ErrorResponse handleDocumentVerificationException(DocumentVerificationException ex) { + logger.warn("Document verification failed", ex); + return new ErrorResponse("DOCUMENT_VERIFICATION_FAILED", "Document verification failed."); + } + + /** + * Handling of document verification exceptions. + * @param ex Exception. + * @return Response with error details. + */ + @ExceptionHandler(RemoteCommunicationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public @ResponseBody ErrorResponse handleRemoteExceptionException(RemoteCommunicationException ex) { + logger.warn("Communication with remote system failed", ex); + return new ErrorResponse("REMOTE_COMMUNICATION_ERROR", "Communication with remote system failed."); + } + + /** + * Handling of onboarding process exceptions. + * @param ex Exception. + * @return Response with error details. + */ + @ExceptionHandler(OnboardingProcessException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public @ResponseBody ErrorResponse handleOnboardingProcessException(OnboardingProcessException ex) { + logger.warn("Onboarding process failed", ex); + return new ErrorResponse("ONBOARDING_FAILED", "Onboarding process failed."); + } + + /** + * Handling of onboarding OTP delivery exceptions. + * @param ex Exception. + * @return Response with error details. + */ + @ExceptionHandler(OnboardingOtpDeliveryException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public @ResponseBody ErrorResponse handleOnboardingOtpDeliveryException(OnboardingOtpDeliveryException ex) { + logger.warn("Onboarding OTP delivery failed", ex); + return new ErrorResponse("ONBOARDING_OTP_FAILED", "Onboarding OTP delivery failed."); + } + + /** + * Handling of too many onboarding processes exceptions. + * @param ex Exception. + * @return Response with error details. + */ + @ExceptionHandler(TooManyProcessesException.class) + @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) + public @ResponseBody ErrorResponse handleTooManyProcessesException(TooManyProcessesException ex) { + logger.warn("Too many onboarding processes started by the user", ex); + return new ErrorResponse("TOO_MANY_REQUESTS", "Too many onboarding processes started by the user."); + } } diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/DocumentSubmitException.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/DocumentSubmitException.java new file mode 100644 index 000000000..4ef291fa7 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/DocumentSubmitException.java @@ -0,0 +1,33 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.errorhandling; + +/** + * Exception thrown in case of an error during document submit. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +public class DocumentSubmitException extends Exception { + + private static final long serialVersionUID = -5868335942741210351L; + + public DocumentSubmitException(String message) { + super(message); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/DocumentVerificationException.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/DocumentVerificationException.java new file mode 100644 index 000000000..0ba07f82e --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/DocumentVerificationException.java @@ -0,0 +1,36 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.errorhandling; + +/** + * Exception thrown in case of an error during document verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public class DocumentVerificationException extends Exception { + + private static final long serialVersionUID = -5868335942741210351L; + + public DocumentVerificationException() { + } + + public DocumentVerificationException(String message) { + super(message); + } + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/IdentityVerificationException.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/IdentityVerificationException.java new file mode 100644 index 000000000..80189ba6a --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/IdentityVerificationException.java @@ -0,0 +1,36 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.errorhandling; + +/** + * Exception thrown in case of an error during identity verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public class IdentityVerificationException extends Exception { + + private static final long serialVersionUID = 678593206284581851L; + + public IdentityVerificationException() { + } + + public IdentityVerificationException(String message) { + super(message); + } + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/IdentityVerificationNotFoundException.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/IdentityVerificationNotFoundException.java new file mode 100644 index 000000000..a8a0ddf9e --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/IdentityVerificationNotFoundException.java @@ -0,0 +1,33 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.enrollmentserver.errorhandling; + +/** + * Exception thrown in case an identity verification cannot be found. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +public class IdentityVerificationNotFoundException extends IdentityVerificationException { + + private static final long serialVersionUID = -7599680135511121879L; + + public IdentityVerificationNotFoundException(String message) { + super(message); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/OnboardingOtpDeliveryException.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/OnboardingOtpDeliveryException.java new file mode 100644 index 000000000..4ebd2d860 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/OnboardingOtpDeliveryException.java @@ -0,0 +1,29 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.errorhandling; + +/** + * Exception thrown in case onboarding OTP delivery fails. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public class OnboardingOtpDeliveryException extends Exception { + + private static final long serialVersionUID = -2641221254167428346L; + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/OnboardingProcessException.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/OnboardingProcessException.java new file mode 100644 index 000000000..e204674fe --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/OnboardingProcessException.java @@ -0,0 +1,36 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.errorhandling; + +/** + * Exception thrown in case onboarding process fails. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public class OnboardingProcessException extends Exception { + + private static final long serialVersionUID = 7558022671624330227L; + + public OnboardingProcessException() { + } + + public OnboardingProcessException(String message) { + super(message); + } + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/OnboardingProviderException.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/OnboardingProviderException.java new file mode 100644 index 000000000..0fd64632b --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/OnboardingProviderException.java @@ -0,0 +1,29 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.errorhandling; + +/** + * Exception thrown in case onboarding provider fails. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public class OnboardingProviderException extends Exception { + + private static final long serialVersionUID = 787256528155796393L; + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/PresenceCheckException.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/PresenceCheckException.java new file mode 100644 index 000000000..6d962dea3 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/PresenceCheckException.java @@ -0,0 +1,36 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.errorhandling; + +/** + * Exception thrown in case presence check fails. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public class PresenceCheckException extends Exception { + + private static final long serialVersionUID = 3977949988982066411L; + + public PresenceCheckException() { + } + + public PresenceCheckException(String message) { + super(message); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/PresenceCheckNotEnabledException.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/PresenceCheckNotEnabledException.java new file mode 100644 index 000000000..80a2bb697 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/PresenceCheckNotEnabledException.java @@ -0,0 +1,31 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.enrollmentserver.errorhandling; + +/** + * Exception thrown in case presence check is not enabled. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +public class PresenceCheckNotEnabledException extends Exception { + + private static final long serialVersionUID = -6830136273098780465L; + + public PresenceCheckNotEnabledException() { } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/RemoteCommunicationException.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/RemoteCommunicationException.java new file mode 100644 index 000000000..db06078fb --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/RemoteCommunicationException.java @@ -0,0 +1,36 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.errorhandling; + +/** + * Exception thrown in case of an error during communication with remote system. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public class RemoteCommunicationException extends Exception { + + private static final long serialVersionUID = -6809966084351557214L; + + public RemoteCommunicationException() { + } + + public RemoteCommunicationException(String message) { + super(message); + } + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/TooManyProcessesException.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/TooManyProcessesException.java new file mode 100644 index 000000000..a213b8a06 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/TooManyProcessesException.java @@ -0,0 +1,29 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.errorhandling; + +/** + * Exception thrown in case too many onboarding processes were created by user. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public class TooManyProcessesException extends Exception { + + private static final long serialVersionUID = 6611918579148298666L; + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/ActivationCodeService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/ActivationCodeService.java index 6bb5f2b6d..3dad17f4b 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/ActivationCodeService.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/ActivationCodeService.java @@ -20,8 +20,8 @@ import com.wultra.app.enrollmentserver.errorhandling.ActivationCodeException; import com.wultra.app.enrollmentserver.errorhandling.InvalidRequestObjectException; import com.wultra.app.enrollmentserver.impl.service.converter.ActivationCodeConverter; -import com.wultra.app.enrollmentserver.model.request.ActivationCodeRequest; -import com.wultra.app.enrollmentserver.model.response.ActivationCodeResponse; +import com.wultra.app.enrollmentserver.api.model.request.ActivationCodeRequest; +import com.wultra.app.enrollmentserver.api.model.response.ActivationCodeResponse; import com.wultra.app.enrollmentserver.model.validator.ActivationCodeRequestValidator; import com.wultra.security.powerauth.client.PowerAuthClient; import com.wultra.security.powerauth.client.model.error.PowerAuthClientException; diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/DataExtractionService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/DataExtractionService.java new file mode 100644 index 000000000..b6dbdb725 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/DataExtractionService.java @@ -0,0 +1,117 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service; + +import com.wultra.app.enrollmentserver.errorhandling.DocumentVerificationException; +import com.wultra.app.enrollmentserver.model.Document; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Service implementing extraction and basic verification of uploaded documents. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Service +public class DataExtractionService { + + private static final Logger logger = LoggerFactory.getLogger(DataExtractionService.class); + + /** + * Extract request data and return a singled document. + * @param requestData ZIP archive with a single zipped file. + * @return Extracted document. + * @throws DocumentVerificationException Thrown in case input data is invalid. + */ + public Document extractDocument(byte[] requestData) throws DocumentVerificationException { + if (requestData == null) { + logger.warn("Missing request data"); + throw new DocumentVerificationException("Invalid data received"); + } + List extractedDocuments = decompress(requestData); + if (extractedDocuments.size() != 1) { + // Exactly 1 document is expected to be present in the archive + logger.warn("Input data does not contain a single document"); + throw new DocumentVerificationException("Invalid data received"); + } + logger.info("Extracted document {} from request data", extractedDocuments); + return extractedDocuments.get(0); + } + + /** + * Extract request data and return list of extracted documents. + * @param requestData ZIP archive with one or more documents. + * @return Extracted documents. + * @throws DocumentVerificationException Thrown in case input data is invalid. + */ + public List extractDocuments(byte[] requestData) throws DocumentVerificationException { + if (requestData == null) { + logger.warn("Missing request data"); + throw new DocumentVerificationException("Invalid data received"); + } + List extractedDocuments = decompress(requestData); + logger.info("Extracted documents {} from request data", extractedDocuments); + return extractedDocuments; + } + + /** + * Decompress an archive with documents. + * @param inputData Compressed input data. + * @return Extracted documents. + * @throws DocumentVerificationException Thrown in case input data is invalid. + */ + private List decompress(byte[] inputData) throws DocumentVerificationException { + List extractedDocuments = new ArrayList<>(); + try { + ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(inputData)); + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory()) { + // Directories are skipped, data is extracted from regular files + continue; + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int read; + while ((read = zis.read(buffer)) != -1) { + baos.write(buffer, 0, read); + } + Document document = new Document(); + document.setFilename(entry.getName()); + document.setData(baos.toByteArray()); + extractedDocuments.add(document); + } + zis.closeEntry(); + zis.close(); + } catch (IOException ex) { + logger.warn(ex.getMessage(), ex); + throw new DocumentVerificationException("Invalid data received"); + } + return extractedDocuments; + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationCreateService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationCreateService.java new file mode 100644 index 000000000..a3dab5146 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationCreateService.java @@ -0,0 +1,112 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service; + +import com.wultra.app.enrollmentserver.database.IdentityVerificationRepository; +import com.wultra.app.enrollmentserver.database.entity.IdentityVerificationEntity; +import com.wultra.app.enrollmentserver.errorhandling.IdentityVerificationException; +import com.wultra.app.enrollmentserver.errorhandling.RemoteCommunicationException; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.security.powerauth.client.PowerAuthClient; +import com.wultra.security.powerauth.client.model.error.PowerAuthClientException; +import com.wultra.security.powerauth.client.v3.ListActivationFlagsResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; +import java.util.ArrayList; +import java.util.List; + +/** + * Service implementing creating of identity verification. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Service +public class IdentityVerificationCreateService { + + private static final Logger logger = LoggerFactory.getLogger(IdentityVerificationCreateService.class); + + private static final String ACTIVATION_FLAG_VERIFICATION_PENDING = "VERIFICATION_PENDING"; + private static final String ACTIVATION_FLAG_VERIFICATION_IN_PROGRESS = "VERIFICATION_IN_PROGRESS"; + + /** + * Identity verification repository. + */ + private final IdentityVerificationRepository identityVerificationRepository; + + /** + * PowerAuth client. + */ + private final PowerAuthClient powerAuthClient; + + /** + * Service constructor. + * @param identityVerificationRepository Identity verification repository. + * @param powerAuthClient PowerAuth client. + */ + @Autowired + public IdentityVerificationCreateService(IdentityVerificationRepository identityVerificationRepository, PowerAuthClient powerAuthClient) { + this.identityVerificationRepository = identityVerificationRepository; + this.powerAuthClient = powerAuthClient; + } + + /** + * Creates new identity for the verification process. + * + * @param ownerId Owner identification. + * @return Identity verification entity + * @throws IdentityVerificationException Thrown when identity verification initialization fails. + * @throws RemoteCommunicationException Thrown when communication with PowerAuth server fails. + */ + @Transactional + public IdentityVerificationEntity createIdentityVerification(OwnerId ownerId, String processId) throws IdentityVerificationException, RemoteCommunicationException { + try { + ListActivationFlagsResponse response = powerAuthClient.listActivationFlags(ownerId.getActivationId()); + + List activationFlags = new ArrayList<>(response.getActivationFlags()); + if (!activationFlags.contains(ACTIVATION_FLAG_VERIFICATION_PENDING)) { + throw new IdentityVerificationException("Activation flag VERIFICATION_PENDING not found when initializing identity verification"); + } + activationFlags.remove(ACTIVATION_FLAG_VERIFICATION_PENDING); + activationFlags.add(ACTIVATION_FLAG_VERIFICATION_IN_PROGRESS); + + powerAuthClient.updateActivationFlags(ownerId.getActivationId(), activationFlags); + } catch (PowerAuthClientException ex) { + logger.warn("Activation flag request failed, error: {}", ex.getMessage()); + logger.debug(ex.getMessage(), ex); + throw new RemoteCommunicationException("Communication with PowerAuth server failed"); + } + + IdentityVerificationEntity entity = new IdentityVerificationEntity(); + entity.setActivationId(ownerId.getActivationId()); + entity.setPhase(IdentityVerificationPhase.DOCUMENT_UPLOAD); + entity.setStatus(IdentityVerificationStatus.IN_PROGRESS); + entity.setTimestampCreated(ownerId.getTimestamp()); + entity.setUserId(ownerId.getUserId()); + entity.setProcessId(processId); + + return identityVerificationRepository.save(entity); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationFinishService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationFinishService.java new file mode 100644 index 000000000..554e5e1be --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationFinishService.java @@ -0,0 +1,95 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service; + +import com.wultra.app.enrollmentserver.database.entity.OnboardingProcessEntity; +import com.wultra.app.enrollmentserver.errorhandling.OnboardingProcessException; +import com.wultra.app.enrollmentserver.errorhandling.RemoteCommunicationException; +import com.wultra.app.enrollmentserver.model.enumeration.OnboardingStatus; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.security.powerauth.client.PowerAuthClient; +import com.wultra.security.powerauth.client.model.error.PowerAuthClientException; +import com.wultra.security.powerauth.client.v3.ListActivationFlagsResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +/** + * Service implementing finishing of identity verification. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Service +public class IdentityVerificationFinishService { + + private static final Logger logger = LoggerFactory.getLogger(IdentityVerificationFinishService.class); + + private static final String ACTIVATION_FLAG_VERIFICATION_IN_PROGRESS = "VERIFICATION_IN_PROGRESS"; + + private final PowerAuthClient powerAuthClient; + private final OnboardingService onboardingService; + + /** + * Service constructor. + * @param powerAuthClient PowerAuth client. + * @param onboardingService Onboarding service. + */ + @Autowired + public IdentityVerificationFinishService(PowerAuthClient powerAuthClient, OnboardingService onboardingService) { + this.powerAuthClient = powerAuthClient; + this.onboardingService = onboardingService; + } + + /** + * Finish identity verification by removing the VERIFICATION_IN_PROGRESS flag. + * + * @param ownerId Owner identification. + * @throws RemoteCommunicationException Thrown when communication with PowerAuth server fails. + * @throws OnboardingProcessException Thrown when onboarding process termination fails. + */ + @Transactional + public void finishIdentityVerification(OwnerId ownerId) throws RemoteCommunicationException, OnboardingProcessException { + try { + ListActivationFlagsResponse response = powerAuthClient.listActivationFlags(ownerId.getActivationId()); + List activationFlags = response.getActivationFlags(); + if (!activationFlags.contains(ACTIVATION_FLAG_VERIFICATION_IN_PROGRESS)) { + // Identity verification has already been finished in PowerAuth server + return; + } + powerAuthClient.removeActivationFlags(ownerId.getActivationId(), Collections.singletonList(ACTIVATION_FLAG_VERIFICATION_IN_PROGRESS)); + } catch (PowerAuthClientException ex) { + logger.warn("Activation flag request failed, error: {}", ex.getMessage()); + logger.debug(ex.getMessage(), ex); + throw new RemoteCommunicationException("Communication with PowerAuth server failed"); + } + + // Terminate onboarding process + final OnboardingProcessEntity processEntity = onboardingService.findExistingProcessWithVerificationInProgress(ownerId.getActivationId()); + processEntity.setStatus(OnboardingStatus.FINISHED); + processEntity.setTimestampFinished(new Date()); + onboardingService.updateProcess(processEntity); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationOtpService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationOtpService.java new file mode 100644 index 000000000..5d0919b13 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationOtpService.java @@ -0,0 +1,181 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service; + +import com.wultra.app.enrollmentserver.api.model.response.OtpVerifyResponse; +import com.wultra.app.enrollmentserver.database.OnboardingOtpRepository; +import com.wultra.app.enrollmentserver.database.OnboardingProcessRepository; +import com.wultra.app.enrollmentserver.database.entity.IdentityVerificationEntity; +import com.wultra.app.enrollmentserver.database.entity.OnboardingOtpEntity; +import com.wultra.app.enrollmentserver.database.entity.OnboardingProcessEntity; +import com.wultra.app.enrollmentserver.errorhandling.OnboardingOtpDeliveryException; +import com.wultra.app.enrollmentserver.errorhandling.OnboardingProcessException; +import com.wultra.app.enrollmentserver.errorhandling.OnboardingProviderException; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus; +import com.wultra.app.enrollmentserver.model.enumeration.OtpStatus; +import com.wultra.app.enrollmentserver.model.enumeration.OtpType; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.provider.OnboardingProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +/** + * Service implementing OTP delivery and verification during identity verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Service +public class IdentityVerificationOtpService { + + private static final Logger logger = LoggerFactory.getLogger(IdentityVerificationOtpService.class); + + private final OnboardingProcessRepository onboardingProcessRepository; + private final OnboardingOtpRepository onboardingOtpRepository; + private final OtpService otpService; + + private OnboardingProvider onboardingProvider; + + /** + * Service constructor. + * @param onboardingProcessRepository Onboarding process repository. + * @param onboardingOtpRepository Onboarding OTP repository. + * @param otpService OTP service. + */ + public IdentityVerificationOtpService(OnboardingProcessRepository onboardingProcessRepository, OnboardingOtpRepository onboardingOtpRepository, OtpService otpService) { + this.onboardingProcessRepository = onboardingProcessRepository; + this.onboardingOtpRepository = onboardingOtpRepository; + this.otpService = otpService; + } + + /** + * Set onboarding provider via setter injection. + * @param onboardingProvider Onboarding provider. + */ + @Autowired(required = false) + public void setOnboardingProvider(OnboardingProvider onboardingProvider) { + this.onboardingProvider = onboardingProvider; + } + + /** + * Resends an OTP code for a process during identity verification. + * @param ownerId Owner identification. + * @param identityVerification Identity verification entity. + * @throws OnboardingProcessException Thrown when OTP code could not be generated. + * @throws OnboardingOtpDeliveryException Thrown when OTP code could not be sent. + */ + public void resendOtp(OwnerId ownerId, IdentityVerificationEntity identityVerification) throws OnboardingProcessException, OnboardingOtpDeliveryException { + checkPreconditions(ownerId, identityVerification); + sendOtpCode(identityVerification.getProcessId(), true); + } + + /** + * Sends an OTP code for a process during identity verification. + * @param ownerId Owner identification. + * @param identityVerification Identity verification entity. + * @throws OnboardingProcessException Thrown when OTP code could not be generated. + * @throws OnboardingOtpDeliveryException Thrown when OTP code could not be sent. + */ + public void sendOtp(OwnerId ownerId, IdentityVerificationEntity identityVerification) throws OnboardingProcessException, OnboardingOtpDeliveryException { + checkPreconditions(ownerId, identityVerification); + sendOtpCode(identityVerification.getProcessId(), false); + } + + /** + * Verify an OTP code. + * @param processId Onboarding process identification. + * @param otpCode OTP code. + * @return OTP verification response. + * @throws OnboardingProcessException Thrown when onboarding process is not found. + */ + public OtpVerifyResponse verifyOtpCode(String processId, String otpCode) throws OnboardingProcessException { + Optional processOptional = onboardingProcessRepository.findById(processId); + if (!processOptional.isPresent()) { + logger.warn("Onboarding process not found: {}", processId); + throw new OnboardingProcessException(); + } + final OnboardingProcessEntity process = processOptional.get(); + return otpService.verifyOtpCode(process.getId(), otpCode, OtpType.USER_VERIFICATION); + } + + /** + * Get whether user is verified using OTP code. + * @param processId Onboarding process ID. + * @return Whether user is verified using OTP code. + */ + public boolean isUserVerifiedUsingOtp(String processId) { + Optional otpOptional = onboardingOtpRepository.findLastOtp(processId, OtpType.USER_VERIFICATION); + if (!otpOptional.isPresent()) { + return false; + } + OnboardingOtpEntity otp = otpOptional.get(); + return otp.getStatus() == OtpStatus.VERIFIED; + } + + /** + * Sends or resends an OTP code for a process during identity verification. + * @param processId Process ID. + * @param isResend Whether the OTP code is being resent. + * @throws OnboardingProcessException Thrown when OTP code could not be generated. + * @throws OnboardingOtpDeliveryException Thrown when OTP code could not be sent. + */ + private void sendOtpCode(String processId, boolean isResend) throws OnboardingProcessException, OnboardingOtpDeliveryException { + if (onboardingProvider == null) { + logger.error("Onboarding provider is not available. Implement an onboarding provider and make it accessible using autowiring."); + throw new OnboardingProcessException(); + } + final Optional processOptional = onboardingProcessRepository.findById(processId); + if (!processOptional.isPresent()) { + logger.warn("Onboarding process not found: {}", processId); + throw new OnboardingProcessException(); + } + final OnboardingProcessEntity process = processOptional.get(); + // Create an OTP code + final String otpCode; + if (isResend) { + otpCode = otpService.createOtpCodeForResend(process, OtpType.USER_VERIFICATION); + } else { + otpCode = otpService.createOtpCode(process, OtpType.USER_VERIFICATION); + } + // Send the OTP code + try { + onboardingProvider.sendOtpCode(process.getUserId(), otpCode, isResend); + } catch (OnboardingProviderException e) { + logger.warn("OTP code delivery failed, error: {}", e.getMessage(), e); + throw new OnboardingOtpDeliveryException(); + } + } + + private void checkPreconditions(OwnerId ownerId, IdentityVerificationEntity identityVerification) throws OnboardingProcessException { + if (!IdentityVerificationPhase.OTP_VERIFICATION.equals(identityVerification.getPhase())) { + logger.warn("Invalid identity verification phase {}, but expected {}, {}", + identityVerification.getPhase(), IdentityVerificationPhase.OTP_VERIFICATION, ownerId); + throw new OnboardingProcessException("Unexpected state of identity verification"); + } + if (!IdentityVerificationStatus.OTP_VERIFICATION_PENDING.equals(identityVerification.getStatus())) { + logger.warn("Invalid identity verification status {}, but expected {}, {}", + identityVerification.getStatus(), IdentityVerificationStatus.OTP_VERIFICATION_PENDING, ownerId); + throw new OnboardingProcessException("Unexpected state of identity verification"); + } + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationResetService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationResetService.java new file mode 100644 index 000000000..90ce62bfd --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationResetService.java @@ -0,0 +1,87 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service; + +import com.wultra.app.enrollmentserver.errorhandling.RemoteCommunicationException; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.security.powerauth.client.PowerAuthClient; +import com.wultra.security.powerauth.client.model.error.PowerAuthClientException; +import com.wultra.security.powerauth.client.v3.ListActivationFlagsResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +/** + * Service implementing reset of identity verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Service +public class IdentityVerificationResetService { + + private static final Logger logger = LoggerFactory.getLogger(IdentityVerificationResetService.class); + + private static final String ACTIVATION_FLAG_VERIFICATION_PENDING = "VERIFICATION_PENDING"; + private static final String ACTIVATION_FLAG_VERIFICATION_IN_PROGRESS = "VERIFICATION_IN_PROGRESS"; + + /** + * PowerAuth client. + */ + private final PowerAuthClient powerAuthClient; + + /** + * Service constructor. + * @param powerAuthClient PowerAuth client. + */ + @Autowired + public IdentityVerificationResetService(PowerAuthClient powerAuthClient) { + this.powerAuthClient = powerAuthClient; + } + + /** + * Reset identity verification by setting activation flag to VERIFICATION_PENDING. + * + * @param ownerId Owner identification. + * @throws RemoteCommunicationException Thrown when communication with PowerAuth server fails. + */ + public void resetIdentityVerification(OwnerId ownerId) throws RemoteCommunicationException { + try { + ListActivationFlagsResponse response = powerAuthClient.listActivationFlags(ownerId.getActivationId()); + + List activationFlags = new ArrayList<>(response.getActivationFlags()); + // Remove flag VERIFICATION_IN_PROGRESS + activationFlags.remove(ACTIVATION_FLAG_VERIFICATION_IN_PROGRESS); + + // Add flag VERIFICATION_PENDING to restart the identity verification process + if (!activationFlags.contains(ACTIVATION_FLAG_VERIFICATION_PENDING)) { + activationFlags.add(ACTIVATION_FLAG_VERIFICATION_PENDING); + } + + powerAuthClient.updateActivationFlags(ownerId.getActivationId(), activationFlags); + } catch (PowerAuthClientException ex) { + logger.warn("Activation flag request failed, error: {}", ex.getMessage()); + logger.debug(ex.getMessage(), ex); + throw new RemoteCommunicationException("Communication with PowerAuth server failed"); + } + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationService.java new file mode 100644 index 000000000..85390343c --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationService.java @@ -0,0 +1,528 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service; + +import com.wultra.app.enrollmentserver.api.model.request.DocumentStatusRequest; +import com.wultra.app.enrollmentserver.api.model.request.DocumentSubmitRequest; +import com.wultra.app.enrollmentserver.api.model.response.DocumentStatusResponse; +import com.wultra.app.enrollmentserver.api.model.response.data.DocumentMetadataResponseDto; +import com.wultra.app.enrollmentserver.configuration.IdentityVerificationConfig; +import com.wultra.app.enrollmentserver.database.DocumentDataRepository; +import com.wultra.app.enrollmentserver.database.DocumentVerificationRepository; +import com.wultra.app.enrollmentserver.database.IdentityVerificationRepository; +import com.wultra.app.enrollmentserver.database.entity.DocumentResultEntity; +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.database.entity.IdentityVerificationEntity; +import com.wultra.app.enrollmentserver.errorhandling.*; +import com.wultra.app.enrollmentserver.impl.service.document.DocumentProcessingService; +import com.wultra.app.enrollmentserver.impl.service.verification.VerificationProcessingService; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus; +import com.wultra.app.enrollmentserver.model.integration.DocumentsVerificationResult; +import com.wultra.app.enrollmentserver.model.integration.Image; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.provider.DocumentVerificationProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.util.Streamable; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Service implementing document identity verification. + * + * @author Roman Strobl, roman.strobl@wultra.com + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@Service +public class IdentityVerificationService { + + private static final Logger logger = LoggerFactory.getLogger(IdentityVerificationService.class); + + private final IdentityVerificationConfig identityVerificationConfig; + private final DocumentDataRepository documentDataRepository; + private final DocumentVerificationRepository documentVerificationRepository; + private final IdentityVerificationRepository identityVerificationRepository; + private final DocumentProcessingService documentProcessingService; + private final IdentityVerificationCreateService identityVerificationCreateService; + private final VerificationProcessingService verificationProcessingService; + private final DocumentVerificationProvider documentVerificationProvider; + private final IdentityVerificationResetService identityVerificationResetService; + private final IdentityVerificationOtpService identityVerificationOtpService; + + private static final List DOCUMENT_STATUSES_PROCESSED = Arrays.asList(DocumentStatus.ACCEPTED, DocumentStatus.FAILED, DocumentStatus.REJECTED); + + /** + * Service constructor. + * @param identityVerificationConfig Identity verification config. + * @param documentDataRepository Document data repository. + * @param documentVerificationRepository Document verification repository. + * @param identityVerificationRepository Identity verification repository. + * @param documentProcessingService Document processing service. + * @param identityVerificationCreateService Identity verification create service. + * @param verificationProcessingService Verification processing service. + * @param documentVerificationProvider Document verification provider. + * @param identityVerificationResetService Identity verification reset service. + * @param identityVerificationOtpService Identity verification OTP service. + */ + @Autowired + public IdentityVerificationService( + IdentityVerificationConfig identityVerificationConfig, + DocumentDataRepository documentDataRepository, + DocumentVerificationRepository documentVerificationRepository, + IdentityVerificationRepository identityVerificationRepository, + DocumentProcessingService documentProcessingService, + IdentityVerificationCreateService identityVerificationCreateService, + VerificationProcessingService verificationProcessingService, + DocumentVerificationProvider documentVerificationProvider, + IdentityVerificationResetService identityVerificationResetService, + IdentityVerificationOtpService identityVerificationOtpService) { + this.identityVerificationConfig = identityVerificationConfig; + this.documentDataRepository = documentDataRepository; + this.documentVerificationRepository = documentVerificationRepository; + this.identityVerificationRepository = identityVerificationRepository; + this.documentProcessingService = documentProcessingService; + this.identityVerificationCreateService = identityVerificationCreateService; + this.verificationProcessingService = verificationProcessingService; + this.documentVerificationProvider = documentVerificationProvider; + this.identityVerificationResetService = identityVerificationResetService; + this.identityVerificationOtpService = identityVerificationOtpService; + } + + /** + * Finds the current verification identity + * @param ownerId Owner identification. + * @return Optional entity of the verification identity + */ + public Optional findBy(OwnerId ownerId) { + return identityVerificationRepository.findFirstByActivationIdOrderByTimestampCreatedDesc(ownerId.getActivationId()); + } + + /** + * Initialize identity verification. + * @param ownerId Owner identification. + * @param processId Process identifier. + * @throws IdentityVerificationException Thrown when identity verification initialization fails. + * @throws RemoteCommunicationException Thrown when communication with PowerAuth server fails. + * @throws OnboardingProcessException Thrown when onboarding process is invalid. + */ + public void initializeIdentityVerification(OwnerId ownerId, String processId) throws IdentityVerificationException, RemoteCommunicationException, OnboardingProcessException { + identityVerificationCreateService.createIdentityVerification(ownerId, processId); + } + + /** + * Submit identity-related documents for verification. + * @param request Document submit request. + * @param ownerId Owner identification. + * @return Document verification entities. + */ + public List submitDocuments(DocumentSubmitRequest request, + OwnerId ownerId) + throws DocumentSubmitException { + + // Find an already existing identity verification + Optional idVerificationOptional = findBy(ownerId); + + if (!idVerificationOptional.isPresent()) { + logger.error("Identity verification has not been initialized, {}", ownerId); + throw new DocumentSubmitException("Identity verification has not been initialized"); + } + + IdentityVerificationEntity idVerification = idVerificationOptional.get(); + + String processId = idVerification.getProcessId(); + if (!processId.equals(request.getProcessId())) { + logger.warn("Invalid process ID in request: {}", processId); + throw new DocumentSubmitException("Invalid process ID"); + } + + if (!IdentityVerificationPhase.DOCUMENT_UPLOAD.equals(idVerification.getPhase())) { + logger.error("The verification phase is {} but expected {}, {}", + idVerification.getPhase(), IdentityVerificationPhase.DOCUMENT_UPLOAD, ownerId + ); + throw new DocumentSubmitException("Not allowed submit of documents during not upload phase"); + } else if (IdentityVerificationStatus.VERIFICATION_PENDING.equals(idVerification.getStatus())) { + logger.info("Switching {} from {} to {} due to new documents submit, {}", + idVerification, IdentityVerificationStatus.VERIFICATION_PENDING, IdentityVerificationStatus.IN_PROGRESS, ownerId + ); + idVerification.setPhase(IdentityVerificationPhase.DOCUMENT_UPLOAD); + idVerification.setStatus(IdentityVerificationStatus.IN_PROGRESS); + idVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + identityVerificationRepository.save(idVerification); + } else if (!IdentityVerificationStatus.IN_PROGRESS.equals(idVerification.getStatus())) { + logger.error("The verification status is {} but expected {}, {}", + idVerification.getStatus(), IdentityVerificationStatus.IN_PROGRESS, ownerId + ); + throw new DocumentSubmitException("Not allowed submit of documents during not in progress status"); + } + + List docsVerifications = + documentProcessingService.submitDocuments(idVerification, request, ownerId); + documentProcessingService.pairTwoSidedDocuments(docsVerifications); + + identityVerificationRepository.save(idVerification); + return docsVerifications; + } + + /** + * Starts the verification process + * + * @param ownerId Owner identification. + * @throws IdentityVerificationException Thrown when identity verification could not be started. + * @throws DocumentVerificationException Thrown when document verification fails. + */ + @Transactional + public void startVerification(OwnerId ownerId) throws IdentityVerificationException, DocumentVerificationException { + Optional identityVerificationOptional = + identityVerificationRepository.findFirstByActivationIdOrderByTimestampCreatedDesc(ownerId.getActivationId()); + + if (!identityVerificationOptional.isPresent()) { + logger.error("No identity verification entity found to start the verification, {}", ownerId); + throw new IdentityVerificationException("Unable to start verification"); + } + IdentityVerificationEntity identityVerification = identityVerificationOptional.get(); + + List docVerifications = + documentVerificationRepository.findAllDocumentVerifications(identityVerification, + Collections.singletonList(DocumentStatus.VERIFICATION_PENDING)); + + List selfiePhotoVerifications = + docVerifications.stream() + .filter(entity -> DocumentType.SELFIE_PHOTO.equals(entity.getType())) + .collect(Collectors.toList()); + + // If not enabled then remove selfie photos from the verification process + if (!identityVerificationConfig.isVerifySelfieWithDocumentsEnabled()) { + docVerifications.removeAll(selfiePhotoVerifications); + } + + documentProcessingService.pairTwoSidedDocuments(docVerifications); + + List uploadIds = docVerifications.stream() + .map(DocumentVerificationEntity::getUploadId) + .collect(Collectors.toList()); + + DocumentsVerificationResult result = documentVerificationProvider.verifyDocuments(ownerId, uploadIds); + + identityVerification.setPhase(IdentityVerificationPhase.DOCUMENT_VERIFICATION); + identityVerification.setStatus(IdentityVerificationStatus.IN_PROGRESS); + identityVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + + docVerifications.forEach(docVerification -> { + docVerification.setStatus(DocumentStatus.VERIFICATION_IN_PROGRESS); + docVerification.setVerificationId(result.getVerificationId()); + docVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + }); + documentVerificationRepository.saveAll(docVerifications); + + // If selfie photos are not included in the verification process with documents change their status to ACCEPTED + if (!identityVerificationConfig.isVerifySelfieWithDocumentsEnabled()) { + selfiePhotoVerifications.forEach(selfiePhotoVerification -> { + selfiePhotoVerification.setStatus(DocumentStatus.ACCEPTED); + selfiePhotoVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + }); + documentVerificationRepository.saveAll(selfiePhotoVerifications); + } + } + + /** + * Checks verification result and evaluates the final state of the identity verification process + * + * @param ownerId Owner identification. + * @param idVerification Verification identity + * @throws DocumentVerificationException Thrown when an error during verification check occurred. + * @throws OnboardingOtpDeliveryException Thrown when OTP could not be sent when changing status. + */ + @Transactional + public void checkVerificationResult(OwnerId ownerId, IdentityVerificationEntity idVerification) + throws DocumentVerificationException, OnboardingProcessException, OnboardingOtpDeliveryException { + List allDocVerifications = + documentVerificationRepository.findAllDocumentVerifications(idVerification, + Collections.singletonList(DocumentStatus.VERIFICATION_IN_PROGRESS)); + Map> verificationsById = new HashMap<>(); + + for (DocumentVerificationEntity docVerification : allDocVerifications) { + verificationsById.computeIfAbsent(docVerification.getVerificationId(), (verificationId) -> new ArrayList<>()) + .add(docVerification); + } + + for (String verificationId: verificationsById.keySet()) { + DocumentsVerificationResult docVerificationResult = + documentVerificationProvider.getVerificationResult(ownerId, verificationId); + List docVerifications = verificationsById.get(verificationId); + verificationProcessingService.processVerificationResult(ownerId, docVerifications, docVerificationResult); + } + + if (allDocVerifications.stream() + .anyMatch(docVerification -> DocumentStatus.VERIFICATION_IN_PROGRESS.equals(docVerification.getStatus()))) { + return; + } + + // The code below is called only once, right after completed document verification + if (identityVerificationConfig.isVerificationOtpEnabled()) { + // OTP verification is pending, switch to OTP verification state and send OTP code even in case identity verification fails + idVerification.setStatus(IdentityVerificationStatus.OTP_VERIFICATION_PENDING); + idVerification.setPhase(IdentityVerificationPhase.OTP_VERIFICATION); + identityVerificationOtpService.sendOtp(ownerId, idVerification); + } else { + resolveIdentityVerificationResult(idVerification, allDocVerifications); + } + idVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + identityVerificationRepository.save(idVerification); + } + + /** + * Process identity verification result for document verifications which have already been previously processed. + * @param ownerId Owner identification. + * @param idVerification Identity verification entity. + */ + @Transactional + public void processDocumentVerificationResult(OwnerId ownerId, IdentityVerificationEntity idVerification) { + List processedDocVerifications = + documentVerificationRepository.findAllDocumentVerifications(idVerification, DOCUMENT_STATUSES_PROCESSED); + resolveIdentityVerificationResult(idVerification, processedDocVerifications); + idVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + identityVerificationRepository.save(idVerification); + } + + /** + * Resolve identity verification result for an identity verification entity using specified document verification results. + * @param idVerification Identity verification entity. + * @param docVerifications Document verification results. + */ + private void resolveIdentityVerificationResult(IdentityVerificationEntity idVerification, List docVerifications) { + if (docVerifications.stream() + .allMatch(docVerification -> DocumentStatus.ACCEPTED.equals(docVerification.getStatus()))) { + idVerification.setStatus(IdentityVerificationStatus.ACCEPTED); + idVerification.setPhase(IdentityVerificationPhase.COMPLETED); + } else { + docVerifications.stream() + .filter(docVerification -> DocumentStatus.FAILED.equals(docVerification.getStatus())) + .findAny() + .ifPresent(failed -> { + idVerification.setPhase(IdentityVerificationPhase.COMPLETED); + idVerification.setStatus(IdentityVerificationStatus.FAILED); + idVerification.setErrorDetail(failed.getErrorDetail()); + }); + + docVerifications.stream() + .filter(docVerification -> DocumentStatus.REJECTED.equals(docVerification.getStatus())) + .findAny() + .ifPresent(failed -> { + idVerification.setPhase(IdentityVerificationPhase.COMPLETED); + idVerification.setStatus(IdentityVerificationStatus.REJECTED); + idVerification.setErrorDetail(failed.getRejectReason()); + }); + } + } + + /** + * Check status of document verification related to identity. + * @param request Document status request. + * @param ownerId Owner identification. + * @return Document status response. + * @throws IdentityVerificationException Thrown when identity verification fails. + */ + @Transactional + public DocumentStatusResponse checkIdentityVerificationStatus(DocumentStatusRequest request, OwnerId ownerId) throws IdentityVerificationException { + DocumentStatusResponse response = new DocumentStatusResponse(); + + Optional idVerificationOptional = + identityVerificationRepository.findFirstByActivationIdOrderByTimestampCreatedDesc(ownerId.getActivationId()); + + if (!idVerificationOptional.isPresent()) { + logger.error("Checking identity verification status on a not existing entity, {}", ownerId); + response.setStatus(IdentityVerificationStatus.FAILED); + return response; + } + + final IdentityVerificationEntity idVerification = idVerificationOptional.get(); + + final List entities; + if (request.getFilter() != null) { + final List documentIds = request.getFilter().stream() + .map(DocumentStatusRequest.DocumentFilter::getDocumentId) + .collect(Collectors.toList()); + entities = Streamable.of(documentVerificationRepository.findAllById(documentIds)).toList(); + } else { + entities = idVerification.getDocumentVerifications().stream() + .filter(DocumentVerificationEntity::isUsedForVerification) + .collect(Collectors.toList()); + } + + // Ensure that all entities are related to the identity verification + if (!entities.isEmpty()) { + for (DocumentVerificationEntity entity : entities) { + if (!entity.getActivationId().equals(idVerification.getActivationId())) { + logger.error("Not related {} to {}, {}", entity, idVerification, ownerId); + response.setStatus(IdentityVerificationStatus.FAILED); + return response; + } + } + } + + // Check statuses of all documents used for the verification, update identity verification status accordingly + if (IdentityVerificationPhase.DOCUMENT_UPLOAD.equals(idVerification.getPhase()) + && IdentityVerificationStatus.IN_PROGRESS.equals(idVerification.getStatus())) { + checkIdentityDocumentsForVerification(ownerId, idVerification); + } + + List docsMetadata = createDocsMetadata(entities); + response.setStatus(idVerification.getStatus()); + response.setDocuments(docsMetadata); + + return response; + } + + /** + * Cleanup documents related to identity verification. + * @param ownerId Owner identification. + * @throws DocumentVerificationException Thrown when document cleanup fails + * @throws PresenceCheckException Thrown when presence check cleanup fails. + * @throws RemoteCommunicationException Thrown when communication with PowerAuth server fails. + */ + @Transactional + public void cleanup(OwnerId ownerId) + throws DocumentVerificationException, PresenceCheckException, RemoteCommunicationException { + + List uploadIds = documentVerificationRepository.findAllUploadIds(ownerId.getActivationId()); + + if (identityVerificationConfig.isDocumentVerificationCleanupEnabled()) { + documentVerificationProvider.cleanupDocuments(ownerId, uploadIds); + } else { + logger.debug("Skipped cleanup of documents at document verification provider (not enabled), {}", ownerId); + } + + // Delete all large documents by activation ID + documentDataRepository.deleteAllByActivationId(ownerId.getActivationId()); + // Set status of all not finished document verifications to failed + documentVerificationRepository.failVerifications(ownerId.getActivationId(), ownerId.getTimestamp(), DocumentStatus.ALL_NOT_FINISHED); + // Set status of all currently running identity verifications to failed + identityVerificationRepository.failRunningVerifications(ownerId.getActivationId(), ownerId.getTimestamp()); + // Reset activation flags, the client is expected to call /api/identity/init for the next round of verification + identityVerificationResetService.resetIdentityVerification(ownerId); + } + + /** + * Provides photo data + * @param photoId Identification of the photo + * @return Photo image + * @throws DocumentVerificationException When an error occurred during + */ + public Image getPhotoById(String photoId) throws DocumentVerificationException { + return documentVerificationProvider.getPhoto(photoId); + } + + /** + * Check documents used for verification on their status + *

+ * When all of the documents are {@link DocumentStatus#VERIFICATION_PENDING} the identity verification is set + * also to {@link IdentityVerificationStatus#VERIFICATION_PENDING} + *

+ * @param idVerification Identity verification entity. + * @param ownerId Owner identification + */ + @Transactional + public void checkIdentityDocumentsForVerification(OwnerId ownerId, IdentityVerificationEntity idVerification) { + List docVerifications = + documentVerificationRepository.findAllUsedForVerification(idVerification); + + if (docVerifications.stream() + .allMatch(docVerification -> + DocumentStatus.VERIFICATION_PENDING.equals(docVerification.getStatus()) + ) + ) { + logger.info("All documents are pending verification, changing status of {} to {}", + idVerification, IdentityVerificationStatus.VERIFICATION_PENDING + ); + idVerification.setPhase(IdentityVerificationPhase.DOCUMENT_UPLOAD); + idVerification.setStatus(IdentityVerificationStatus.VERIFICATION_PENDING); + idVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + } + } + + public List createDocsMetadata(List entities) { + List docsMetadata = new ArrayList<>(); + entities.forEach(entity -> { + DocumentMetadataResponseDto docMetadata = toDocumentMetadata(entity); + + if (DocumentStatus.REJECTED.equals(entity.getStatus())) { + List errors = collectRejectionErrors(entity); + if (docMetadata.getErrors() == null) { + docMetadata.setErrors(new ArrayList<>()); + } + docMetadata.getErrors().addAll(errors); + } + + docsMetadata.add(docMetadata); + }); + return docsMetadata; + } + + private List collectRejectionErrors(DocumentVerificationEntity entity) { + List errors = new ArrayList<>(); + + // Collect all rejection reasons from the latest document result + Optional docResultOptional = entity.getResults().stream().findFirst(); + if (docResultOptional.isPresent()) { + DocumentResultEntity docResult = docResultOptional.get(); + List rejectionReasons; + try { + rejectionReasons = documentVerificationProvider.parseRejectionReasons(docResult); + } catch (DocumentVerificationException e) { + logger.debug("Parsing rejection reasons failure", e); + logger.warn("Unable to parse rejection reasons from {} of a rejected {}", docResult, entity); + return Collections.emptyList(); + } + if (rejectionReasons.isEmpty()) { + logger.warn("No rejection reasons found in {} of a rejected {}", docResult, entity); + } else { + errors.addAll(rejectionReasons); + } + } else { + logger.warn("Missing document result for {}, defaulting errors to reject reason", entity); + errors.add(entity.getRejectReason()); + } + return errors; + } + + /** + * Create {@link DocumentMetadataResponseDto} from {@link DocumentVerificationEntity} + * @param entity Document verification entity. + * @return Document metadata for response + */ + private DocumentMetadataResponseDto toDocumentMetadata(DocumentVerificationEntity entity) { + DocumentMetadataResponseDto docMetadata = new DocumentMetadataResponseDto(); + docMetadata.setId(entity.getId()); + if (entity.getErrorDetail() != null) { + docMetadata.setErrors(List.of(entity.getErrorDetail())); + } + docMetadata.setFilename(entity.getFilename()); + docMetadata.setSide(entity.getSide()); + docMetadata.setStatus(entity.getStatus()); + docMetadata.setType(entity.getType()); + return docMetadata; + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationStatusService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationStatusService.java new file mode 100644 index 000000000..5c448574c --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/IdentityVerificationStatusService.java @@ -0,0 +1,274 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service; + +import com.wultra.app.enrollmentserver.configuration.IdentityVerificationConfig; +import com.wultra.app.enrollmentserver.database.IdentityVerificationRepository; +import com.wultra.app.enrollmentserver.database.entity.IdentityVerificationEntity; +import com.wultra.app.enrollmentserver.database.entity.OnboardingProcessEntity; +import com.wultra.app.enrollmentserver.errorhandling.*; +import com.wultra.app.enrollmentserver.impl.service.internal.JsonSerializationService; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus; +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.enrollmentserver.api.model.request.IdentityVerificationStatusRequest; +import com.wultra.app.enrollmentserver.api.model.response.IdentityVerificationStatusResponse; +import com.wultra.security.powerauth.client.PowerAuthClient; +import com.wultra.security.powerauth.client.model.error.PowerAuthClientException; +import com.wultra.security.powerauth.client.v3.ListActivationFlagsResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; +import java.util.List; +import java.util.Optional; + +/** + * Service implementing document identity verification status services. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Service +public class IdentityVerificationStatusService { + + private static final Logger logger = LoggerFactory.getLogger(IdentityVerificationService.class); + + private final IdentityVerificationConfig identityVerificationConfig; + private final IdentityVerificationRepository identityVerificationRepository; + private final IdentityVerificationService identityVerificationService; + private final JsonSerializationService jsonSerializationService; + private final PresenceCheckService presenceCheckService; + private final IdentityVerificationFinishService identityVerificationFinishService; + private final OnboardingService onboardingService; + private final IdentityVerificationOtpService identityVerificationOtpService; + + private final PowerAuthClient powerAuthClient; + + private static final String ACTIVATION_FLAG_VERIFICATION_IN_PROGRESS = "VERIFICATION_IN_PROGRESS"; + + /** + * Service constructor. + * @param identityVerificationConfig Identity verification configuration. + * @param identityVerificationRepository Identity verification repository. + * @param identityVerificationService Identity verification service. + * @param jsonSerializationService JSON serialization service. + * @param presenceCheckService Presence check service. + * @param identityVerificationFinishService Identity verification finish service. + * @param onboardingService Onboarding service. + * @param identityVerificationOtpService Identity verification OTP service. + * @param powerAuthClient PowerAuth client. + */ + @Autowired + public IdentityVerificationStatusService( + IdentityVerificationConfig identityVerificationConfig, + IdentityVerificationRepository identityVerificationRepository, + IdentityVerificationService identityVerificationService, + JsonSerializationService jsonSerializationService, + PresenceCheckService presenceCheckService, + IdentityVerificationFinishService identityVerificationFinishService, + OnboardingService onboardingService, + IdentityVerificationOtpService identityVerificationOtpService, PowerAuthClient powerAuthClient) { + this.identityVerificationConfig = identityVerificationConfig; + this.identityVerificationRepository = identityVerificationRepository; + this.identityVerificationService = identityVerificationService; + this.jsonSerializationService = jsonSerializationService; + this.presenceCheckService = presenceCheckService; + this.identityVerificationFinishService = identityVerificationFinishService; + this.onboardingService = onboardingService; + this.identityVerificationOtpService = identityVerificationOtpService; + this.powerAuthClient = powerAuthClient; + } + + /** + * Check status of identity verification. + * @param request Identity verification status request. + * @param ownerId Owner identifier. + * @return Identity verification status response. + * @throws IdentityVerificationException Thrown when identity verification could not be started. + * @throws RemoteCommunicationException Thrown when communication with PowerAuth server fails. + * @throws OnboardingProcessException Thrown when onboarding process is invalid. + * @throws OnboardingOtpDeliveryException Thrown when OTP could not be sent when changing status. + */ + @Transactional + public IdentityVerificationStatusResponse checkIdentityVerificationStatus(IdentityVerificationStatusRequest request, OwnerId ownerId) throws IdentityVerificationException, RemoteCommunicationException, OnboardingProcessException, OnboardingOtpDeliveryException { + IdentityVerificationStatusResponse response = new IdentityVerificationStatusResponse(); + + Optional idVerificationOptional = + identityVerificationRepository.findFirstByActivationIdOrderByTimestampCreatedDesc(ownerId.getActivationId()); + + if (!idVerificationOptional.isPresent()) { + response.setIdentityVerificationStatus(IdentityVerificationStatus.NOT_INITIALIZED); + response.setIdentityVerificationPhase(null); + final OnboardingProcessEntity onboardingProcess = onboardingService.findProcessByActivationId(ownerId.getActivationId()); + response.setProcessId(onboardingProcess.getId()); + return response; + } + + IdentityVerificationEntity idVerification = idVerificationOptional.get(); + response.setProcessId(idVerification.getProcessId()); + + // Check activation flags, the identity verification entity may need to be re-initialized after cleanup + try { + ListActivationFlagsResponse flagResponse = powerAuthClient.listActivationFlags(ownerId.getActivationId()); + List flags = flagResponse.getActivationFlags(); + if (!flags.contains(ACTIVATION_FLAG_VERIFICATION_IN_PROGRESS)) { + // Initialization is required because verification is not in progress for current identity verification + response.setIdentityVerificationStatus(IdentityVerificationStatus.NOT_INITIALIZED); + response.setIdentityVerificationPhase(null); + return response; + } + } catch (PowerAuthClientException ex) { + logger.warn("Activation flag request failed, error: {}", ex.getMessage()); + logger.debug(ex.getMessage(), ex); + throw new RemoteCommunicationException("Communication with PowerAuth server failed"); + } + + response.setIdentityVerificationPhase(idVerification.getPhase()); + + if (IdentityVerificationPhase.DOCUMENT_UPLOAD.equals(idVerification.getPhase()) + && IdentityVerificationStatus.IN_PROGRESS.equals(idVerification.getStatus())) { + identityVerificationService.checkIdentityDocumentsForVerification(ownerId, idVerification); + } else if (IdentityVerificationPhase.PRESENCE_CHECK.equals(idVerification.getPhase()) + && IdentityVerificationStatus.IN_PROGRESS.equals(idVerification.getStatus())) { + response.setIdentityVerificationPhase(IdentityVerificationPhase.PRESENCE_CHECK); + + SessionInfo sessionInfo = + jsonSerializationService.deserialize(idVerification.getSessionInfo(), SessionInfo.class); + if (sessionInfo == null) { + logger.error("Checking presence verification failed due to invalid session info, " + ownerId); + idVerification.setErrorDetail("Unable to deserialize session info"); + idVerification.setStatus(IdentityVerificationStatus.FAILED); + idVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + } else { + PresenceCheckResult presenceCheckResult = null; + try { + presenceCheckResult = + presenceCheckService.checkPresenceVerification(ownerId, idVerification, sessionInfo); + } catch (PresenceCheckException e) { + logger.error("Checking presence verification failed, " + ownerId, e); + idVerification.setErrorDetail(e.getMessage()); + idVerification.setStatus(IdentityVerificationStatus.FAILED); + idVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + } + + if (presenceCheckResult != null) { + evaluatePresenceCheckResult(ownerId, idVerification, presenceCheckResult); + } + } + } else if (IdentityVerificationPhase.PRESENCE_CHECK.equals(idVerification.getPhase()) + && IdentityVerificationStatus.VERIFICATION_PENDING.equals(idVerification.getStatus())) { + startVerification(ownerId, idVerification); + } else if (!identityVerificationConfig.isPresenceCheckEnabled() + && IdentityVerificationPhase.DOCUMENT_UPLOAD.equals(idVerification.getPhase()) + && IdentityVerificationStatus.VERIFICATION_PENDING.equals(idVerification.getStatus())) { + logger.info("Starting verification, pending verification without presence check is automatically started"); + startVerification(ownerId, idVerification); + } else if (IdentityVerificationPhase.DOCUMENT_VERIFICATION.equals(idVerification.getPhase()) + && IdentityVerificationStatus.IN_PROGRESS.equals(idVerification.getStatus())) { + + try { + identityVerificationService.checkVerificationResult(ownerId, idVerification); + if (idVerification.getStatus() == IdentityVerificationStatus.ACCEPTED) { + identityVerificationFinishService.finishIdentityVerification(ownerId); + } + } catch (DocumentVerificationException e) { + logger.error("Checking identity verification result failed, " + ownerId, e); + response.setIdentityVerificationStatus(IdentityVerificationStatus.FAILED); + return response; + } catch (OnboardingProcessException e) { + logger.error("Updating onboarding process failed, " + ownerId, e); + response.setIdentityVerificationStatus(IdentityVerificationStatus.FAILED); + return response; + } + } else if (IdentityVerificationPhase.OTP_VERIFICATION.equals(idVerification.getPhase()) + && IdentityVerificationStatus.OTP_VERIFICATION_PENDING.equals(idVerification.getStatus())) { + + try { + if (identityVerificationOtpService.isUserVerifiedUsingOtp(idVerification.getProcessId())) { + // OTP verification is complete, process document verification result + identityVerificationService.processDocumentVerificationResult(ownerId, idVerification); + if (idVerification.getStatus() == IdentityVerificationStatus.ACCEPTED) { + identityVerificationFinishService.finishIdentityVerification(ownerId); + } + } + } catch (OnboardingProcessException e) { + logger.error("Updating onboarding process failed, " + ownerId, e); + response.setIdentityVerificationStatus(IdentityVerificationStatus.FAILED); + return response; + } + + } + + response.setIdentityVerificationStatus(idVerification.getStatus()); + response.setIdentityVerificationPhase(idVerification.getPhase()); + return response; + } + + private void evaluatePresenceCheckResult(OwnerId ownerId, + IdentityVerificationEntity idVerification, + PresenceCheckResult result) { + switch (result.getStatus()) { + case ACCEPTED: + idVerification.setStatus(IdentityVerificationStatus.VERIFICATION_PENDING); + idVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + logger.info("Presence check accepted, {}", ownerId); + break; + case FAILED: + idVerification.setErrorDetail(result.getErrorDetail()); + idVerification.setStatus(IdentityVerificationStatus.FAILED); + idVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + logger.warn("Presence check failed, {}, errorDetail: '{}'", ownerId, result.getErrorDetail()); + break; + case IN_PROGRESS: + logger.debug("Presence check still in progress, {}", ownerId); + break; + case REJECTED: + idVerification.setRejectReason(result.getRejectReason()); + idVerification.setStatus(IdentityVerificationStatus.REJECTED); + idVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + logger.warn("Presence check rejected, {}, rejectReason: '{}'", ownerId, result.getRejectReason()); + break; + default: + throw new IllegalStateException("Unexpected presence check result status: " + result.getStatus()); + } + } + + /** + * Starts the verification + * + * @param ownerId Owner identification. + * @param idVerification Verification identity. + * @throws IdentityVerificationException When an error during verification start occurred. + */ + private void startVerification(OwnerId ownerId, IdentityVerificationEntity idVerification) throws IdentityVerificationException { + try { + identityVerificationService.startVerification(ownerId); + } catch (DocumentVerificationException e) { + idVerification.setPhase(IdentityVerificationPhase.DOCUMENT_VERIFICATION); + idVerification.setStatus(IdentityVerificationStatus.FAILED); + idVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + logger.warn("Verification start failed, " + ownerId, e); + } + } + +} 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 8a4744a50..bf5bded60 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 @@ -18,7 +18,7 @@ package com.wultra.app.enrollmentserver.impl.service; -import com.wultra.app.enrollmentserver.database.entity.OperationTemplate; +import com.wultra.app.enrollmentserver.database.entity.OperationTemplateEntity; import com.wultra.app.enrollmentserver.errorhandling.MobileTokenAuthException; import com.wultra.app.enrollmentserver.errorhandling.MobileTokenConfigurationException; import com.wultra.app.enrollmentserver.errorhandling.MobileTokenException; @@ -105,7 +105,7 @@ public OperationListResponse operationListForUser( for (OperationDetailResponse operationDetail: pendingList) { final String activationFlag = operationDetail.getActivationFlag(); if (activationFlag == null || activationFlags.contains(activationFlag)) { // only return data if there is no flag, or if flag matches flags of activation - final OperationTemplate operationTemplate = operationTemplateService.prepareTemplate(operationDetail.getOperationType(), language); + final OperationTemplateEntity operationTemplate = operationTemplateService.prepareTemplate(operationDetail.getOperationType(), language); final Operation operation = mobileTokenConverter.convert(operationDetail, operationTemplate); responseObject.add(operation); if (responseObject.size() >= OPERATION_LIST_LIMIT) { // limit the list size in response diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/OnboardingService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/OnboardingService.java new file mode 100644 index 000000000..b7aa7bc17 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/OnboardingService.java @@ -0,0 +1,337 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service; + +import com.wultra.app.enrollmentserver.api.model.request.OnboardingCleanupRequest; +import com.wultra.app.enrollmentserver.api.model.request.OnboardingStartRequest; +import com.wultra.app.enrollmentserver.api.model.request.OnboardingStatusRequest; +import com.wultra.app.enrollmentserver.api.model.request.OnboardingOtpResendRequest; +import com.wultra.app.enrollmentserver.configuration.IdentityVerificationConfig; +import com.wultra.app.enrollmentserver.configuration.OnboardingConfig; +import com.wultra.app.enrollmentserver.database.OnboardingProcessRepository; +import com.wultra.app.enrollmentserver.database.entity.IdentityVerificationEntity; +import com.wultra.app.enrollmentserver.database.entity.OnboardingProcessEntity; +import com.wultra.app.enrollmentserver.errorhandling.*; +import com.wultra.app.enrollmentserver.impl.service.internal.JsonSerializationService; +import com.wultra.app.enrollmentserver.model.enumeration.OnboardingStatus; +import com.wultra.app.enrollmentserver.api.model.response.OnboardingStartResponse; +import com.wultra.app.enrollmentserver.api.model.response.OnboardingStatusResponse; +import com.wultra.app.enrollmentserver.model.enumeration.OtpType; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.provider.OnboardingProvider; +import io.getlime.core.rest.model.base.response.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; +import java.util.*; + +/** + * Service implementing the onboarding process. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Service +public class OnboardingService { + + private static final Logger logger = LoggerFactory.getLogger(OnboardingService.class); + + private final OnboardingProcessRepository onboardingProcessRepository; + private final JsonSerializationService serializer; + private final OnboardingConfig onboardingConfig; + private final IdentityVerificationConfig identityVerificationConfig; + private final OtpService otpService; + + private OnboardingProvider onboardingProvider; + + /** + * Service constructor. + * @param onboardingProcessRepository Onboarding process repository. + * @param serializer JSON serialization service. + * @param config Onboarding configuration. + * @param identityVerificationConfig Identity verification config. + * @param otpService OTP service. + */ + @Autowired + public OnboardingService(OnboardingProcessRepository onboardingProcessRepository, JsonSerializationService serializer, OnboardingConfig config, IdentityVerificationConfig identityVerificationConfig, OtpService otpService) { + this.onboardingProcessRepository = onboardingProcessRepository; + this.serializer = serializer; + this.onboardingConfig = config; + this.identityVerificationConfig = identityVerificationConfig; + this.otpService = otpService; + } + + /** + * Set onboarding provider via setter injection. + * @param onboardingProvider Onboarding provider. + */ + @Autowired(required = false) + public void setOnboardingProvider(OnboardingProvider onboardingProvider) { + this.onboardingProvider = onboardingProvider; + } + + /** + * Start an onboarding process. + * @param request Onboarding start request. + * @return Onboarding start response. + * @throws OnboardingProcessException Thrown in case onboarding process fails. + * @throws TooManyProcessesException Thrown in case too many onboarding processes are started. + */ + @Transactional + public OnboardingStartResponse startOnboarding(OnboardingStartRequest request) throws OnboardingProcessException, OnboardingOtpDeliveryException, TooManyProcessesException { + if (onboardingProvider == null) { + logger.error("Onboarding provider is not available. Implement an onboarding provider and make it accessible using autowiring."); + throw new OnboardingProcessException(); + } + Map identification = request.getIdentification(); + String identificationData = serializer.serialize(identification); + + // Lookup user using identification attributes + String userId; + try { + userId = onboardingProvider.lookupUser(identification); + } catch (OnboardingProviderException e) { + logger.warn("User look failed, error: {}", e.getMessage(), e); + throw new OnboardingProcessException(); + } + + // Check for brute force attacks + Calendar c = GregorianCalendar.getInstance(); + c.add(Calendar.HOUR, -24); + Date timestampCheckStart = c.getTime(); + int existingProcessCount = onboardingProcessRepository.countProcessesAfterTimestamp(userId, timestampCheckStart); + if (existingProcessCount >= onboardingConfig.getMaxProcessCountPerDay()) { + logger.warn("Maximum number of processes per day reached for user: " + userId); + throw new TooManyProcessesException(); + } + + Optional processOptional = onboardingProcessRepository.findExistingProcessForUser(userId, OnboardingStatus.ACTIVATION_IN_PROGRESS); + OnboardingProcessEntity process; + if (processOptional.isPresent()) { + // Resume an existing process + process = processOptional.get(); + // Use latest identification data + process.setIdentificationData(identificationData); + process.setTimestampLastUpdated(new Date()); + } else { + // Create an onboarding process + process = new OnboardingProcessEntity(); + process.setIdentificationData(identificationData); + process.setStatus(OnboardingStatus.ACTIVATION_IN_PROGRESS); + process.setUserId(userId); + process.setTimestampCreated(new Date()); + } + process = onboardingProcessRepository.save(process); + // Create an OTP code + String otpCode = otpService.createOtpCode(process, OtpType.ACTIVATION); + // Send the OTP code + try { + onboardingProvider.sendOtpCode(userId, otpCode, false); + } catch (OnboardingProviderException e) { + logger.warn("OTP code delivery failed, error: {}", e.getMessage(), e); + throw new OnboardingOtpDeliveryException(); + } + OnboardingStartResponse response = new OnboardingStartResponse(); + response.setProcessId(process.getId()); + response.setOnboardingStatus(process.getStatus()); + return response; + } + + /** + * Resend an OTP code. + * @param request Resend OTP code request. + * @return Resend OTP code response. + * @throws OnboardingProcessException Thrown when OTP resend fails. + */ + @Transactional + public Response resendOtp(OnboardingOtpResendRequest request) throws OnboardingProcessException, OnboardingOtpDeliveryException { + if (onboardingProvider == null) { + logger.error("Onboarding provider is not available. Implement an onboarding provider and make it accessible using autowiring."); + throw new OnboardingProcessException(); + } + String processId = request.getProcessId(); + OnboardingProcessEntity process = findProcess(processId); + String userId = process.getUserId(); + // Create an OTP code + String otpCode = otpService.createOtpCodeForResend(process, OtpType.ACTIVATION); + // Resend the OTP code + try { + onboardingProvider.sendOtpCode(userId, otpCode, true); + } catch (OnboardingProviderException e) { + logger.warn("OTP code resend failed, error: {}", e.getMessage(), e); + throw new OnboardingOtpDeliveryException(); + } + return new Response(); + } + + /** + * Get onboarding process status. + * @param request Onboarding status request. + * @return Onboarding status response. + * @throws OnboardingProcessException Thrown when onboarding process is not found. + */ + @Transactional + public OnboardingStatusResponse getStatus(OnboardingStatusRequest request) throws OnboardingProcessException { + String processId = request.getProcessId(); + Optional processOptional = onboardingProcessRepository.findById(processId); + if (!processOptional.isPresent()) { + logger.warn("Onboarding process not found, process ID: {}", processId); + throw new OnboardingProcessException(); + } + OnboardingProcessEntity process = processOptional.get(); + OnboardingStatusResponse response = new OnboardingStatusResponse(); + response.setProcessId(processId); + response.setOnboardingStatus(process.getStatus()); + return response; + } + + /** + * Perform cleanup of an onboarding process. + * @param request Onboarding process cleanup request. + * @return Onboarding process cleanup response. + * @throws OnboardingProcessException Thrown when onboarding process is not found. + */ + @Transactional + public Response performCleanup(OnboardingCleanupRequest request) throws OnboardingProcessException { + String processId = request.getProcessId(); + Optional processOptional = onboardingProcessRepository.findById(processId); + if (!processOptional.isPresent()) { + logger.warn("Onboarding process not found, process ID: {}", processId); + throw new OnboardingProcessException(); + } + otpService.cancelOtp(processOptional.get(), OtpType.ACTIVATION); + otpService.cancelOtp(processOptional.get(), OtpType.USER_VERIFICATION); + OnboardingProcessEntity process = processOptional.get(); + process.setStatus(OnboardingStatus.FAILED); + process.setTimestampLastUpdated(new Date()); + process.setErrorDetail("canceled"); + onboardingProcessRepository.save(process); + return new Response(); + } + + /** + * Verify process identifier. + * @param ownerId Owner identification. + * @param processId Process identifier from request. + * @throws OnboardingProcessException Thrown in case process identifier is invalid. + */ + public void verifyProcessId(OwnerId ownerId, String processId) throws OnboardingProcessException { + Optional processOptional = onboardingProcessRepository.findProcessByActivationId(ownerId.getActivationId()); + if (!processOptional.isPresent()) { + logger.error("Onboarding process not found, {}", ownerId); + throw new OnboardingProcessException(); + } + String expectedProcessId = processOptional.get().getId(); + + if (!expectedProcessId.equals(processId)) { + logger.warn("Invalid process ID received in request: {}, {}", processId, ownerId); + throw new OnboardingProcessException(); + } + } + + /** + * Find an onboarding process. + * @param processId Process identifier. + * @return Onboarding process. + * @throws OnboardingProcessException Thrown when onboarding process is not found. + */ + public OnboardingProcessEntity findProcess(String processId) throws OnboardingProcessException { + Optional processOptional = onboardingProcessRepository.findById(processId); + if (!processOptional.isPresent()) { + logger.warn("Onboarding process not found, process ID: {}", processId); + throw new OnboardingProcessException(); + } + return processOptional.get(); + } + + /** + * Find an existing onboarding process with verification in progress by activation identifier. + * @param activationId Activation identifier. + * @return Onboarding process. + * @throws OnboardingProcessException Thrown when onboarding process is not found. + */ + public OnboardingProcessEntity findExistingProcessWithVerificationInProgress(String activationId) throws OnboardingProcessException { + Optional processOptional = onboardingProcessRepository.findExistingProcessForActivation(activationId, OnboardingStatus.VERIFICATION_IN_PROGRESS); + if (!processOptional.isPresent()) { + logger.warn("Onboarding process not found, activation ID: {}", activationId); + throw new OnboardingProcessException(); + } + return processOptional.get(); + } + + /** + * Find an existing onboarding process by activation ID in any state. + * @param activationId Activation identifier. + * @return Onboarding process. + * @throws OnboardingProcessException Thrown when onboarding process is not found. + */ + public OnboardingProcessEntity findProcessByActivationId(String activationId) throws OnboardingProcessException { + Optional processOptional = onboardingProcessRepository.findProcessByActivationId(activationId); + if (!processOptional.isPresent()) { + logger.warn("Onboarding process not found, activation ID: {}", activationId); + throw new OnboardingProcessException(); + } + return processOptional.get(); + } + + /** + * Update a process entity in database. + * @param process Onboarding process entity. + * @return Updated onboarding process entity. + */ + public OnboardingProcessEntity updateProcess(OnboardingProcessEntity process) { + return onboardingProcessRepository.save(process); + } + + /** + * Check for inactive processes and terminate them. + */ + @Transactional + @Scheduled(fixedDelayString = "PT15S", initialDelayString = "PT15S") + public void terminateInactiveProcesses() { + // Terminate processes with activations in progress + final int activationExpirationSeconds = onboardingConfig.getActivationExpirationTime(); + final Date createdDateActivations = convertExpirationToCreatedDate(activationExpirationSeconds); + onboardingProcessRepository.terminateOldProcesses(createdDateActivations, OnboardingStatus.ACTIVATION_IN_PROGRESS); + + // Terminate processes with verifications in progress + final int verificationExpirationSeconds = identityVerificationConfig.getVerificationExpirationTime(); + final Date createdDateExpirations = convertExpirationToCreatedDate(verificationExpirationSeconds); + onboardingProcessRepository.terminateOldProcesses(convertExpirationToCreatedDate(verificationExpirationSeconds), OnboardingStatus.VERIFICATION_IN_PROGRESS); + + // Terminate OTP codes for all processes + final int otpExpirationSeconds = (int) onboardingConfig.getOtpExpirationTime().getSeconds(); + final Date createdDateOtp = convertExpirationToCreatedDate(otpExpirationSeconds); + otpService.terminateOldOtps(createdDateOtp); + } + + /** + * Convert expiration time to minimal created date used for expiration. + * @param expirationSeconds Expiration time in seconds. + * @return Created date used for expiration. + */ + private Date convertExpirationToCreatedDate(int expirationSeconds) { + Calendar c = GregorianCalendar.getInstance(); + c.add(Calendar.SECOND, -expirationSeconds); + return c.getTime(); + } + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/OperationTemplateService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/OperationTemplateService.java index 29eda3df2..a7b47f0d6 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/OperationTemplateService.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/OperationTemplateService.java @@ -18,7 +18,7 @@ package com.wultra.app.enrollmentserver.impl.service; -import com.wultra.app.enrollmentserver.database.entity.OperationTemplate; +import com.wultra.app.enrollmentserver.database.entity.OperationTemplateEntity; import com.wultra.app.enrollmentserver.database.OperationTemplateRepository; import com.wultra.app.enrollmentserver.errorhandling.MobileTokenConfigurationException; import org.springframework.beans.factory.annotation.Autowired; @@ -42,8 +42,8 @@ public OperationTemplateService(OperationTemplateRepository operationTemplateRep this.operationTemplateRepository = operationTemplateRepository; } - public OperationTemplate prepareTemplate(@NotNull String operationType, @NotNull String language) throws MobileTokenConfigurationException { - Optional operationTemplateOptional = operationTemplateRepository.findFirstByLanguageAndPlaceholder(language, operationType); + public OperationTemplateEntity prepareTemplate(@NotNull String operationType, @NotNull String language) throws MobileTokenConfigurationException { + Optional operationTemplateOptional = operationTemplateRepository.findFirstByLanguageAndPlaceholder(language, operationType); if (!operationTemplateOptional.isPresent()) { // try fallback to EN locale operationTemplateOptional = operationTemplateRepository.findFirstByLanguageAndPlaceholder("en", operationType); if (!operationTemplateOptional.isPresent()) { diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/OtpService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/OtpService.java new file mode 100644 index 000000000..72dff5ab1 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/OtpService.java @@ -0,0 +1,263 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service; + +import com.wultra.app.enrollmentserver.api.model.response.OtpVerifyResponse; +import com.wultra.app.enrollmentserver.configuration.OnboardingConfig; +import com.wultra.app.enrollmentserver.database.OnboardingOtpRepository; +import com.wultra.app.enrollmentserver.database.OnboardingProcessRepository; +import com.wultra.app.enrollmentserver.database.entity.OnboardingOtpEntity; +import com.wultra.app.enrollmentserver.database.entity.OnboardingProcessEntity; +import com.wultra.app.enrollmentserver.errorhandling.OnboardingOtpDeliveryException; +import com.wultra.app.enrollmentserver.errorhandling.OnboardingProcessException; +import com.wultra.app.enrollmentserver.impl.service.internal.OtpGeneratorService; +import com.wultra.app.enrollmentserver.model.enumeration.OnboardingStatus; +import com.wultra.app.enrollmentserver.model.enumeration.OtpStatus; +import com.wultra.app.enrollmentserver.model.enumeration.OtpType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Calendar; +import java.util.Date; +import java.util.Optional; + +/** + * Service implementing OTP delivery and verification during onboarding process. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Service +public class OtpService { + + private final static Logger logger = LoggerFactory.getLogger(OtpService.class); + + private final OtpGeneratorService otpGeneratorService; + private final OnboardingOtpRepository onboardingOtpRepository; + private final OnboardingProcessRepository onboardingProcessRepository; + private final OnboardingConfig onboardingConfig; + + /** + * Service constructor. + * @param otpGeneratorService OTP generator service. + * @param onboardingOtpRepository Onboarding OTP repository. + * @param onboardingProcessRepository Onboarding process repository. + * @param onboardingConfig Onboarding configuration. + */ + @Autowired + public OtpService(OtpGeneratorService otpGeneratorService, OnboardingOtpRepository onboardingOtpRepository, OnboardingProcessRepository onboardingProcessRepository, OnboardingConfig onboardingConfig) { + this.otpGeneratorService = otpGeneratorService; + this.onboardingOtpRepository = onboardingOtpRepository; + this.onboardingProcessRepository = onboardingProcessRepository; + this.onboardingConfig = onboardingConfig; + } + + /** + * Create an OTP code for onboarding process. + * @param process Onboarding process. + * @param otpType OTP type. + * @return OTP code. + * @throws OnboardingProcessException Thrown in case OTP code could not be generated. + */ + public String createOtpCode(OnboardingProcessEntity process, OtpType otpType) throws OnboardingProcessException { + return generateOtpCode(process, otpType); + } + + /** + * Create an OTP code for onboarding process for resend. + * @param process Onboarding process. + * @param otpType OTP type. + * @return OTP code. + * @throws OnboardingOtpDeliveryException Thrown in case OTP code could not be created yet due to a time limit. + * @throws OnboardingProcessException Thrown in case a previous OTP code is not found or OTP code could not be generated. + */ + public String createOtpCodeForResend(OnboardingProcessEntity process, OtpType otpType) throws OnboardingOtpDeliveryException, OnboardingProcessException { + final String processId = process.getId(); + // Do not allow spamming by OTP codes + final Date otpLastCreatedDate = onboardingOtpRepository.getNewestOtpCreatedTimestamp(processId, otpType); + final Duration resendPeriod = onboardingConfig.getOtpResendPeriod(); + if (isFromNowCloserThan(otpLastCreatedDate, resendPeriod)) { + logger.warn("Resend OTP functionality is not available yet (due to resend period) for process ID: {}", processId); + throw new OnboardingOtpDeliveryException(); + } + final Optional otpOptional = onboardingOtpRepository.findLastOtp(processId, otpType); + if (!otpOptional.isPresent()) { + logger.warn("Onboarding OTP not found for process ID: {}", processId); + throw new OnboardingProcessException(); + } + final OnboardingOtpEntity existingOtp = otpOptional.get(); + if (!OtpStatus.FAILED.equals(existingOtp.getStatus())) { + existingOtp.setStatus(OtpStatus.FAILED); + existingOtp.setTimestampLastUpdated(new Date()); + onboardingOtpRepository.save(existingOtp); + logger.info("Marked previous {} as {} to allow new send of the OTP code", existingOtp, OtpStatus.FAILED); + } + // Generate an OTP code + return generateOtpCode(process, otpType); + } + + /** + * Verify an OTP code. + * @param processId Process identifier. + * @param otpCode OTP code sent by the user. + * @param otpType OTP type. + * @return Verify OTP code response. + * @throws OnboardingProcessException Thrown when process or OTP code is not found. + */ + public OtpVerifyResponse verifyOtpCode(String processId, String otpCode, OtpType otpType) throws OnboardingProcessException { + Optional processOptional = onboardingProcessRepository.findById(processId); + if (!processOptional.isPresent()) { + logger.warn("Onboarding process not found: {}", processId); + throw new OnboardingProcessException(); + } + OnboardingProcessEntity process = processOptional.get(); + + Optional otpOptional = onboardingOtpRepository.findLastOtp(processId, otpType); + if (!otpOptional.isPresent()) { + logger.warn("Onboarding OTP not found for process ID: {}", processId); + throw new OnboardingProcessException(); + } + OnboardingOtpEntity otp = otpOptional.get(); + + // Verify OTP code + Date now = new Date(); + boolean expired = false; + boolean verified = false; + int failedAttempts = onboardingOtpRepository.getFailedAttemptsByProcess(processId, otpType); + int maxFailedAttempts = onboardingConfig.getOtpMaxFailedAttempts(); + if (OtpStatus.ACTIVE != otp.getStatus()) { + logger.warn("Unexpected not active {}, process ID: {}", otp, processId); + } else if (failedAttempts >= maxFailedAttempts) { + logger.warn("Unexpected OTP code verification when already exhausted max failed attempts, process ID: {}", processId); + process = failProcess(process, OnboardingOtpEntity.ERROR_MAX_FAILED_ATTEMPTS); + } else if (otp.hasExpired()) { + logger.info("Expired OTP code received, process ID: {}", processId); + expired = true; + otp.setStatus(OtpStatus.FAILED); + otp.setErrorDetail(OnboardingOtpEntity.ERROR_EXPIRED); + otp.setTimestampLastUpdated(now); + onboardingOtpRepository.save(otp); + } else if (otp.getOtpCode().equals(otpCode)) { + verified = true; + otp.setStatus(OtpStatus.VERIFIED); + otp.setTimestampVerified(now); + otp.setTimestampLastUpdated(now); + onboardingOtpRepository.save(otp); + } else { + otp.setFailedAttempts(otp.getFailedAttempts() + 1); + failedAttempts++; + if (failedAttempts >= maxFailedAttempts) { + otp.setStatus(OtpStatus.FAILED); + otp.setErrorDetail(OnboardingOtpEntity.ERROR_MAX_FAILED_ATTEMPTS); + + // Onboarding process is failed, update it + process = failProcess(process, OnboardingOtpEntity.ERROR_MAX_FAILED_ATTEMPTS); + } + otp.setTimestampLastUpdated(now); + onboardingOtpRepository.save(otp); + } + + OtpVerifyResponse response = new OtpVerifyResponse(); + response.setProcessId(processId); + response.setOnboardingStatus(process.getStatus()); + response.setExpired(expired); + response.setVerified(verified); + response.setRemainingAttempts(maxFailedAttempts - failedAttempts); + return response; + } + + /** + * Cancel an OTP for an onboarding process. + * @param process Onboarding process. + */ + public void cancelOtp(OnboardingProcessEntity process, OtpType otpType) { + String processId = process.getId(); + Optional otpOptional = onboardingOtpRepository.findLastOtp(processId, otpType); + // Fail current OTP, if it is present + if (otpOptional.isPresent()) { + OnboardingOtpEntity otp = otpOptional.get(); + if (otp.getStatus() != OtpStatus.FAILED) { + otp.setStatus(OtpStatus.FAILED); + otp.setTimestampLastUpdated(new Date()); + otp.setErrorDetail(OnboardingOtpEntity.ERROR_CANCELED); + onboardingOtpRepository.save(otp); + } + } + } + + /** + * Terminate OTPs created before specified date. + * @param createdDateOtp OTP created date. + */ + public void terminateOldOtps(Date createdDateOtp) { + onboardingOtpRepository.terminateOldOtps(createdDateOtp); + } + + /** + * Generate an OTP code for an onboarding process. + * @param process Onboarding process. + * @param otpType OTP type. + * @return OTP code. + * @throws OnboardingProcessException Thrown in case OTP code could not be generated. + */ + private String generateOtpCode(OnboardingProcessEntity process, OtpType otpType) throws OnboardingProcessException { + int otpLength = onboardingConfig.getOtpLength(); + String otpCode = otpGeneratorService.generateOtpCode(otpLength); + + // prepare timestamp created and expiration + Calendar calendar = Calendar.getInstance(); + Date timestampCreated = calendar.getTime(); + calendar.add(Calendar.SECOND, (int) onboardingConfig.getOtpExpirationTime().getSeconds()); + Date timestampExpiration = calendar.getTime(); + + OnboardingOtpEntity otp = new OnboardingOtpEntity(); + otp.setProcess(process); + otp.setOtpCode(otpCode); + otp.setType(otpType); + otp.setStatus(OtpStatus.ACTIVE); + otp.setTimestampCreated(timestampCreated); + otp.setTimestampExpiration(timestampExpiration); + otp.setFailedAttempts(0); + onboardingOtpRepository.save(otp); + return otpCode; + } + + private OnboardingProcessEntity failProcess(OnboardingProcessEntity entity, String errorDetail) { + if (OnboardingStatus.FAILED == entity.getStatus()) { + logger.debug("Not failing already failed onboarding entity"); + return entity; + } + entity.setStatus(OnboardingStatus.FAILED); + entity.setTimestampLastUpdated(new Date()); + entity.setErrorDetail(errorDetail); + return onboardingProcessRepository.save(entity); + } + + /** + * Checks whether a date is less than a specified duration closer to the current time + * @param date Date value + * @param duration Minimum duration before now + * @return true when the date is before or after the current time shorter duration than the specified one + */ + private boolean isFromNowCloserThan(Date date, Duration duration) { + return Math.abs(System.currentTimeMillis() - date.getTime()) < (duration.getSeconds() * 1_000); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/PresenceCheckService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/PresenceCheckService.java new file mode 100644 index 000000000..7ac9cb835 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/PresenceCheckService.java @@ -0,0 +1,285 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service; + +import com.google.common.base.Ascii; +import com.google.common.base.Preconditions; +import com.wultra.app.enrollmentserver.configuration.IdentityVerificationConfig; +import com.wultra.app.enrollmentserver.database.DocumentVerificationRepository; +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.database.entity.IdentityVerificationEntity; +import com.wultra.app.enrollmentserver.errorhandling.DocumentVerificationException; +import com.wultra.app.enrollmentserver.errorhandling.PresenceCheckException; +import com.wultra.app.enrollmentserver.impl.service.document.DocumentProcessingService; +import com.wultra.app.enrollmentserver.impl.service.internal.JsonSerializationService; +import com.wultra.app.enrollmentserver.model.enumeration.*; +import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.enrollmentserver.provider.PresenceCheckProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; +import java.util.List; +import java.util.Optional; + +/** + * Service implementing presence check. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@Service +public class PresenceCheckService { + + private static final Logger logger = LoggerFactory.getLogger(PresenceCheckService.class); + + private final IdentityVerificationConfig identityVerificationConfig; + + private final DocumentVerificationRepository documentVerificationRepository; + + private final DocumentProcessingService documentProcessingService; + + private final IdentityVerificationService identityVerificationService; + + private final JsonSerializationService jsonSerializationService; + + private final PresenceCheckProvider presenceCheckProvider; + + /** + * Service constructor. + * @param documentVerificationRepository Document verification repository. + * @param documentProcessingService Document processing service. + * @param identityVerificationService Identity verification service. + * @param jsonSerializationService JSON serialization service. + * @param presenceCheckProvider Presence check provider. + */ + @Autowired + public PresenceCheckService( + IdentityVerificationConfig identityVerificationConfig, + DocumentVerificationRepository documentVerificationRepository, + DocumentProcessingService documentProcessingService, + IdentityVerificationService identityVerificationService, + JsonSerializationService jsonSerializationService, + PresenceCheckProvider presenceCheckProvider) { + this.identityVerificationConfig = identityVerificationConfig; + this.documentVerificationRepository = documentVerificationRepository; + this.documentProcessingService = documentProcessingService; + this.identityVerificationService = identityVerificationService; + this.jsonSerializationService = jsonSerializationService; + this.presenceCheckProvider = presenceCheckProvider; + } + + /** + * Initializes presence check process. + * + * @param ownerId Owner identification. + * @param processId Process identifier. + * @return Session info with data needed to perform the presence check process + * @throws DocumentVerificationException When an error during obtaining the user personal image occurred + * @throws PresenceCheckException When an error during initializing the presence check occurred + */ + @Transactional + public SessionInfo init(OwnerId ownerId, String processId) + throws DocumentVerificationException, PresenceCheckException { + IdentityVerificationEntity idVerification = fetchIdVerification(ownerId); + + if (!idVerification.isPresenceCheckInitialized()) { + List docsWithPhoto = documentVerificationRepository.findAllWithPhoto(idVerification); + if (docsWithPhoto.isEmpty()) { + logger.error("Missing person photo to initialize presence check, {}", ownerId); + throw new PresenceCheckException("Unable to initialize presence check"); + } else { + Image photo = selectPhotoForPresenceCheck(ownerId, docsWithPhoto); + presenceCheckProvider.initPresenceCheck(ownerId, photo); + } + } + return startPresenceCheck(ownerId, idVerification); + } + + /** + * Checks presence verification result. + *

+ * When is the presence check accepted the person image is submitted to document verification provider. + *

+ * + * @param ownerId Owner identifier. + * @param idVerification Identity verification entity. + * @param sessionInfo Session info with presence check data. + * @return Result of the presence check + * @throws PresenceCheckException When an error during the presence check verification occurred + */ + @Transactional + public PresenceCheckResult checkPresenceVerification(OwnerId ownerId, + IdentityVerificationEntity idVerification, + SessionInfo sessionInfo) throws PresenceCheckException { + PresenceCheckResult result = presenceCheckProvider.getResult(ownerId, sessionInfo); + + if (!PresenceCheckStatus.ACCEPTED.equals(result.getStatus())) { + logger.info("Not accepted presence check, {}", ownerId); + return result; + } + logger.debug("Processing a result of an accepted presence check, {}", ownerId); + + Image photo = result.getPhoto(); + if (photo == null) { + logger.error("Missing person photo from presence verification, {}", ownerId); + throw new PresenceCheckException("Missing person photo from presence verification"); + } + logger.debug("Obtained a photo from the result, {}", ownerId); + + SubmittedDocument submittedDoc = new SubmittedDocument(); + // TODO use different random id approach + submittedDoc.setDocumentId( + Ascii.truncate("selfie-photo-" + ownerId.getActivationId(), 36, "...") + ); + submittedDoc.setPhoto(photo); + submittedDoc.setType(DocumentType.SELFIE_PHOTO); + + DocumentVerificationEntity docVerificationEntity = new DocumentVerificationEntity(); + docVerificationEntity.setActivationId(ownerId.getActivationId()); + docVerificationEntity.setIdentityVerification(idVerification); + docVerificationEntity.setFilename(result.getPhoto().getFilename()); + docVerificationEntity.setStatus(DocumentStatus.VERIFICATION_PENDING); + docVerificationEntity.setTimestampCreated(ownerId.getTimestamp()); + docVerificationEntity.setType(DocumentType.SELFIE_PHOTO); + docVerificationEntity.setUsedForVerification(true); + + DocumentSubmitResult documentSubmitResult = + documentProcessingService.submitDocumentToProvider(ownerId, docVerificationEntity, submittedDoc); + docVerificationEntity.setTimestampUploaded(ownerId.getTimestamp()); + docVerificationEntity.setUploadId(documentSubmitResult.getUploadId()); + + documentVerificationRepository.save(docVerificationEntity); + + documentVerificationRepository.setVerificationPending(idVerification, ownerId.getTimestamp()); + + return result; + } + + /** + * Cleans identity data used in the presence check process. + * + * @param ownerId Owner identification. + * @throws PresenceCheckException When an error during cleanup occurred. + */ + public void cleanup(OwnerId ownerId) throws PresenceCheckException { + if (identityVerificationConfig.isPresenceCheckCleanupEnabled()) { + presenceCheckProvider.cleanupIdentityData(ownerId); + } else { + logger.debug("Skipped cleanup of presence check data at the provider (not enabled), {}", ownerId); + } + } + + /** + * Starts new presence check process. + * + * @param ownerId Owner identification. + * @param idVerification Verification identity. + * @return Session info with data needed to perform the presence check process + * @throws PresenceCheckException When an error during starting the presence check process occurred. + */ + private SessionInfo startPresenceCheck(OwnerId ownerId, IdentityVerificationEntity idVerification) throws PresenceCheckException { + SessionInfo sessionInfo = presenceCheckProvider.startPresenceCheck(ownerId); + + String sessionInfoJson = jsonSerializationService.serialize(sessionInfo); + if (sessionInfoJson == null) { + logger.error("JSON serialization of session info failed, {}", ownerId); + throw new PresenceCheckException("Unable to initialize presence check"); + } + + idVerification.setSessionInfo(sessionInfoJson); + idVerification.setPhase(IdentityVerificationPhase.PRESENCE_CHECK); + idVerification.setStatus(IdentityVerificationStatus.IN_PROGRESS); + idVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + + return sessionInfo; + } + + /** + * Selects person photo for the presence check process + * @param ownerId Owner identification. + * @param docsWithPhoto Documents with a mined person photography. + * @return Image with a person photography + * @throws DocumentVerificationException When an error during obtaining a person photo occurred. + */ + public Image selectPhotoForPresenceCheck(OwnerId ownerId, List docsWithPhoto) throws DocumentVerificationException { + docsWithPhoto.forEach(docWithPhoto -> + Preconditions.checkNotNull(docWithPhoto.getPhotoId(), "Expected photoId value in " + docWithPhoto) + ); + + DocumentVerificationEntity preferredDocWithPhoto = null; + for (DocumentType documentType : DocumentType.PREFERRED_SOURCE_OF_PERSON_PHOTO) { + Optional docEntity = docsWithPhoto.stream() + .filter(value -> documentType.equals(value.getType())) + .findFirst(); + if (docEntity.isPresent()) { + preferredDocWithPhoto = docEntity.get(); + break; + } + } + if (preferredDocWithPhoto == null) { + logger.warn("Unable to select a source of person photo to initialize presence check, {}", ownerId); + preferredDocWithPhoto = docsWithPhoto.get(0); + } + logger.info("Selected {} as the source of person photo, {}", preferredDocWithPhoto, ownerId); + String photoId = preferredDocWithPhoto.getPhotoId(); + return identityVerificationService.getPhotoById(photoId); + } + + /** + * Fetches a current identity verification for presence check initialization + * @param ownerId Owner identification. + * @return Verification identity ready to be initialized for the presence check. + * @throws PresenceCheckException When an error during validating the identity verification status occurred. + */ + private IdentityVerificationEntity fetchIdVerification(OwnerId ownerId) throws PresenceCheckException { + Optional idVerificationOptional = identityVerificationService.findBy(ownerId); + if (!idVerificationOptional.isPresent()) { + logger.error("No identity verification entity found to initialize the presence check, {}", ownerId); + throw new PresenceCheckException("Unable to initialize presence check"); + } + + IdentityVerificationEntity idVerification = idVerificationOptional.get(); + + if (IdentityVerificationPhase.PRESENCE_CHECK.equals(idVerification.getPhase()) && + IdentityVerificationStatus.ACCEPTED.equals(idVerification.getStatus())) { + logger.error("The presence check is already accepted, not allowed to initialize it again, {}", ownerId); + throw new PresenceCheckException("Unable to initialize presence check"); + } else if (IdentityVerificationPhase.PRESENCE_CHECK.equals(idVerification.getPhase()) && + IdentityVerificationStatus.IN_PROGRESS.equals(idVerification.getStatus())) { + logger.info("The presence check is currently in progress, ready to be initialized again, {}", ownerId); + } else if (IdentityVerificationPhase.PRESENCE_CHECK.equals(idVerification.getPhase()) && + IdentityVerificationStatus.REJECTED.equals(idVerification.getStatus())) { + logger.info("The presence check is rejected, ready to be initialized again, {}", ownerId); + } else if (!IdentityVerificationPhase.DOCUMENT_UPLOAD.equals(idVerification.getPhase())) { + logger.error("The verification phase is {} but expected {}, {}", + idVerification.getPhase(), IdentityVerificationPhase.DOCUMENT_UPLOAD, ownerId + ); + throw new PresenceCheckException("Unable to initialize presence check"); + } else if (!IdentityVerificationStatus.VERIFICATION_PENDING.equals(idVerification.getStatus())) { + logger.error("The verification status is {} but expected {}, {}", + idVerification.getStatus(), IdentityVerificationStatus.VERIFICATION_PENDING, ownerId + ); + throw new PresenceCheckException("Unable to initialize presence check"); + } + + return idVerification; + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/PushRegistrationService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/PushRegistrationService.java index 0bb28631a..4af74c859 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/PushRegistrationService.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/PushRegistrationService.java @@ -20,7 +20,7 @@ import com.wultra.app.enrollmentserver.errorhandling.InvalidRequestObjectException; import com.wultra.app.enrollmentserver.errorhandling.PushRegistrationFailedException; -import com.wultra.app.enrollmentserver.model.request.PushRegisterRequest; +import com.wultra.app.enrollmentserver.api.model.request.PushRegisterRequest; import com.wultra.app.enrollmentserver.model.validator.PushRegisterRequestValidator; import io.getlime.core.rest.model.base.request.ObjectRequest; import io.getlime.core.rest.model.base.response.Response; diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/ActivationCodeConverter.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/ActivationCodeConverter.java index 307bf290a..c2691622b 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/ActivationCodeConverter.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/ActivationCodeConverter.java @@ -17,7 +17,7 @@ */ package com.wultra.app.enrollmentserver.impl.service.converter; -import com.wultra.app.enrollmentserver.model.response.ActivationCodeResponse; +import com.wultra.app.enrollmentserver.api.model.response.ActivationCodeResponse; import com.wultra.security.powerauth.client.v3.InitActivationResponse; import org.springframework.stereotype.Component; diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverter.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverter.java index 637744395..afd252e59 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverter.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverter.java @@ -20,7 +20,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.wultra.app.enrollmentserver.database.entity.OperationTemplate; +import com.wultra.app.enrollmentserver.database.entity.OperationTemplateEntity; import com.wultra.app.enrollmentserver.database.entity.OperationTemplateParam; import com.wultra.app.enrollmentserver.errorhandling.MobileTokenConfigurationException; import com.wultra.security.powerauth.client.model.enumeration.SignatureType; @@ -75,7 +75,7 @@ private AllowedSignatureType convert(List signatureType) { return allowedSignatureType; } - public Operation convert(OperationDetailResponse operationDetail, OperationTemplate operationTemplate) throws MobileTokenConfigurationException { + public Operation convert(OperationDetailResponse operationDetail, OperationTemplateEntity operationTemplate) throws MobileTokenConfigurationException { try { final Operation operation = new Operation(); operation.setId(operationDetail.getId()); diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentProcessingBatchService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentProcessingBatchService.java new file mode 100644 index 000000000..ee51a365f --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentProcessingBatchService.java @@ -0,0 +1,91 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service.document; + +import com.wultra.app.enrollmentserver.database.DocumentResultRepository; +import com.wultra.app.enrollmentserver.database.entity.DocumentResultEntity; +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +/** + * Service implementing document processing features. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@Service +public class DocumentProcessingBatchService { + + private static final Logger logger = LoggerFactory.getLogger(DocumentProcessingBatchService.class); + + private final DocumentResultRepository documentResultRepository; + + private final DocumentProcessingService documentProcessingService; + + /** + * Service constructor. + * @param documentResultRepository Document verification result repository. + * @param documentProcessingService Document processing service. + */ + @Autowired + public DocumentProcessingBatchService( + DocumentResultRepository documentResultRepository, + DocumentProcessingService documentProcessingService) { + this.documentResultRepository = documentResultRepository; + this.documentProcessingService = documentProcessingService; + } + + /** + * Checks in progress document submits on current provider status and data result + */ + @Transactional + public void checkInProgressDocumentSubmits() { + AtomicInteger countFinished = new AtomicInteger(0); + try (Stream stream = documentResultRepository.streamAllInProgressDocumentSubmits()) { + stream.forEach(docResult -> { + DocumentVerificationEntity docVerification = docResult.getDocumentVerification(); + final OwnerId ownerId = new OwnerId(); + ownerId.setActivationId(docVerification.getActivationId()); + ownerId.setUserId("server-task-in-progress-submits"); + + try { + this.documentProcessingService.checkDocumentSubmitWithProvider(ownerId, docResult); + } catch (Exception e) { + logger.error("Unable to check submit status of {} at provider, {}", docResult, ownerId); + } + + if (!DocumentStatus.UPLOAD_IN_PROGRESS.equals(docVerification.getStatus())) { + logger.debug("Synced {} status to {} with the provider, {}", docVerification, docVerification.getStatus(), ownerId); + countFinished.incrementAndGet(); + } + }); + } + if (countFinished.get() > 0) { + logger.debug("Finished {} documents which were in progress", countFinished.get()); + } + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentProcessingService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentProcessingService.java new file mode 100644 index 000000000..352e23dce --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentProcessingService.java @@ -0,0 +1,408 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service.document; + +import com.wultra.app.enrollmentserver.api.model.request.DocumentSubmitRequest; +import com.wultra.app.enrollmentserver.configuration.IdentityVerificationConfig; +import com.wultra.app.enrollmentserver.database.DocumentDataRepository; +import com.wultra.app.enrollmentserver.database.DocumentResultRepository; +import com.wultra.app.enrollmentserver.database.DocumentVerificationRepository; +import com.wultra.app.enrollmentserver.database.entity.DocumentDataEntity; +import com.wultra.app.enrollmentserver.database.entity.DocumentResultEntity; +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.database.entity.IdentityVerificationEntity; +import com.wultra.app.enrollmentserver.errorhandling.DocumentSubmitException; +import com.wultra.app.enrollmentserver.errorhandling.DocumentVerificationException; +import com.wultra.app.enrollmentserver.impl.service.DataExtractionService; +import com.wultra.app.enrollmentserver.model.Document; +import com.wultra.app.enrollmentserver.model.DocumentMetadata; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentProcessingPhase; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.enrollmentserver.provider.DocumentVerificationProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * Service implementing document processing features. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@Service +public class DocumentProcessingService { + + private static final Logger logger = LoggerFactory.getLogger(DocumentProcessingService.class); + + private final IdentityVerificationConfig identityVerificationConfig; + + private final DocumentDataRepository documentDataRepository; + + private final DocumentVerificationRepository documentVerificationRepository; + + private final DocumentResultRepository documentResultRepository; + + private final DataExtractionService dataExtractionService; + + private final DocumentVerificationProvider documentVerificationProvider; + + /** + * Service constructor. + * @param identityVerificationConfig Identity verification configuration. + * @param documentDataRepository Document data repository. + * @param documentVerificationRepository Document verification repository. + * @param documentResultRepository Document verification result repository. + * @param dataExtractionService Data extraction service. + * @param documentVerificationProvider Document verification provider. + */ + @Autowired + public DocumentProcessingService( + IdentityVerificationConfig identityVerificationConfig, + DocumentDataRepository documentDataRepository, + DocumentVerificationRepository documentVerificationRepository, + DocumentResultRepository documentResultRepository, + DataExtractionService dataExtractionService, + DocumentVerificationProvider documentVerificationProvider) { + this.identityVerificationConfig = identityVerificationConfig; + this.documentDataRepository = documentDataRepository; + this.documentVerificationRepository = documentVerificationRepository; + this.documentResultRepository = documentResultRepository; + this.dataExtractionService = dataExtractionService; + this.documentVerificationProvider = documentVerificationProvider; + } + + /** + * Submit identity-related documents for verification. + * @param idVerification Identity verification entity. + * @param request Document submit request. + * @param ownerId Owner identification. + * @return Document verification entities. + */ + @Transactional(value = Transactional.TxType.REQUIRES_NEW) + public List submitDocuments( + IdentityVerificationEntity idVerification, + DocumentSubmitRequest request, + OwnerId ownerId) throws DocumentSubmitException { + + List documents = getDocuments(ownerId, request); + + List docVerifications = new ArrayList<>(); + List docResults = new ArrayList<>(); + + List docsMetadata = request.getDocuments(); + for (DocumentSubmitRequest.DocumentMetadata docMetadata : docsMetadata) { + DocumentVerificationEntity docVerification = createDocumentVerification(ownerId, idVerification, docMetadata); + docVerification.setIdentityVerification(idVerification); + docVerifications.add(docVerification); + + checkDocumentResubmit(ownerId, request, docVerification); + + SubmittedDocument submittedDoc; + try { + submittedDoc = createSubmittedDocument(ownerId, docMetadata, documents); + } catch (DocumentSubmitException e) { + docVerification.setStatus(DocumentStatus.FAILED); + docVerification.setErrorDetail(e.getMessage()); + return docVerifications; + } + + DocumentSubmitResult docSubmitResult = submitDocumentToProvider(ownerId, docVerification, submittedDoc); + + DocumentResultEntity docResult = createDocumentResult(docVerification, docSubmitResult); + docResult.setTimestampCreated(ownerId.getTimestamp()); + + docResults.add(docResult); + + // Delete previously persisted document after a successful upload to the provider + if (docVerification.getUploadId() != null && docMetadata.getUploadId() != null) { + documentDataRepository.deleteById(docMetadata.getUploadId()); + } + } + + documentVerificationRepository.saveAll(docVerifications); + + for (int i = 0; i < docVerifications.size(); i++) { + DocumentVerificationEntity docVerificationEntity = docVerifications.get(i); + docResults.get(i).setDocumentVerification(docVerificationEntity); + } + documentResultRepository.saveAll(docResults); + + return docVerifications; + } + + public void checkDocumentResubmit(OwnerId ownerId, + DocumentSubmitRequest request, + DocumentVerificationEntity docVerification) throws DocumentSubmitException { + if (request.isResubmit() && docVerification.getOriginalDocumentId() == null) { + logger.error("Detected a resubmit request without specified originalDocumentId for {}, {}", docVerification, ownerId); + throw new DocumentSubmitException("Missing originalDocumentId in a resubmit request"); + } else if (request.isResubmit()) { + Optional originalDocOptional = + documentVerificationRepository.findById(docVerification.getOriginalDocumentId()); + if (!originalDocOptional.isPresent()) { + logger.warn("Missing previous DocumentVerificationEntity(originalDocumentId={}), {}", + docVerification.getOriginalDocumentId(), ownerId); + } else { + DocumentVerificationEntity originalDoc = originalDocOptional.get(); + originalDoc.setStatus(DocumentStatus.DISPOSED); + originalDoc.setUsedForVerification(false); + originalDoc.setTimestampDisposed(ownerId.getTimestamp()); + originalDoc.setTimestampLastUpdated(ownerId.getTimestamp()); + logger.info("Replaced previous {} with new {}, {}", originalDocOptional, docVerification, ownerId); + } + } else if (!request.isResubmit() && docVerification.getOriginalDocumentId() != null) { + logger.error("Detected a submit request with specified originalDocumentId={} for {}, {}", + docVerification.getOriginalDocumentId(), docVerification, ownerId); + throw new DocumentSubmitException("Specified originalDocumentId in a submit request"); + } + } + + /** + * Checks document submit status and data at the provider. + * @param ownerId Owner identification. + * @param documentResultEntity Document result entity to be checked at the provider. + */ + @Transactional(Transactional.TxType.REQUIRES_NEW) + public void checkDocumentSubmitWithProvider(OwnerId ownerId, DocumentResultEntity documentResultEntity) { + DocumentVerificationEntity docVerification = documentResultEntity.getDocumentVerification(); + DocumentsSubmitResult docsSubmitResults; + DocumentSubmitResult docSubmitResult; + try { + docsSubmitResults = documentVerificationProvider.checkDocumentUpload(ownerId, docVerification); + docSubmitResult = docsSubmitResults.getResults().get(0); + } catch (DocumentVerificationException e) { + docsSubmitResults = new DocumentsSubmitResult(); + docSubmitResult = new DocumentSubmitResult(); + docSubmitResult.setErrorDetail(e.getMessage()); + } + + documentResultEntity.setErrorDetail(docSubmitResult.getErrorDetail()); + documentResultEntity.setExtractedData(docSubmitResult.getExtractedData()); + documentResultEntity.setRejectReason(docSubmitResult.getRejectReason()); + processDocsSubmitResults(ownerId, docVerification, docsSubmitResults, docSubmitResult); + } + + public DocumentSubmitResult submitDocumentToProvider(OwnerId ownerId, DocumentVerificationEntity docVerification, SubmittedDocument submittedDoc) { + DocumentsSubmitResult docsSubmitResults; + DocumentSubmitResult docSubmitResult; + try { + docsSubmitResults = documentVerificationProvider.submitDocuments(ownerId, List.of(submittedDoc)); + docSubmitResult = docsSubmitResults.getResults().get(0); + } catch (DocumentVerificationException e) { + docsSubmitResults = new DocumentsSubmitResult(); + docSubmitResult = new DocumentSubmitResult(); + docSubmitResult.setErrorDetail(e.getMessage()); + } + + processDocsSubmitResults(ownerId, docVerification, docsSubmitResults, docSubmitResult); + return docSubmitResult; + } + + /** + * Upload a single document related to identity verification. + * @param idVerification Identity verification entity. + * @param requestData Binary document data. + * @param ownerId Owner identification. + * @return Persisted document metadata of the uploaded document. + * @throws DocumentVerificationException Thrown when document is invalid. + */ + @Transactional + public DocumentMetadata uploadDocument(IdentityVerificationEntity idVerification, byte[] requestData, OwnerId ownerId) throws DocumentVerificationException { + // TODO consider limiting the amount (count, space) of currently uploaded documents per ownerId + Document document = dataExtractionService.extractDocument(requestData); + return persistDocumentData(idVerification, ownerId, document); + } + + /** + * Pairs documents with two sides, front side will be linked to the back side and vice versa. + * @param documents Documents to be checked on two sides linkin + */ + @Transactional + public void pairTwoSidedDocuments(List documents) { + for (DocumentVerificationEntity document : documents) { + if (!document.getType().isTwoSided()) { + continue; + } + documents.stream() + .filter(item -> item.getType().equals(document.getType())) + .filter(item -> !item.getSide().equals(document.getSide())) + .forEach(item -> { + logger.debug("Found other side {} for {}", item, document); + item.setOtherSideId(document.getId()); + documentVerificationRepository.setOtherDocumentSide(item.getId(), document.getId()); + }); + } + } + + /** + * Persist a document into database. + * @param idVerification Identity verification entity. + * @param ownerId Owner identification + * @param document Document to be persisted. + * @return Persisted document metadata. + */ + private DocumentMetadata persistDocumentData(IdentityVerificationEntity idVerification, OwnerId ownerId, Document document) { + DocumentDataEntity entity = new DocumentDataEntity(); + entity.setActivationId(ownerId.getActivationId()); + entity.setIdentityVerification(idVerification); + entity.setFilename(document.getFilename()); + entity.setData(document.getData()); + entity.setTimestampCreated(ownerId.getTimestamp()); + entity = documentDataRepository.save(entity); + + document.setId(entity.getId()); + + // Return document metadata only + DocumentMetadata persistedDocument = new DocumentMetadata(); + persistedDocument.setId(entity.getId()); + persistedDocument.setFilename(entity.getFilename()); + return persistedDocument; + } + + private DocumentResultEntity createDocumentResult( + DocumentVerificationEntity docVerificationEntity, + DocumentSubmitResult docSubmitResult) { + DocumentResultEntity entity = new DocumentResultEntity(); + entity.setErrorDetail(docVerificationEntity.getErrorDetail()); + entity.setExtractedData(docSubmitResult.getExtractedData()); + entity.setPhase(DocumentProcessingPhase.UPLOAD); + entity.setRejectReason(docVerificationEntity.getRejectReason()); + return entity; + } + + private DocumentVerificationEntity createDocumentVerification(OwnerId ownerId, IdentityVerificationEntity identityVerification, DocumentSubmitRequest.DocumentMetadata docMetadata) { + DocumentVerificationEntity entity = new DocumentVerificationEntity(); + entity.setActivationId(ownerId.getActivationId()); + entity.setIdentityVerification(identityVerification); + entity.setFilename(docMetadata.getFilename()); + entity.setOriginalDocumentId(docMetadata.getOriginalDocumentId()); + entity.setSide(docMetadata.getSide()); + entity.setType(docMetadata.getType()); + entity.setStatus(DocumentStatus.UPLOAD_IN_PROGRESS); + entity.setTimestampCreated(ownerId.getTimestamp()); + entity.setUsedForVerification(true); + return documentVerificationRepository.save(entity); + } + + private SubmittedDocument createSubmittedDocument( + OwnerId ownerId, + DocumentSubmitRequest.DocumentMetadata docMetadata, + List docs) throws DocumentSubmitException { + Image photo = new Image(); + photo.setFilename(docMetadata.getFilename()); + + SubmittedDocument submittedDoc = new SubmittedDocument(); + submittedDoc.setDocumentId(docMetadata.getUploadId()); + submittedDoc.setPhoto(photo); + submittedDoc.setSide(docMetadata.getSide()); + submittedDoc.setType(docMetadata.getType()); + + if (docMetadata.getUploadId() == null) { + Optional docOptional = docs.stream() + .filter(doc -> doc.getFilename().equals(docMetadata.getFilename())) + .findFirst(); + if (docOptional.isPresent()) { + photo.setData(docOptional.get().getData()); + } else { + logger.error("Missing {} in data, {}", docMetadata, ownerId); + throw new DocumentSubmitException("Not found document data in sent request"); + } + } else { + Optional docDataOptional = + documentDataRepository.findById(docMetadata.getUploadId()); + if (docDataOptional.isPresent()) { + DocumentDataEntity documentData = docDataOptional.get(); + if (!ownerId.getActivationId().equals(documentData.getActivationId())) { + logger.error("The referenced document data uploadId={} are from different activation, {}", + docMetadata, ownerId); + throw new DocumentSubmitException("Invalid reference on uploaded document data in sent request"); + } + photo.setData(documentData.getData()); + } else { + logger.error("Missing {} in data, {}", docMetadata, ownerId); + throw new DocumentSubmitException("Not found document data in saved upload"); + } + } + return submittedDoc; + } + + private List getDocuments(OwnerId ownerId, DocumentSubmitRequest request) { + List documents; + if (request.getData() == null) { + documents = Collections.emptyList(); + } else { + try { + documents = dataExtractionService.extractDocuments(request.getData()); + } catch (DocumentVerificationException e) { + logger.error("Unable to extract documents from {}, {}", request, ownerId); + documents = Collections.emptyList(); + } + } + return documents; + } + + private void processDocsSubmitResults(OwnerId ownerId, DocumentVerificationEntity docVerification, + DocumentsSubmitResult docsSubmitResults, DocumentSubmitResult docSubmitResult) { + if (docSubmitResult.getErrorDetail() != null) { + docVerification.setStatus(DocumentStatus.FAILED); + docVerification.setErrorDetail(docSubmitResult.getErrorDetail()); + } else if (docSubmitResult.getRejectReason() != null) { + docVerification.setStatus(DocumentStatus.REJECTED); + docVerification.setRejectReason(docSubmitResult.getRejectReason()); + } else { + docVerification.setPhotoId(docsSubmitResults.getExtractedPhotoId()); + docVerification.setProviderName(identityVerificationConfig.getDocumentVerificationProvider()); + if (docVerification.getTimestampUploaded() == null) { + docVerification.setTimestampUploaded(ownerId.getTimestamp()); + } + docVerification.setUploadId(docSubmitResult.getUploadId()); + + // only finished upload contains extracted data + if (docSubmitResult.getExtractedData() == null) { + docVerification.setStatus(DocumentStatus.UPLOAD_IN_PROGRESS); + } else if (!DocumentType.SELFIE_PHOTO.equals(docVerification.getType()) && + identityVerificationConfig.isDocumentVerificationOnSubmitEnabled()) { + verifyDocumentWithUpload(ownerId, docVerification, docSubmitResult.getUploadId()); + docVerification.setStatus(DocumentStatus.UPLOAD_IN_PROGRESS); + } else { // no document verification during upload, wait for the final all documents verification + docVerification.setStatus(DocumentStatus.VERIFICATION_PENDING); + } + } + } + + private void verifyDocumentWithUpload(OwnerId ownerId, DocumentVerificationEntity docVerification, String uploadId) { + try { + DocumentsVerificationResult docsVerificationResult = + documentVerificationProvider.verifyDocuments(ownerId, List.of(uploadId)); + docVerification.setVerificationId(docsVerificationResult.getVerificationId()); + } catch (DocumentVerificationException e) { + logger.warn("Unable to verify document with uploadId: " + uploadId, e.getMessage(), ownerId); + docVerification.setStatus(DocumentStatus.FAILED); + docVerification.setErrorDetail(e.getMessage()); + } + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentStatusService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentStatusService.java new file mode 100644 index 000000000..af3a62710 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentStatusService.java @@ -0,0 +1,101 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service.document; + +import com.wultra.app.enrollmentserver.configuration.IdentityVerificationConfig; +import com.wultra.app.enrollmentserver.database.DocumentDataRepository; +import com.wultra.app.enrollmentserver.database.DocumentVerificationRepository; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; + +/** + * Service implementing background tasks related to document status update. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Service +public class DocumentStatusService { + + private static final Logger logger = LoggerFactory.getLogger(DocumentStatusService.class); + + public static final String MESSAGE_OBSOLETE_VERIFICATION_PROCESS = "Obsolete verification process"; + + private final DocumentVerificationRepository documentVerificationRepository; + private final DocumentDataRepository documentDataRepository; + private final IdentityVerificationConfig identityVerificationConfig; + + /** + * Service constructor. + * @param documentVerificationRepository Document verification repository. + * @param documentDataRepository Document data repository. + * @param identityVerificationConfig Identity verification configuration. + */ + @Autowired + public DocumentStatusService(DocumentVerificationRepository documentVerificationRepository, DocumentDataRepository documentDataRepository, IdentityVerificationConfig identityVerificationConfig) { + this.documentVerificationRepository = documentVerificationRepository; + this.documentDataRepository = documentDataRepository; + this.identityVerificationConfig = identityVerificationConfig; + } + + /** + * Cleanup of large documents older than retention time. + */ + @Transactional + @Scheduled(fixedDelayString = "PT10M", initialDelayString = "PT10M") + public void cleanupLargeDocuments() { + documentDataRepository.cleanupDocumentData(getDataRetentionTimestamp()); + } + + @Transactional + @Scheduled(fixedDelayString = "PT10M", initialDelayString = "PT10M") + public void cleanupObsoleteVerificationProcesses() { + int count = documentVerificationRepository.failObsoleteVerifications( + getVerificationExpirationTimestamp(), + new Date(), + MESSAGE_OBSOLETE_VERIFICATION_PROCESS, + DocumentStatus.ALL_NOT_FINISHED + ); + if (count > 0) { + logger.info("Failed {} obsolete verification processes", count); + } + } + + private Date getDataRetentionTimestamp() { + int retentionTime = identityVerificationConfig.getDataRetentionTime(); + Calendar dateRetention = GregorianCalendar.getInstance(); + dateRetention.add(Calendar.HOUR, retentionTime); + return dateRetention.getTime(); + } + + private Date getVerificationExpirationTimestamp() { + int expirationTime = identityVerificationConfig.getVerificationExpirationTime(); + Calendar dateExpiration = GregorianCalendar.getInstance(); + dateExpiration.add(Calendar.SECOND, expirationTime); + return dateExpiration.getTime(); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/internal/JsonSerializationService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/internal/JsonSerializationService.java new file mode 100644 index 000000000..6c0a5948e --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/internal/JsonSerializationService.java @@ -0,0 +1,68 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service.internal; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import javax.annotation.Nullable; + +/** + * Service class used for JSON serialization. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Service +public class JsonSerializationService { + + private static final Logger logger = LoggerFactory.getLogger(JsonSerializationService.class); + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Deserialize an object from JSON. + * @param json Serialized JSON representation of the object.. + * @return Deserialized object + */ + @Nullable + public T deserialize(String json, Class cls) { + try { + return objectMapper.readValue(json, cls); + } catch (JsonProcessingException e) { + logger.error("JSON serialization failed due to an error: " + e.getMessage(), e); + return null; + } + } + + /** + * Serialize an object into JSON. + * @param object Object. + * @return Serialized JSON representation of the object. + */ + public String serialize(Object object) { + try { + return objectMapper.writeValueAsString(object); + } catch (JsonProcessingException e) { + logger.error("JSON serialization failed due to an error: " + e.getMessage(), e); + return null; + } + } +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/internal/OtpGeneratorService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/internal/OtpGeneratorService.java new file mode 100644 index 000000000..a60f88d97 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/internal/OtpGeneratorService.java @@ -0,0 +1,58 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service.internal; + +import com.wultra.app.enrollmentserver.errorhandling.OnboardingProcessException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.math.BigInteger; +import java.security.SecureRandom; + +/** + * Service class used for generating OTP codes. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Service +public class OtpGeneratorService { + + private static final Logger logger = LoggerFactory.getLogger(OtpGeneratorService.class); + + private static final int OTP_MIN_LENGTH = 4; + private static final int OTP_MAX_LENGTH = 12; + + /** + * Generate an OTP code. + * @param length Length of generated OTP code. + * @return OTP code. + * @throws OnboardingProcessException Thrown in case OTP code generation fails. + */ + public String generateOtpCode(int length) throws OnboardingProcessException { + if (length < OTP_MIN_LENGTH || length > OTP_MAX_LENGTH) { + logger.warn("Invalid OTP length: " + length); + throw new OnboardingProcessException(); + } + SecureRandom random = new SecureRandom(); + BigInteger bound = BigInteger.TEN.pow(length).subtract(BigInteger.ONE); + long number = Math.abs(random.nextLong() % bound.longValue()); + return String.format("%0" + length + "d", number); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/verification/VerificationProcessingBatchService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/verification/VerificationProcessingBatchService.java new file mode 100644 index 000000000..624eafed3 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/verification/VerificationProcessingBatchService.java @@ -0,0 +1,104 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.enrollmentserver.impl.service.verification; + +import com.wultra.app.enrollmentserver.database.DocumentResultRepository; +import com.wultra.app.enrollmentserver.database.entity.DocumentResultEntity; +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.errorhandling.DocumentVerificationException; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus; +import com.wultra.app.enrollmentserver.model.integration.DocumentsVerificationResult; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.provider.DocumentVerificationProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +/** + * Service implementing verification processing features. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@Service +public class VerificationProcessingBatchService { + + private static final Logger logger = LoggerFactory.getLogger(VerificationProcessingBatchService.class); + + private final DocumentResultRepository documentResultRepository; + + private final DocumentVerificationProvider documentVerificationProvider; + + private final VerificationProcessingService verificationProcessingService; + + /** + * Service constructor. + * @param documentResultRepository Document verification result repository. + * @param documentVerificationProvider Document verification provider. + * @param verificationProcessingService Verification processing service. + */ + @Autowired + public VerificationProcessingBatchService( + DocumentResultRepository documentResultRepository, + DocumentVerificationProvider documentVerificationProvider, + VerificationProcessingService verificationProcessingService) { + this.documentResultRepository = documentResultRepository; + this.documentVerificationProvider = documentVerificationProvider; + this.verificationProcessingService = verificationProcessingService; + } + + /** + * Checks document submit verifications + */ + @Transactional + public void checkDocumentSubmitVerifications() { + AtomicInteger countFinished = new AtomicInteger(0); + try (Stream stream = documentResultRepository.streamAllInProgressDocumentSubmitVerifications()) { + stream.forEach(docResult -> { + DocumentVerificationEntity docVerification = docResult.getDocumentVerification(); + + final OwnerId ownerId = new OwnerId(); + ownerId.setActivationId(docVerification.getActivationId()); + ownerId.setUserId("server-task-doc-submit-verifications"); + + DocumentsVerificationResult docVerificationResult; + try { + docVerificationResult = documentVerificationProvider.getVerificationResult(ownerId, docVerification.getVerificationId()); + } catch (DocumentVerificationException e) { + logger.error("Checking document submit verification failed, " + ownerId, e); + return; + } + verificationProcessingService.processVerificationResult(ownerId, List.of(docVerification), docVerificationResult); + + if (!DocumentStatus.UPLOAD_IN_PROGRESS.equals(docVerification.getStatus())) { + logger.debug("Finished verification of {} during submit at the provider, {}", docVerification, ownerId); + countFinished.incrementAndGet(); + } + }); + } + if (countFinished.get() > 0) { + logger.debug("Finished {} documents verifications during submit", countFinished.get()); + } + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/verification/VerificationProcessingService.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/verification/VerificationProcessingService.java new file mode 100644 index 000000000..178f0d688 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/verification/VerificationProcessingService.java @@ -0,0 +1,187 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.service.verification; + +import com.wultra.app.enrollmentserver.database.DocumentResultRepository; +import com.wultra.app.enrollmentserver.database.entity.DocumentResultEntity; +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.errorhandling.DocumentVerificationException; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentProcessingPhase; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentVerificationStatus; +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase; +import com.wultra.app.enrollmentserver.model.integration.DocumentVerificationResult; +import com.wultra.app.enrollmentserver.model.integration.DocumentsVerificationResult; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +/** + * Service implementing verification processing features. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@Service +public class VerificationProcessingService { + + private static final Logger logger = LoggerFactory.getLogger(VerificationProcessingService.class); + + private final DocumentResultRepository documentResultRepository; + + /** + * Service constructor. + * + * @param documentResultRepository Document result repository. + */ + @Autowired + public VerificationProcessingService(DocumentResultRepository documentResultRepository) { + this.documentResultRepository = documentResultRepository; + } + + /** + * Processes documents verification result and updates the tracked verification state + * + * @param ownerId Owner identification. + * @param docVerifications Tracked state of documents verification. + * @param result Verification result from the provider. + */ + public void processVerificationResult(OwnerId ownerId, + List docVerifications, + DocumentsVerificationResult result) { + if (DocumentVerificationStatus.IN_PROGRESS.equals(result.getStatus())) { + logger.debug("Verification of the identity is still in progress, {}", ownerId); + return; + } + + for (DocumentVerificationEntity docVerification : docVerifications) { + updateDocVerification(ownerId, docVerification, result); + + if (!DocumentStatus.FAILED.equals(docVerification.getStatus())) { + Optional docVerificationResult = result.getResults().stream() + .filter(value -> value.getUploadId().equals(docVerification.getUploadId())) + .findFirst(); + if (docVerificationResult.isPresent()) { + DocumentResultEntity docResult = null; + try { + docResult = getDocumentResultEntity(ownerId, docVerification); + } catch (DocumentVerificationException e) { + logger.warn("Unable to get document result for " + docVerification + ", " + ownerId, e); + docVerification.setStatus(DocumentStatus.FAILED); + } + if (docResult != null) { + updateDocumentResult(docResult, docVerificationResult.get()); + documentResultRepository.save(docResult); + } + } else { + logger.error("Missing verification result for {} with uploadId: {}, {}", + docVerification, docVerification.getUploadId(), ownerId + ); + } + } + } + } + + /** + * Provides document result entity for verification data update + * + * @param ownerId Owner identification. + * @param docVerification Document verification entity. + * @return Document result entity to be updated with verification data + */ + private DocumentResultEntity getDocumentResultEntity(OwnerId ownerId, DocumentVerificationEntity docVerification) + throws DocumentVerificationException { + IdentityVerificationPhase phase = docVerification.getIdentityVerification().getPhase(); + DocumentResultEntity docResult; + if (IdentityVerificationPhase.DOCUMENT_UPLOAD.equals(phase)) { + List docResults = + documentResultRepository.findLatestResults(docVerification.getId(), DocumentProcessingPhase.UPLOAD); + if (docResults.isEmpty()) { + logger.warn("No document result for upload of {}, creating a new one, {}", docVerification, ownerId); + docResult = new DocumentResultEntity(); + docResult.setDocumentVerification(docVerification); + docResult.setPhase(DocumentProcessingPhase.UPLOAD); + docResult.setTimestampCreated(ownerId.getTimestamp()); + } else { + docResult = docResults.get(0); + if (docResults.size() > 1) { + logger.warn("Too many document results for upload of {}, count: {}, the latest wins, {}", + docVerification, docResults.size(), ownerId); + } + } + } else if (IdentityVerificationPhase.DOCUMENT_VERIFICATION.equals(phase)) { + docResult = new DocumentResultEntity(); + docResult.setDocumentVerification(docVerification); + docResult.setPhase(DocumentProcessingPhase.VERIFICATION); + docResult.setTimestampCreated(ownerId.getTimestamp()); + } else { + throw new DocumentVerificationException("Unexpected identity verification phase: " + phase); + } + return docResult; + } + + /** + * Update document verification data + * @param ownerId Owner identification. + * @param docVerification Document verification entity. + * @param docVerificationResult Document verification result. + */ + private void updateDocVerification(OwnerId ownerId, DocumentVerificationEntity docVerification, DocumentsVerificationResult docVerificationResult) { + docVerification.setTimestampLastUpdated(ownerId.getTimestamp()); + docVerification.setTimestampVerified(ownerId.getTimestamp()); + docVerification.setVerificationScore(docVerificationResult.getVerificationScore()); + switch (docVerificationResult.getStatus()) { + case ACCEPTED: + // document during upload is only partially verified and cannot be accepted now, it waits for the standard verification process + if (IdentityVerificationPhase.DOCUMENT_UPLOAD.equals(docVerification.getIdentityVerification().getPhase())) { + docVerification.setStatus(DocumentStatus.VERIFICATION_PENDING); + } else { + docVerification.setStatus(DocumentStatus.ACCEPTED); + } + break; + case FAILED: + docVerification.setStatus(DocumentStatus.FAILED); + docVerification.setErrorDetail(docVerificationResult.getErrorDetail()); + break; + case REJECTED: + docVerification.setStatus(DocumentStatus.REJECTED); + docVerification.setRejectReason(docVerificationResult.getRejectReason()); + break; + default: + throw new IllegalStateException("Unexpected verification result status: " + docVerificationResult.getStatus()); + } + logger.info("Finished verification of {} with status: {}, {}", docVerification, docVerification.getStatus(), ownerId); + } + + /** + * Updates document result with the verification result + * @param docResult Document result. + * @param docVerificationResult Document verification result. + */ + private void updateDocumentResult(DocumentResultEntity docResult, + DocumentVerificationResult docVerificationResult) { + docResult.setErrorDetail(docVerificationResult.getErrorDetail()); + docResult.setRejectReason(docVerificationResult.getRejectReason()); + docResult.setVerificationResult(docVerificationResult.getVerificationResult()); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/util/ConditionalOnPropertyNotEmpty.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/util/ConditionalOnPropertyNotEmpty.java index e96aa8c48..89a6e8771 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/util/ConditionalOnPropertyNotEmpty.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/util/ConditionalOnPropertyNotEmpty.java @@ -18,7 +18,7 @@ package com.wultra.app.enrollmentserver.impl.util; -import org.apache.commons.lang3.StringUtils; +import com.google.common.base.Strings; import org.springframework.context.annotation.Condition; import org.springframework.context.annotation.ConditionContext; import org.springframework.context.annotation.Conditional; @@ -52,7 +52,7 @@ public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) Map attrs = metadata.getAnnotationAttributes(ConditionalOnPropertyNotEmpty.class.getName()); String propertyName = (String) Objects.requireNonNull(attrs).get("value"); String val = context.getEnvironment().getProperty(propertyName); - return StringUtils.isNotBlank(val); + return !Strings.nullToEmpty(val).trim().isEmpty(); } } diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/util/PowerAuthUtil.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/util/PowerAuthUtil.java new file mode 100644 index 000000000..9c04442df --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/util/PowerAuthUtil.java @@ -0,0 +1,46 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.impl.util; + +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import io.getlime.security.powerauth.rest.api.spring.authentication.PowerAuthActivation; +import io.getlime.security.powerauth.rest.api.spring.authentication.PowerAuthApiAuthentication; + +/** + * PowerAuth utilities + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +public class PowerAuthUtil { + + /** + * Provides context data + * @param apiAuthentication Authentication object with user and app details. + * @return OwnerId context + */ + public static OwnerId getOwnerId(PowerAuthApiAuthentication apiAuthentication) { + PowerAuthActivation powerAuthActivation = apiAuthentication.getActivationContext(); + + OwnerId ownerId = new OwnerId(); + ownerId.setActivationId(powerAuthActivation.getActivationId()); + ownerId.setUserId(powerAuthActivation.getUserId()); + + return ownerId; + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/validator/ActivationCodeRequestValidator.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/validator/ActivationCodeRequestValidator.java index 82abb46ab..1ffd038c9 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/validator/ActivationCodeRequestValidator.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/validator/ActivationCodeRequestValidator.java @@ -17,7 +17,7 @@ */ package com.wultra.app.enrollmentserver.model.validator; -import com.wultra.app.enrollmentserver.model.request.ActivationCodeRequest; +import com.wultra.app.enrollmentserver.api.model.request.ActivationCodeRequest; import org.apache.commons.lang3.StringUtils; /** diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/validator/PushRegisterRequestValidator.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/validator/PushRegisterRequestValidator.java index d5aeada98..413ed4305 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/validator/PushRegisterRequestValidator.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/model/validator/PushRegisterRequestValidator.java @@ -18,7 +18,7 @@ package com.wultra.app.enrollmentserver.model.validator; -import com.wultra.app.enrollmentserver.model.request.PushRegisterRequest; +import com.wultra.app.enrollmentserver.api.model.request.PushRegisterRequest; import org.apache.commons.lang3.StringUtils; /** diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/provider/DocumentVerificationProvider.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/provider/DocumentVerificationProvider.java new file mode 100644 index 000000000..c77d68f96 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/provider/DocumentVerificationProvider.java @@ -0,0 +1,101 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.provider; + +import com.wultra.app.enrollmentserver.database.entity.DocumentResultEntity; +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.errorhandling.DocumentVerificationException; +import com.wultra.app.enrollmentserver.model.integration.*; + +import java.util.List; + +/** + * Provider which allows customization of the document verification process. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public interface DocumentVerificationProvider { + + /** + * Check document submit result and return data extracted from the document (including photo) and identifiers + * + * @param id Owner identification. + * @param document Document entity. + * @return Result of the document submit + * @throws DocumentVerificationException When an error during submitting of documents occurred + */ + DocumentsSubmitResult checkDocumentUpload(OwnerId id, DocumentVerificationEntity document) throws DocumentVerificationException; + + /** + * Analyze documents and return data extracted from documents (including photo) and identifiers. With enabled + * asynchronous processing no extracted data are returned. The data has be checked with {@link #checkDocumentUpload(OwnerId, DocumentVerificationEntity)} + * + * @param id Owner identification. + * @param documents Documents to be submitted + * @return Result of the documents submit + * @throws DocumentVerificationException When an error during submitting of documents occurred + */ + DocumentsSubmitResult submitDocuments(OwnerId id, List documents) throws DocumentVerificationException; + + /** + * Analyze previously submitted documents, detect frauds, return binary result + * + * @param id Owner identification. + * @param uploadIds Ids of previously uploaded documents + * @return Result of the documents verification + * @throws DocumentVerificationException When an error during verification occurred + */ + DocumentsVerificationResult verifyDocuments(OwnerId id, List uploadIds) throws DocumentVerificationException; + + /** + * Gets the result of verification + * + * @param id Owner identification. + * @param verificationId Identification of a previously run verification + * @return Result of a previously run documents verification + * @throws DocumentVerificationException When an error during verification result obtaining occurred + */ + DocumentsVerificationResult getVerificationResult(OwnerId id, String verificationId) throws DocumentVerificationException; + + /** + * Gets a photo + * @param photoId Identification of the photo + * @return Photo image + * @throws DocumentVerificationException When an error during getting of a photo occurred + */ + Image getPhoto(String photoId) throws DocumentVerificationException; + + /** + * Disposes documents which are no longer needed, throw away any sensitive data + * + * @param id Owner identification. + * @param uploadIds Ids of previously uploaded documents + * @throws DocumentVerificationException When an error during documents cleanup occurred + */ + void cleanupDocuments(OwnerId id, List uploadIds) throws DocumentVerificationException; + + // TODO reconsider this method, mention it in the tldr doc + /** + * Parses all detected rejection reasons from a JSON result of verification + * @param docResult Document result entity + * @return List of rejection reasons parsed from the verification result + * @throws DocumentVerificationException When an error during parsing rejection reasons occurred + */ + List parseRejectionReasons(DocumentResultEntity docResult) throws DocumentVerificationException; + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/provider/OnboardingProvider.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/provider/OnboardingProvider.java new file mode 100644 index 000000000..c0d667776 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/provider/OnboardingProvider.java @@ -0,0 +1,35 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.provider; + +import com.wultra.app.enrollmentserver.errorhandling.OnboardingProviderException; + +import java.util.Map; + +/** + * Provider which allows customization of the onboarding process. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public interface OnboardingProvider { + + String lookupUser(Map identification) throws OnboardingProviderException; + + void sendOtpCode(String userId, String otpCode, boolean resend) throws OnboardingProviderException; + +} \ No newline at end of file diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/provider/PresenceCheckProvider.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/provider/PresenceCheckProvider.java new file mode 100644 index 000000000..713ef7805 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/provider/PresenceCheckProvider.java @@ -0,0 +1,69 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver.provider; + +import com.wultra.app.enrollmentserver.errorhandling.PresenceCheckException; +import com.wultra.app.enrollmentserver.model.integration.Image; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.model.integration.PresenceCheckResult; +import com.wultra.app.enrollmentserver.model.integration.SessionInfo; + +/** + * Provider which allows customization of the presence check. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public interface PresenceCheckProvider { + + /** + * Initializes presence check process. + * + * @param id Owner identification. + * @param photo Trusted photo of the user. + * @throws PresenceCheckException When an error during initialization occurred. + */ + void initPresenceCheck(OwnerId id, Image photo) throws PresenceCheckException; + + /** + * Starts the presence check process. The process has to be initialized before this call. + * + * @param id Owner identification. + * @return Session info with data related to the presence check. + * @throws PresenceCheckException When an error occurred during presence check start. + */ + SessionInfo startPresenceCheck(OwnerId id) throws PresenceCheckException; + + /** + * Gets the result of presence check process. + * + * @param id Owner identification. + * @param sessionInfo Session info with presence check relevant data. + * @return Result of the presence check + * @throws PresenceCheckException When an error during getting of the result occurred. + */ + PresenceCheckResult getResult(OwnerId id, SessionInfo sessionInfo) throws PresenceCheckException; + + /** + * Cleans up all presence check data related to the identity. + * + * @param id Owner identification. + * @throws PresenceCheckException When an error during cleanup occurred. + */ + void cleanupIdentityData(OwnerId id) throws PresenceCheckException; + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/task/DocumentSubmitSyncTask.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/task/DocumentSubmitSyncTask.java new file mode 100644 index 000000000..ffd2f804b --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/task/DocumentSubmitSyncTask.java @@ -0,0 +1,48 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.enrollmentserver.task; + +import com.wultra.app.enrollmentserver.impl.service.document.DocumentProcessingBatchService; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Task to sync document submit status and data with the provider. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@Component +public class DocumentSubmitSyncTask { + + private final DocumentProcessingBatchService documentProcessingBatchService; + + public DocumentSubmitSyncTask(DocumentProcessingBatchService documentProcessingBatchService) { + this.documentProcessingBatchService = documentProcessingBatchService; + } + + /** + * Scheduled task to check in progress document submits at the target provider + */ + @Scheduled(cron = "${enrollment-server.document-verification.checkInProgressDocumentSubmits.cron:0/5 * * * * *}", zone = "UTC") + @SchedulerLock(name = "checkInProgressDocumentSubmits", lockAtLeastFor = "1s", lockAtMostFor = "5m") + public void checkInProgressDocumentSubmits() { + documentProcessingBatchService.checkInProgressDocumentSubmits(); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/task/DocumentSubmitVerificationSyncTask.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/task/DocumentSubmitVerificationSyncTask.java new file mode 100644 index 000000000..9a99e9ec5 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/task/DocumentSubmitVerificationSyncTask.java @@ -0,0 +1,48 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.enrollmentserver.task; + +import com.wultra.app.enrollmentserver.impl.service.verification.VerificationProcessingBatchService; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Task to check document submit verification result with the provider. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@Component +public class DocumentSubmitVerificationSyncTask { + + private final VerificationProcessingBatchService verificationProcessingBatchService; + + public DocumentSubmitVerificationSyncTask(VerificationProcessingBatchService verificationProcessingBatchService) { + this.verificationProcessingBatchService = verificationProcessingBatchService; + } + + /** + * Scheduled task to check document submit verifications at the target provider + */ + @Scheduled(cron = "${enrollment-server.document-verification.checkDocumentSubmitVerifications.cron:0/5 * * * * *}", zone = "UTC") + @SchedulerLock(name = "checkDocumentSubmitVerifications", lockAtLeastFor = "1s", lockAtMostFor = "5m") + public void checkDocumentSubmitVerifications() { + verificationProcessingBatchService.checkDocumentSubmitVerifications(); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/IProovConst.java b/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/IProovConst.java new file mode 100644 index 000000000..3c1ff2f71 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/IProovConst.java @@ -0,0 +1,32 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.presencecheck.iproov; + +/** + * Constants for iProov purposes + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +public class IProovConst { + + /** + * Session parameter name of the verification token + */ + public static final String VERIFICATION_TOKEN = "iproov-verification-token"; + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/config/IProovConfig.java b/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/config/IProovConfig.java new file mode 100644 index 000000000..8f54b1672 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/config/IProovConfig.java @@ -0,0 +1,71 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.presencecheck.iproov.config; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.wultra.core.rest.client.base.DefaultRestClient; +import com.wultra.core.rest.client.base.RestClient; +import com.wultra.core.rest.client.base.RestClientConfiguration; +import com.wultra.core.rest.client.base.RestClientException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; + +/** + * iProov configuration. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@ConditionalOnProperty(value = "enrollment-server.presence-check.provider", havingValue = "iproov") +@ComponentScan(basePackages = {"com.wultra.app.presencecheck"}) +@Configuration +public class IProovConfig { + + /** + * @return Object mapper bean specific to iProov json format + */ + @Bean("objectMapperIproov") + public ObjectMapper objectMapperIproov() { + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); + return mapper; + } + + /** + * Prepares REST client specific to iProov + * @param configProps Configuration properties + * @return REST client for iProov service API calls + */ + @Bean("restClientIProov") + public RestClient restClientIProov(IProovConfigProps configProps) throws RestClientException { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.HOST, configProps.getServiceHostname()); + headers.add(HttpHeaders.USER_AGENT, configProps.getServiceUserAgent()); + + RestClientConfiguration restClientConfiguration = configProps.getRestClientConfig(); + restClientConfiguration.setBaseUrl(configProps.getServiceBaseUrl()); + restClientConfiguration.setDefaultHttpHeaders(headers); + return new DefaultRestClient(restClientConfiguration); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/config/IProovConfigProps.java b/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/config/IProovConfigProps.java new file mode 100644 index 000000000..8a918cb80 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/config/IProovConfigProps.java @@ -0,0 +1,90 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.presencecheck.iproov.config; + +import com.wultra.app.presencecheck.iproov.model.api.ServerClaimRequest; +import com.wultra.core.rest.client.base.RestClientConfiguration; +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * iProov configuration properties. + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@ConditionalOnProperty(value = "enrollment-server.presence-check.provider", havingValue = "iproov") +@Configuration +@ConfigurationProperties(prefix = "enrollment-server.presence-check.iproov") +@Getter @Setter +public class IProovConfigProps { + + /** + * API key + */ + private String apiKey; + + /** + * API secret + */ + private String apiSecret; + + /** + * The assurance type of the claim + */ + private ServerClaimRequest.AssuranceTypeEnum assuranceType; + + /** + * The pre-defined risk profile to use (optional value) + */ + private String riskProfile; + + /** + * Service base URL + */ + private String serviceBaseUrl; + + /** + * Service hostname + */ + private String serviceHostname; + + /** + * Identification of the application calling the REST services passed as the User-Agent header + */ + private String serviceUserAgent; + + /** + * Enabled/disabled ensuring of valid user id value before sending + */ + private boolean ensureUserIdValueEnabled; + + /** + * REST client configuration + */ + private RestClientConfiguration restClientConfig; + + public void setRiskProfile(String riskProfile) { + // prevent blank value which is invalid and potentially hard to catch + this.riskProfile = StringUtils.isNotBlank(riskProfile) ? riskProfile : null; + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/provider/IProovPresenceCheckProvider.java b/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/provider/IProovPresenceCheckProvider.java new file mode 100644 index 000000000..feb68c0f9 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/provider/IProovPresenceCheckProvider.java @@ -0,0 +1,280 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.presencecheck.iproov.provider; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Strings; +import com.wultra.app.enrollmentserver.errorhandling.PresenceCheckException; +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; +import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import com.wultra.app.enrollmentserver.provider.PresenceCheckProvider; +import com.wultra.app.presencecheck.iproov.IProovConst; +import com.wultra.app.presencecheck.iproov.model.api.*; +import com.wultra.app.presencecheck.iproov.service.IProovRestApiService; +import com.wultra.core.rest.client.base.RestClientException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +import java.util.Base64; +import java.util.Calendar; + +/** + * Implementation of the {@link PresenceCheckProvider} with iProov (https://www.iproov.com/) + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@ConditionalOnProperty(value = "enrollment-server.presence-check.provider", havingValue = "iproov") +@Component +public class IProovPresenceCheckProvider implements PresenceCheckProvider { + + private static final Logger logger = LoggerFactory.getLogger(IProovPresenceCheckProvider.class); + + private final ObjectMapper objectMapper; + + private final IProovRestApiService iProovRestApiService; + + /** + * Service constructor. + * @param objectMapper Object mapper. + * @param iProovRestApiService REST API service for iProov calls. + */ + @Autowired + public IProovPresenceCheckProvider( + @Qualifier("objectMapperIproov") + ObjectMapper objectMapper, + IProovRestApiService iProovRestApiService) { + this.objectMapper = objectMapper; + this.iProovRestApiService = iProovRestApiService; + } + + @Override + public void initPresenceCheck(OwnerId id, Image photo) throws PresenceCheckException { + ResponseEntity responseEntityToken = callGenerateEnrolToken(id); + // FIXME temporary solution of repeated presence check initialization + // Deleting the iProov enrollment properly is not implemented yet, use random suffix to the current userId + if (responseEntityToken.getBody() != null && responseEntityToken.getBody().contains("already_enrolled")) { + logger.warn("Retrying the iProov enrollment with adapted userId"); + OwnerId adaptedId = new OwnerId(); + adaptedId.setUserId(id.getUserId() + Calendar.getInstance().toInstant().getEpochSecond()); + adaptedId.setActivationId(id.getActivationId()); + adaptedId.setTimestamp(id.getTimestamp()); + id = adaptedId; + responseEntityToken = callGenerateEnrolToken(id); + } + + if (responseEntityToken.getBody() == null) { + logger.error("Missing response body when generating an enrol token in iProov, " + id); + throw new PresenceCheckException("Unexpected error when generating an enrol token in iProov, " + id); + } + + if (!HttpStatus.OK.equals(responseEntityToken.getStatusCode())) { + logger.error("Failed to generate an enrol token, statusCode={}, responseBody='{}', {}", + responseEntityToken.getStatusCode(), responseEntityToken.getBody(), id); + throw new PresenceCheckException("Unable to init a presence check due to a service error"); + } + + ClaimResponse claimResponse = parseResponse(responseEntityToken.getBody(), ClaimResponse.class); + String token = claimResponse.getToken(); + + ResponseEntity responseEntityEnrol; + try { + responseEntityEnrol = iProovRestApiService.enrolUserImageForToken(token, photo); + } catch (RestClientException e) { + logger.error("Failed to enrol a user image to iProov, statusCode={}, responseBody='{}', {}", + responseEntityToken.getStatusCode(), responseEntityToken.getBody(), id); + throw new PresenceCheckException("Unable to init a presence check due to a service error"); + } catch (Exception e) { + logger.error("Unexpected error when enrolling a user image to iProov, " + id, e); + throw new PresenceCheckException("Unexpected error when initializing a presence check"); + } + + if (responseEntityEnrol.getBody() == null) { + logger.error("Missing response body when enrolling a user image to iProov, " + id); + throw new PresenceCheckException("Unexpected error when initializing a presence check"); + } + + EnrolResponse enrolResponse = parseResponse(responseEntityEnrol.getBody(), EnrolResponse.class); + if (!enrolResponse.isSuccess()) { + logger.error("Not successful enrol of a user image to iProov, " + id); + throw new PresenceCheckException("Unable to init a presence check"); + } + } + + @Override + public SessionInfo startPresenceCheck(OwnerId id) throws PresenceCheckException { + ResponseEntity responseEntity; + try { + responseEntity = iProovRestApiService.generateVerificationToken(id); + } catch (RestClientException e) { + logger.error("Failed to generate a verification token, statusCode={}, responseBody='{}', {}", + e.getStatusCode(), e.getResponse(), id); + throw new PresenceCheckException("Unable to start a presence check due to a service error"); + } catch (Exception e) { + logger.error("Unexpected error when generating a verification token in iProov, " + id, e); + throw new PresenceCheckException("Unexpected error when starting a presence check"); + } + + if (responseEntity.getBody() == null) { + logger.error("Missing response body when generating a verification token in iProov, " + id); + throw new PresenceCheckException("Unexpected error when generating a verification token in iProov, " + id); + } + + ClaimResponse claimResponse = parseResponse(responseEntity.getBody(), ClaimResponse.class); + String token = claimResponse.getToken(); + + SessionInfo sessionInfo = new SessionInfo(); + sessionInfo.getSessionAttributes().put(IProovConst.VERIFICATION_TOKEN, token); + + return sessionInfo; + } + + @Override + public PresenceCheckResult getResult(OwnerId id, SessionInfo sessionInfo) throws PresenceCheckException { + String token = (String) sessionInfo.getSessionAttributes().get(IProovConst.VERIFICATION_TOKEN); + if (Strings.isNullOrEmpty(token)) { + throw new PresenceCheckException("Missing a token value for verification validation in iProov, " + id); + } + + ResponseEntity responseEntity = null; + try { + responseEntity = iProovRestApiService.validateVerification(id, token); + } catch (RestClientException e) { + logger.warn( + String.format("Failed REST call to validate a verification in iProov, statusCode=%s, responseBody='%s', %s", + e.getStatusCode(), + e.getResponse(), + id), + e + ); + PresenceCheckResult result = new PresenceCheckResult(); + if (HttpStatus.BAD_REQUEST.equals(e.getStatusCode())) { + ClientErrorResponse clientErrorResponse = parseResponse(e.getResponse(), ClientErrorResponse.class); + if (ClientErrorResponse.ErrorEnum.INVALID_TOKEN.equals(clientErrorResponse.getError())) { + // TODO same response when validating the verification using same token repeatedly + result.setStatus(PresenceCheckStatus.IN_PROGRESS); + } else { + result.setStatus(PresenceCheckStatus.FAILED); + result.setErrorDetail(e.getResponse()); + } + } else { + result.setStatus(PresenceCheckStatus.FAILED); + result.setErrorDetail(e.getResponse()); + } + return result; + } catch (Exception e) { + logger.error("Unexpected error when validating a verification in iProov, " + id, e); + throw new PresenceCheckException("Unexpected error when getting a presence check result"); + } + + if (responseEntity == null || responseEntity.getBody() == null) { + logger.error("Missing response body when validating a verification in iProov, " + id); + throw new PresenceCheckException("Unexpected error when getting a presence check result"); + } + + PresenceCheckResult result = new PresenceCheckResult(); + + ClaimValidateResponse response = parseResponse(responseEntity.getBody(), ClaimValidateResponse.class); + if (response.isPassed()) { + result.setStatus(PresenceCheckStatus.ACCEPTED); + + String frameJpeg = response.getFrame(); + frameJpeg = unescapeSlashes(frameJpeg); + byte[] photoData = Base64.getDecoder().decode(frameJpeg); + + Image photo = new Image(); + photo.setFilename("person_photo_from_id.jpg"); + photo.setData(photoData); + + result.setPhoto(photo); + } else { + result.setStatus(PresenceCheckStatus.REJECTED); + result.setRejectReason(response.getReason()); + } + return result; + } + + @Override + public void cleanupIdentityData(OwnerId id) throws PresenceCheckException { + ResponseEntity responseEntity; + try { + responseEntity = iProovRestApiService.deleteUserPersona(id); + } catch (RestClientException e) { + logger.warn( + String.format("Failed REST call to delete a user persona from iProov, statusCode=%s, responseBody='%s', %s", + e.getStatusCode(), + e.getResponse(), + id), + e + ); + throw new PresenceCheckException("Unable to cleanup identity data"); + } catch (Exception e) { + logger.error("Unexpected error when deleting a user persona in iProov, " + id, e); + throw new PresenceCheckException("Unexpected error when cleaning up identity data"); + } + + if (responseEntity.getBody() == null) { + logger.error("Missing response body when validating a verification in iProov, " + id); + throw new PresenceCheckException("Unexpected error when cleaning up identity data"); + } + + UserResponse userResponse = parseResponse(responseEntity.getBody(), UserResponse.class); + logger.info("Deleted a user persona in iProov, status={}, {}", userResponse.getStatus(), id); + } + + private ResponseEntity callGenerateEnrolToken(OwnerId id) throws PresenceCheckException { + ResponseEntity responseEntityToken; + try { + responseEntityToken = iProovRestApiService.generateEnrolToken(id); + } catch (RestClientException e) { + if (e.getStatusCode().is4xxClientError()) { + responseEntityToken = new ResponseEntity<>(e.getResponse(), e.getStatusCode()); + } else { + logger.warn("Failed REST call to generate an enrol token in iProov, " + id, e); + throw new PresenceCheckException("Unable to init a presence check due to a REST call failure"); + } + } catch (Exception e) { + logger.error("Unexpected error when generating an enrol token in iProov, " + id, e); + throw new PresenceCheckException("Unexpected error when initializing a presence check"); + } + return responseEntityToken; + } + + private T parseResponse(String body, Class cls) throws PresenceCheckException { + try { + return objectMapper.readValue(body, cls); + } catch (JsonProcessingException e) { + logger.error("Unable to parse JSON response {} to {}", body, cls); + throw new PresenceCheckException("Unable to process a response from the REST service"); + } + } + + private String unescapeSlashes(String value) { + return value.replace("\\", ""); + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/service/IProovRestApiService.java b/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/service/IProovRestApiService.java new file mode 100644 index 000000000..03529100f --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/presencecheck/iproov/service/IProovRestApiService.java @@ -0,0 +1,229 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.presencecheck.iproov.service; + +import com.wultra.app.enrollmentserver.model.integration.Image; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.presencecheck.iproov.config.IProovConfigProps; +import com.wultra.app.presencecheck.iproov.model.api.ClaimValidateRequest; +import com.wultra.app.presencecheck.iproov.model.api.EnrolImageBody; +import com.wultra.app.presencecheck.iproov.model.api.ServerClaimRequest; +import com.wultra.core.rest.client.base.RestClient; +import com.wultra.core.rest.client.base.RestClientException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.util.regex.Pattern; + +/** + * Implementation of the REST service to iProov (https://www.iproov.com/) + * + *

+ * The userId is filled with a secured user identification + *

    + *
  • optimizes API costs which are based on unique users
  • + *
  • hides potentially sensitive data from leaking at the external provider side
  • + *
+ *

+ * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@ConditionalOnProperty(value = "enrollment-server.presence-check.provider", havingValue = "iproov") +@Service +public class IProovRestApiService { + + private static final Logger logger = LoggerFactory.getLogger(IProovRestApiService.class); + + public static final MultiValueMap EMPTY_QUERY_PARAMS = new LinkedMultiValueMap<>(); + + public static final ParameterizedTypeReference STRING_TYPE_REFERENCE = new ParameterizedTypeReference<>() { }; + + /** + * Max length of the user id value defined by iProov + */ + public static final int USER_ID_MAX_LENGTH = 256; + + /** + * Regex used by iProov on user id values + */ + public static final Pattern USER_ID_REGEX_PATTERN = Pattern.compile("[a-zA-Z0-9'+_@.-]{1,256}"); + + public static final String I_PROOV_RESOURCE_CONTEXT = "presence_check/"; + + /** + * Configuration properties. + */ + private final IProovConfigProps configProps; + + /** + * REST client for iProov calls. + */ + private final RestClient restClient; + + /** + * Service constructor. + * + * @param configProps Configuration properties. + * @param restClient REST template for IProov calls. + */ + @Autowired + public IProovRestApiService( + IProovConfigProps configProps, + @Qualifier("restClientIProov") RestClient restClient) { + this.configProps = configProps; + this.restClient = restClient; + } + + /** + * Generates an enrolment token for a new user to enrol the service + * + * @param id Owner identification. + * @return Response entity with the result json + */ + public ResponseEntity generateEnrolToken(OwnerId id) throws RestClientException { + ServerClaimRequest request = createServerClaimRequest(id); + + return restClient.post("/claim/enrol/token", request, STRING_TYPE_REFERENCE); + } + + /** + * Enrols a user through a photo that is trusted. + * + * @param token An enrolment token value + * @param photo Trusted photo of a person + * @return Response entity with the result json + */ + public ResponseEntity enrolUserImageForToken(String token, Image photo) throws RestClientException { + MultipartBodyBuilder bodyBuilder = new MultipartBodyBuilder(); + bodyBuilder.part("api_key", configProps.getApiKey()); + bodyBuilder.part("secret", configProps.getApiSecret()); + bodyBuilder.part("rotation", 0); + bodyBuilder.part("source", EnrolImageBody.SourceEnum.SELFIE.toString()); + bodyBuilder.part("image", new ByteArrayResource(photo.getData()) { + + @Override + public String getFilename() { + return photo.getFilename(); + } + + }); + bodyBuilder.part("token", token); + + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setContentType(MediaType.MULTIPART_FORM_DATA); + + return restClient.post("/claim/enrol/image", bodyBuilder.build(), EMPTY_QUERY_PARAMS, httpHeaders, STRING_TYPE_REFERENCE); + } + + /** + * Generates a token for initializing a person verification process + * + * @param id Owner identification. + * @return Response entity with the result json + */ + public ResponseEntity generateVerificationToken(OwnerId id) throws RestClientException { + ServerClaimRequest request = createServerClaimRequest(id); + + return restClient.post("/claim/verify/token", request, STRING_TYPE_REFERENCE); + } + + /** + * Validates the result of a person verification process + * + * @param id Owner identification. + * @param token Token value used for initializing the person verification process + * @return Response entity with the result json + */ + public ResponseEntity validateVerification(OwnerId id, String token) throws RestClientException { + ClaimValidateRequest request = new ClaimValidateRequest(); + request.setApiKey(configProps.getApiKey()); + request.setSecret(configProps.getApiSecret()); + request.setClient("Wultra Enrollment Server, activationId: " + id.getActivationId()); // TODO value from the device + request.setIp("192.168.1.1"); // TODO deprecated but still required + request.setRiskProfile(configProps.getRiskProfile()); + request.setToken(token); + + String userId = getUserId(id); + request.setUserId(userId); + + return restClient.post("/claim/verify/validate", request, STRING_TYPE_REFERENCE); + } + + /** + * Deletes user person data + * + * @param id Owner identification. + * @return Response entity with the result json + */ + public ResponseEntity deleteUserPersona(OwnerId id) throws RestClientException { + // TODO implement this, oauth call on DELETE /users/activationId + logger.warn("Not deleting user in iProov (not implemented yet), {}", id); + return ResponseEntity.ok("{\n" + + " \"user_id\": \"" + id.getUserIdSecured() + "\",\n" + + " \"name\": \"user name\",\n" + + " \"status\": \"Deleted\"\n" + + "}"); + } + + private ServerClaimRequest createServerClaimRequest(OwnerId id) { + ServerClaimRequest request = new ServerClaimRequest(); + request.setApiKey(configProps.getApiKey()); + request.setSecret(configProps.getApiSecret()); + request.setAssuranceType(configProps.getAssuranceType()); + request.setResource(I_PROOV_RESOURCE_CONTEXT + id.getActivationId()); + request.setRiskProfile(configProps.getRiskProfile()); + + String userId = getUserId(id); + request.setUserId(userId); + + return request; + } + + public static String ensureValidUserIdValue(String value) { + if (value.length() > USER_ID_MAX_LENGTH) { + value = value.substring(0, USER_ID_MAX_LENGTH); + logger.error("The userId value: '{}', was too long for iProov, shortened to {} characters", value, USER_ID_MAX_LENGTH); + } + if (!USER_ID_REGEX_PATTERN.matcher(value).matches()) { + logger.error("The userId value: '{}', does not match the iProov regex pattern", value); + throw new IllegalArgumentException("Invalid userId value for iProov call"); + } + return value; + } + + private String getUserId(OwnerId id) { + if (configProps.isEnsureUserIdValueEnabled()) { + return ensureValidUserIdValue(id.getUserIdSecured()); + } else { + return id.getUserIdSecured(); + } + } + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/presencecheck/mock/MockConst.java b/enrollment-server/src/main/java/com/wultra/app/presencecheck/mock/MockConst.java new file mode 100644 index 000000000..0c454e288 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/presencecheck/mock/MockConst.java @@ -0,0 +1,32 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.presencecheck.mock; + +/** + * Constants for mock purposes + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +public class MockConst { + + /** + * Session parameter name of the verification token + */ + public static final String VERIFICATION_TOKEN = "mock-verification-token"; + +} diff --git a/enrollment-server/src/main/java/com/wultra/app/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java b/enrollment-server/src/main/java/com/wultra/app/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java new file mode 100644 index 000000000..0e16546ab --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java @@ -0,0 +1,102 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.presencecheck.mock.provider; + +import com.wultra.app.enrollmentserver.errorhandling.PresenceCheckException; +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; +import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import com.wultra.app.enrollmentserver.provider.PresenceCheckProvider; +import com.wultra.app.presencecheck.mock.MockConst; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; + +/** + * Mock implementation of the {@link PresenceCheckProvider} + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@ConditionalOnProperty(value = "enrollment-server.presence-check.provider", havingValue = "mock") +@Component +public class WultraMockPresenceCheckProvider implements PresenceCheckProvider { + + private static final Logger logger = LoggerFactory.getLogger(WultraMockPresenceCheckProvider.class); + + /** + * Service constructor. + */ + public WultraMockPresenceCheckProvider() { + logger.warn("Using mocked version of {}", PresenceCheckProvider.class.getName()); + } + + @Override + public void initPresenceCheck(OwnerId id, Image photo) throws PresenceCheckException { + logger.info("Mock - initialized presence check with a photo, {}", id); + } + + @Override + public SessionInfo startPresenceCheck(OwnerId id) throws PresenceCheckException { + String token = UUID.randomUUID().toString(); + + SessionInfo sessionInfo = new SessionInfo(); + sessionInfo.getSessionAttributes().put(MockConst.VERIFICATION_TOKEN, token); + + logger.info("Mock - started presence check, {}", id); + + return sessionInfo; + } + + @Override + public PresenceCheckResult getResult(OwnerId id, SessionInfo sessionInfo) throws PresenceCheckException { + String selfiePhotoPath = "/images/specimen_photo.jpg"; + Image photo = new Image(); + try (InputStream is = WultraMockPresenceCheckProvider.class.getResourceAsStream(selfiePhotoPath)) { + if (is != null) { + photo.setData(is.readAllBytes()); + } + } catch (IOException e) { + logger.error("Unable to read image data from: " + selfiePhotoPath); + } + if (photo.getData() == null) { + photo.setData(new byte[]{}); + } + photo.setFilename("selfie_photo.jpg"); + + PresenceCheckResult result = new PresenceCheckResult(); + result.setStatus(PresenceCheckStatus.ACCEPTED); + result.setPhoto(photo); + + logger.info("Mock - provided accepted result, {}", id); + + return result; + } + + @Override + public void cleanupIdentityData(OwnerId id) throws PresenceCheckException { + logger.info("Mock - cleaned up identity data, {}", id); + } + +} diff --git a/enrollment-server/src/main/resources/api/api-iproov.json b/enrollment-server/src/main/resources/api/api-iproov.json new file mode 100644 index 000000000..6f18613fa --- /dev/null +++ b/enrollment-server/src/main/resources/api/api-iproov.json @@ -0,0 +1,1612 @@ +{ + "swagger": "2.0", + "info": { + "title": "iProov REST API", + "description": "This is the iProov API documentation guidelines, for simple integration of the iProov system\n# Introduction\nTo use the iProov service, you will need a valid account on [portal.iproov.com](https://portal.iproov.com), where you\nwill be able to set up your Service Provider API credentials.\n\nFor a full description of how to use these APIs in conjunction with your user journey and the iProov service, please\nrefer to [portal.iproov.com/documentation](https://portal.iproov.com/documentation).\n# Verifier\nTo use Verifier, you need to generate a token with the `/claim/TYPE/token` endpoint, where TYPE is `verify` or `enrol`.\nOnce the claim has finished, you should then make a server to server validation call to the `/claim/TYPE/validate`\nendpoint to ensure there has been no tampering with the result from the client.\n# Service Provider API Key and Secret Authentication\nGeneral operations are secured by the `api_key` and `secret` parameters in the request body. See the individual\noperation sample request bodies for example usage. The additional schemes below are used for Management and User APIs.\n", + "contact": { + "name": "iProov API Team" + }, + "version": "2.11.0", + "x-logo": { + "url": "logo.svg" + } + }, + "host": "secure.iproov.me", + "basePath": "/api/v2", + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/availability": { + "get": { + "tags": [ + "Management" + ], + "summary": "Check the platform is available", + "description": "Check the requested platform is available.", + "operationId": "platformAvailable", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "api_key", + "in": "query", + "description": "The API key of the service provider", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/AvailabilityResponse" + } + }, + "400": { + "description": "Bad Request or Client Error", + "schema": { + "$ref": "#/definitions/ClientErrorResponse" + } + } + } + } + }, + "/claim/{token}/invalidate": { + "post": { + "tags": [ + "Management" + ], + "summary": "Invalidates a token", + "description": "Invalidates a token", + "operationId": "invalidateToken", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "token", + "in": "path", + "description": "The token to invalidate", + "required": true, + "type": "string" + }, + { + "name": "claim_request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/InvalidateClaimRequest" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/InvalidateClaimResponse" + } + }, + "400": { + "description": "Bad Request or Client Error", + "schema": { + "$ref": "#/definitions/ClientErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "403": { + "description": "Access Denied", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "500": { + "description": "Error", + "schema": { + "$ref": "#/definitions/ServerErrorResponse" + } + } + } + } + }, + "/claim/verify/token": { + "post": { + "tags": [ + "Verify" + ], + "summary": "Generate a verification token for an existing user to verify with a service provider", + "description": "Generate a verification token for an existing user to verify with a service provider", + "operationId": "userVerifyServerToken", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "claim_request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ServerClaimRequest" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/ClaimResponse" + } + }, + "400": { + "description": "Bad Request or Client Error", + "schema": { + "$ref": "#/definitions/ClientErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "403": { + "description": "Access Denied", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "429": { + "description": "Rate Limit Exceeded", + "schema": { + "$ref": "#/definitions/RateLimitExceededResponse" + } + }, + "500": { + "description": "Error", + "schema": { + "$ref": "#/definitions/ServerErrorResponse" + } + } + } + } + }, + "/claim/verify/validate": { + "post": { + "tags": [ + "Verify" + ], + "summary": "Validate the verification was successful", + "description": "Validate the verification was successful.\n\nThis endpoint can only be called once after a full end to end transaction has been completed. Subsequent calls will emit\n a 400 response.\n\nNote that we strongly recommend you call this endpoint to avoid any man in the middle attacks between the user trying\nto authenticate and the iProov service (including by the user attempting to log in!).", + "operationId": "userVerifyValidate", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "validate_request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ClaimValidateRequest" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/ClaimValidateResponse" + } + }, + "400": { + "description": "Bad Request or Client Error", + "schema": { + "$ref": "#/definitions/ClientErrorResponse" + } + }, + "403": { + "description": "Access Denied", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Error", + "schema": { + "$ref": "#/definitions/ServerErrorResponse" + } + } + } + } + }, + "/{api_key}/access_token": { + "post": { + "tags": [ + "Management" + ], + "summary": "Allows a registered application to obtain an OAuth 2 Bearer Token", + "description": "Allows a registered application to obtain an OAuth 2 Bearer Token, which can be used to make API\nrequests on an application\u2019s own behalf, without a user context. Currently only client credentials are supported.\nNote that the OAuth username and password are to be provided via the Authorization header in Basic format. These are\ndistinct to the service provider API key and secret and supplied separately.", + "operationId": "generateToken", + "consumes": [ + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "grant_type", + "in": "formData", + "description": "The type of grant being requested by the application", + "required": true, + "type": "string" + }, + { + "name": "scope", + "in": "formData", + "description": "Optionally define the scopes being required (omitting grants all allowed scopes)", + "required": false, + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "api_key", + "in": "path", + "description": "The API key of the service provider", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/AuthTokenResponse" + } + }, + "400": { + "description": "Bad Request or Client Error", + "schema": { + "$ref": "#/definitions/ClientErrorResponse" + } + }, + "403": { + "description": "Access Denied", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Server Error", + "schema": { + "$ref": "#/definitions/ServerErrorResponse" + } + } + }, + "security": [ + { + "basicAuth": [] + } + ] + } + }, + "/claim/enrol/token": { + "post": { + "tags": [ + "Enrol" + ], + "summary": "Generate an enrolment token for a new user to enrol with a service provider", + "description": "Generate an enrolment token for a new user to enrol with a service provider.\n\n - For **Capture Enrol**, this token should be used to launch the SDK.\n - For **Photo Enrol**, this token should be used for the Enrol Image API below.", + "operationId": "userEnrolServerToken", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "claim_request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ServerClaimRequest" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/ClaimResponse" + } + }, + "400": { + "description": "Bad Request or Client Error", + "schema": { + "$ref": "#/definitions/ClientErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "403": { + "description": "Access Denied", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "429": { + "description": "Rate Limit Exceeded", + "schema": { + "$ref": "#/definitions/RateLimitExceededResponse" + } + }, + "500": { + "description": "Error", + "schema": { + "$ref": "#/definitions/ServerErrorResponse" + } + } + }, + "x-redoc-order": 1 + } + }, + "/users/{user_id}/activate": { + "post": { + "tags": [ + "User" + ], + "summary": "Activate a user's persona for a service provider", + "description": "Activate a user's persona for a service provider", + "operationId": "userActivate", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "description": "The asserted identifier of the user", + "required": true, + "type": "string" + }, + { + "name": "claim_request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ChangeStateRequest" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/UserResponse" + } + }, + "400": { + "description": "Bad Request or Client Error", + "schema": { + "$ref": "#/definitions/ClientErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "403": { + "description": "Access Denied", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "500": { + "description": "Error", + "schema": { + "$ref": "#/definitions/ServerErrorResponse" + } + } + }, + "security": [ + { + "oauth2": [ + "user-write-status" + ] + } + ], + "x-redoc-order": 1 + } + }, + "/claim/enrol/image": { + "post": { + "tags": [ + "Enrol" + ], + "summary": "Enrol a user through a trusted photo", + "description": "Enrols a user through a photo that is trusted by the service provider to be a likeness of the user.\nTo call this endpoint you need to perform a previous API call:\n * Firstly get a token that is associated with a user ID\n * Secondly upload the image with this endpoint\n\nAccepted media types are jpeg and png images. JP2 image support is currently in progress.\n\nNote that due to the security implications of allowing users to enrol with just images this endpoint is restricted on a\nper service provider basis", + "operationId": "userEnrolImage", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "api_key", + "in": "formData", + "description": "The API key of the service provider", + "required": true, + "type": "string" + }, + { + "name": "secret", + "in": "formData", + "description": "The API secret for the service provider", + "required": true, + "type": "string", + "format": "password" + }, + { + "name": "rotation", + "in": "formData", + "description": "The rotation of the image (currently only 0 is supported)", + "required": true, + "type": "integer" + }, + { + "name": "image", + "in": "formData", + "description": "The image to enrol the user with", + "required": true, + "type": "file" + }, + { + "name": "token", + "in": "formData", + "description": "The enrolment token for the user", + "required": true, + "type": "string" + }, + { + "name": "source", + "in": "formData", + "description": "The source of the image (i.e. Electronic ID or Optical ID)", + "required": false, + "type": "string", + "default": "eid", + "enum": [ + "eid", + "oid", + "selfie" + ] + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/EnrolResponse" + } + }, + "400": { + "description": "Bad Request or Client Error", + "schema": { + "$ref": "#/definitions/ClientErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "500": { + "description": "Error", + "schema": { + "$ref": "#/definitions/ServerErrorResponse" + } + } + }, + "x-redoc-order": 2 + } + }, + "/users/{user_id}/suspend": { + "post": { + "tags": [ + "User" + ], + "summary": "Suspend a user's persona from accessing a service provider", + "description": "Suspend a user's persona from accessing a service provider", + "operationId": "userSuspend", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "description": "The asserted identifier of the user", + "required": true, + "type": "string" + }, + { + "name": "claim_request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ChangeStateRequest" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/UserResponse" + } + }, + "400": { + "description": "Bad Request or Client Error", + "schema": { + "$ref": "#/definitions/ClientErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "403": { + "description": "Access Denied", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "500": { + "description": "Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "oauth2": [ + "user-write-status" + ] + } + ], + "x-redoc-order": 2 + } + }, + "/claim/enrol/validate": { + "post": { + "tags": [ + "Enrol" + ], + "summary": "Validate the enrolment was successful and activate the user", + "description": "Validate the enrolment was successful and activate the user.\n\nThis endpoint can only be called once after a full end to end transaction has been completed. Subsequent calls will give a 400.\n\nNote that without this endpoint being called the user will not be activated and therefore will be unable to use iProov\nunless they were enrolled using the photo enrol API.", + "operationId": "userEnrolValidate", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "validate_request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/EnrolValidateRequest" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/ClaimValidateResponse" + } + }, + "400": { + "description": "Bad Request or Client Error", + "schema": { + "$ref": "#/definitions/ClientErrorResponse" + } + }, + "403": { + "description": "Access Denied", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Error", + "schema": { + "$ref": "#/definitions/ServerErrorResponse" + } + } + }, + "x-redoc-order": 3 + } + }, + "/users/{existing_user_id}": { + "get": { + "tags": [ + "User" + ], + "summary": "Get a user's persona for a service provider", + "description": "Get a user's persona for a service provider", + "operationId": "userGet", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "existing_user_id", + "in": "path", + "description": "The asserted identifier of the user", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/UserResponse" + } + }, + "400": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ClientErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "403": { + "description": "Access Denied", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "500": { + "description": "Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "oauth2": [ + "user-read" + ] + } + ], + "x-redoc-order": 4 + }, + "put": { + "tags": [ + "User" + ], + "summary": "Update a user's identifier for a service provider", + "description": "Update a user's identifier for a service provider. Note one (or both) of name and new user id is required.", + "operationId": "userUpdate", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "existing_user_id", + "in": "path", + "description": "The asserted identifier of the user", + "required": true, + "type": "string" + }, + { + "name": "claim_request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/UserResponse" + } + }, + "400": { + "description": "Bad Request or Client Error", + "schema": { + "$ref": "#/definitions/ClientErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "403": { + "description": "Access Denied", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "500": { + "description": "Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "oauth2": [ + "user-write" + ] + } + ], + "x-redoc-order": 3 + }, + "delete": { + "tags": [ + "User" + ], + "summary": "Deletes a user's persona from a service provider", + "description": "Deletes a user's persona from a service provider", + "operationId": "userDelete", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "existing_user_id", + "in": "path", + "description": "The asserted identifier of the user", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/UserResponse" + } + }, + "400": { + "description": "Bad Request or Client Error", + "schema": { + "$ref": "#/definitions/ClientErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "403": { + "description": "Access Denied", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "500": { + "description": "Error", + "schema": { + "$ref": "#/definitions/ServerErrorResponse" + } + } + }, + "security": [ + { + "oauth2": [ + "user-write" + ] + } + ], + "x-redoc-order": 5 + } + } + }, + "definitions": { + "ErrorResponse": { + "description": "The Error response contains a error constant which can be used to split up responses and can be\nconverted to translatable error messages. The error description returns a more descriptive error response that is\nlargely designed for developers to get detailed information for failures.", + "required": [ + "error", + "error_description" + ], + "properties": { + "error": { + "type": "string" + }, + "error_description": { + "type": "string" + } + }, + "type": "object" + }, + "ClientErrorResponse": { + "description": "Missing data in the request,\nor the state of the requested entity object is such that the operation could not succeed.", + "required": [ + "error", + "error_description" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "invalid_agent", + "invalid_user_id", + "invalid_token", + "invalid_validation", + "invalid_reason", + "invalid_risk_appetite", + "invalid_ip", + "invalid_grant", + "invalid_request", + "invalid_client", + "invalid_scope", + "unsupported_grant_type", + "missing_data", + "no_user", + "inactive_user" + ] + }, + "error_description": { + "type": "string" + } + }, + "type": "object" + }, + "InvalidCredentialErrorResponse": { + "description": "One of: invalid OAuth token, expired OAuth token, invalid credentials, API Key and/or Secret is\n * missing, malformed, or incorrect.", + "required": [ + "error", + "error_description" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "invalid_key", + "invalid_oauth", + "token_quota_exceeded" + ] + }, + "error_description": { + "type": "string" + } + }, + "type": "object" + }, + "RateLimitExceededResponse": { + "description": "Server Error", + "required": [ + "error", + "error_description" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "too_many_requests" + ] + }, + "error_description": { + "type": "string" + } + }, + "type": "object" + }, + "ServerErrorResponse": { + "description": "Server Error", + "required": [ + "error", + "error_description" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "server_error" + ] + }, + "error_description": { + "type": "string" + } + }, + "type": "object" + }, + "AvailabilityResponse": { + "required": [ + "up" + ], + "properties": { + "up": { + "description": "Whether iProov service is available or not.", + "type": "boolean", + "example": true + } + }, + "type": "object" + }, + "EnrolResponse": { + "required": [ + "success", + "token", + "user_id" + ], + "properties": { + "token": { + "type": "string", + "example": "31706131726336496d655177346e55503279616b69547344446e5258684c7542" + }, + "user_id": { + "type": "string", + "example": "enquiries@iproov.com" + }, + "success": { + "type": "boolean", + "example": true + } + }, + "type": "object" + }, + "ServerClaimRequest": { + "required": [ + "api_key", + "secret", + "resource" + ], + "properties": { + "api_key": { + "description": "The API key of the service provider", + "type": "string" + }, + "secret": { + "description": "The API secret for the service provider", + "type": "string", + "format": "password" + }, + "resource": { + "description": "The resource being accessed (e.g. URL)", + "type": "string" + }, + "assurance_type": { + "description": "The assurance type of the claim", + "type": "string", + "default": "genuine_presence", + "enum": [ + "genuine_presence", + "liveness" + ] + }, + "success_url": { + "description": "The URL to redirect to on success. Note: This field has been deprecated and will be omitted in the next release.", + "type": "string", + "deprecated": true + }, + "failure_url": { + "description": "The URL to redirect to on failure. Note: This field has been deprecated and will be omitted in the next release.", + "type": "string", + "deprecated": true + }, + "abort_url": { + "description": "The URL to redirect to on user abort. Note: This field has been deprecated and will be omitted in the next release.", + "type": "string", + "deprecated": true + }, + "user_id": { + "description": "The asserted identifier of the user.", + "type": "string", + "example": "enquiries@iproov.com" + }, + "risk_profile": { + "description": "The pre-defined risk profile to use for this claim.", + "type": "string" + } + }, + "type": "object" + }, + "ClaimResponse": { + "required": [ + "fallback", + "token", + "primary", + "user_id", + "pod" + ], + "properties": { + "fallback": { + "description": "The fallback gives relevant fallback information. It contains a 'type' key and a 'message' key\nthat provides more information about the fallback to be optionally displayed to the user", + "type": "array", + "items": { + "$ref": "#/definitions/FallbackDefinition" + } + }, + "token": { + "description": "The token should be referenced if there are issues with an individuals claim and is used as a\ntransaction id", + "type": "string", + "example": "31706131726336496d655177346e55503279616b69547344446e5258684c7542" + }, + "primary": { + "type": "string" + }, + "user_id": { + "description": "The user id of the user associated with the token. Null if no user is associated with the\ntoken.", + "type": "string", + "example": "enquiries@iproov.com" + }, + "pod": { + "description": "The pod that will be used for the claim.", + "type": "string", + "example": "edge02.eu4" + }, + "redirect_domain": { + "description": "If the service provider has a dedicated landing page hosted at iProov this will contain the URL\nto redirect to.", + "type": "string" + }, + "risk_profile": { + "description": "If the service provider has specified a risk profile then it will be used.", + "type": "string" + } + }, + "type": "object" + }, + "FallbackDefinition": { + "required": [ + "type", + "message" + ], + "properties": { + "type": { + "description": "The type of the fallback to be used", + "type": "string", + "example": "Info" + }, + "message": { + "description": "The message to show the user about the fallback", + "type": "string", + "example": "Sorry, only iProov is available" + } + }, + "type": "object" + }, + "ClientClaimRequest": { + "required": [ + "api_key", + "resource", + "client" + ], + "properties": { + "api_key": { + "description": "The API key of the service provider", + "type": "string" + }, + "client": { + "description": "Fingerprint or client identifier (e.g. User Agent)", + "type": "string" + }, + "resource": { + "description": "The resource being accessed (e.g. URL)", + "type": "string" + }, + "success_url": { + "description": "The URL to redirect to on success. Note: This field has been deprecated and will be omitted in the next release.", + "type": "string", + "deprecated": true + }, + "failure_url": { + "description": "The URL to redirect to on failure. Note: This field has been deprecated and will be omitted in the next release.", + "type": "string", + "deprecated": true + }, + "abort_url": { + "description": "The URL to redirect to on user abort. Note: This field has been deprecated and will be omitted in the next release.", + "type": "string", + "deprecated": true + } + }, + "type": "object" + }, + "EnrolAssociateUserRequest": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/AssociateUserRequest" + }, + { + "properties": { + "name": { + "description": "The display name of the user. If not provided it is parsed from the user id.", + "type": "string" + } + } + } + ] + }, + "ClaimValidateResponse": { + "required": [ + "passed", + "token", + "type" + ], + "properties": { + "passed": { + "type": "boolean", + "example": true + }, + "token": { + "type": "string", + "example": "31706131726336496d655177346e55503279616b69547344446e5258684c7542" + }, + "type": { + "type": "string", + "example": "verify" + }, + "frame_available": { + "description": "Present and True if there is frame available for returning to the integrator.\n\nEnabled on a per service provider basis. Contact support@iproov.com to request this functionality.", + "type": "string", + "example": false + }, + "frame": { + "description": "If `frame_available` is present and True, a base64 encoded representation of the frame.", + "type": "string" + }, + "frame_jpeg": { + "description": "a base64 encoded representation of the frame in JPEG format.", + "type": "string" + }, + "iso_19794_5": { + "description": "If `frame_available` is present and True, a base64 encoded string that contains an ISO 19794_5\ncompliant image.", + "type": "string" + }, + "reason": { + "description": "The failure reason (enabled on a per service provider basis)", + "type": "string", + "example": "Please Keep Still" + }, + "risk_profile": { + "description": "The pre-defined risk profile to use for this claim", + "type": "string" + }, + "assurance_type": { + "description": "Which assurance type was utilized by the transaction", + "type": "string", + "default": "genuine_presence", + "enum": [ + "genuine_presence", + "liveness" + ] + } + }, + "type": "object" + }, + "EnrolValidateRequest": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/ClaimValidateRequest" + }, + { + "properties": { + "activate": { + "description": "Activate the user's account (default: true). User will be SUSPENDED if false.", + "type": "boolean" + } + } + } + ] + }, + "InvalidateClaimResponse": { + "required": [ + "claim_aborted", + "user_informed" + ], + "properties": { + "claim_aborted": { + "description": "True if claim was invalidated.", + "type": "boolean", + "example": true + }, + "user_informed": { + "description": "True if the user was successfully informed that the claim has been invalidated.", + "type": "boolean", + "example": true + } + }, + "type": "object" + }, + "InvalidateClaimRequest": { + "required": [ + "reason" + ], + "properties": { + "reason": { + "description": "The reason to be displayed to the user for the invalidation of the claim", + "type": "string" + } + }, + "type": "object" + }, + "AssociateUserRequest": { + "required": [ + "api_key", + "secret", + "user_id", + "token" + ], + "properties": { + "api_key": { + "description": "The API key of the service provider", + "type": "string" + }, + "secret": { + "description": "The API secret for the service provider", + "type": "string", + "format": "password" + }, + "user_id": { + "description": "The asserted identifier of the user.", + "type": "string", + "example": "enquiries@iproov.com" + }, + "token": { + "description": "The token for the claim", + "type": "string", + "example": "31706131726336496d655177346e55503279616b69547344446e5258684c7542" + } + }, + "type": "object" + }, + "ClaimValidateRequest": { + "required": [ + "api_key", + "secret", + "user_id", + "token", + "ip", + "client" + ], + "properties": { + "api_key": { + "description": "The API key of the service provider", + "type": "string" + }, + "secret": { + "description": "The API secret for the service provider", + "type": "string", + "format": "password" + }, + "user_id": { + "description": "The asserted identifier of the user", + "type": "string", + "example": "enquiries@iproov.com" + }, + "token": { + "description": "The token for the claim", + "type": "string", + "example": "31706131726336496d655177346e55503279616b69547344446e5258684c7542" + }, + "ip": { + "description": "IP address of the device making this request. Note: This field has been deprecated and will be omitted in the next release.", + "type": "string", + "deprecated": true + }, + "client": { + "description": "Fingerprint or client identifier of the device making the request (e.g. User Agent)", + "type": "string" + }, + "risk_profile": { + "description": "The pre-defined risk profile to use for this claim", + "type": "string" + } + }, + "type": "object" + }, + "AuthTokenResponse": { + "description": "A OAuth2 standard compliant response giving an OAuth access token. Usable for up to 50 requests", + "required": [ + "access_token", + "token_type", + "expires_in" + ], + "properties": { + "access_token": { + "type": "string", + "example": "zAVFcpZ1Lxa5DeFnIbOotU4QjX7GEYuonoEj2WY2VygoJzCYhjEqiQqnNrVq" + }, + "token_type": { + "type": "string", + "example": "Bearer" + }, + "expires_in": { + "type": "integer", + "example": "3600" + }, + "scope": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "user-read" + ] + } + }, + "type": "object" + }, + "ProviderCreateRequest": { + "required": [ + "friendly_name", + "description", + "mode" + ], + "properties": { + "friendly_name": { + "description": "The friendly name of the service provider", + "type": "string" + }, + "internal_name": { + "description": "The internal name of the service provider", + "type": "string" + }, + "mode": { + "description": "The mode the service provider accepts", + "type": "array", + "items": { + "type": "string", + "enum": [ + "verify", + "enrol", + "device" + ] + } + }, + "flags": { + "description": "The flags for the service provider", + "type": "array", + "items": { + "$ref": "#/definitions/ServiceProviderFlags" + } + }, + "factors": { + "description": "The factors for the service provider", + "type": "array", + "items": { + "$ref": "#/definitions/ServiceProviderFactors" + } + } + }, + "type": "object" + }, + "ServiceProviderFlags": { + "properties": { + "enable_deepsplice": { + "description": "Enable Deep Splice", + "type": "boolean", + "example": false + }, + "enable_deepmorph": { + "description": "Enable Deep Morph", + "type": "boolean", + "example": false + }, + "enable_welcome_message": { + "description": "Remove the welcome message from the API response to be shown to the user", + "type": "boolean", + "example": true + }, + "enable_unvalidated_users": { + "description": "Allow unvalidated users to verify", + "type": "boolean", + "example": true + }, + "enable_anomaly": { + "description": "Enable the anomaly module", + "type": "boolean", + "example": false + }, + "enable_risk_profile": { + "description": "Enable Risk Profiles (note risk profiles must be provisioned additionally)", + "type": "boolean", + "example": false + }, + "enable_unique": { + "description": "Enable Uniqueness Checking", + "type": "boolean", + "example": false + }, + "enable_image_enrol": { + "description": "Enable Photo Enrol", + "type": "boolean", + "example": false + }, + "enable_validate_frame": { + "description": "Enable frame return on validation of a claim", + "type": "boolean", + "example": false + }, + "enable_redirect_domain": { + "description": "Enable supplying redirect URLs", + "type": "boolean", + "example": false + }, + "enable_production_mode": { + "description": "Enable production mode (disables setting client side requests etc.)", + "type": "boolean", + "example": false + } + }, + "type": "object" + }, + "ServiceProviderFactors": { + "properties": { + "welcome_message": { + "description": "The custom welcome message to use", + "type": "string" + }, + "logo": { + "description": "The path to the logo to use (must be an iProov hosted URL via https://secure.iproov.me)", + "type": "string" + }, + "push_message": { + "description": "The custom push message to use", + "type": "string" + }, + "default_risk_profile": { + "description": "The default risk profile a service provider uses if one isn't specified when setting up a claim", + "type": "string" + }, + "client_timeout": { + "description": "The service provider specific timeout to use", + "type": "integer" + } + }, + "type": "object" + }, + "ChangeStateRequest": { + "properties": { + "date_time": { + "description": "The date from which the state change for the user is effective from (default: now)", + "type": "string", + "format": "date-time" + } + }, + "type": "object" + }, + "UserResponse": { + "description": "Contains a description of the user with User ID, Name, Status of the user, suspension and activated\ndate. ", + "required": [ + "user_id", + "name", + "status" + ], + "properties": { + "user_id": { + "type": "string", + "example": "enquiries@iproov.com" + }, + "name": { + "type": "string", + "example": "John Doe" + }, + "status": { + "description": "A status of either 'inactive', 'active', 'suspended' or 'deleted'.", + "type": "string", + "example": "Active" + }, + "suspension_date": { + "type": "string", + "format": "date-time", + "example": "2016-04-16T16:06:05Z" + }, + "activation_date": { + "type": "string", + "format": "date-time", + "example": "2016-05-16T16:06:05Z" + } + }, + "type": "object" + }, + "UpdateUserRequest": { + "properties": { + "user_id": { + "description": "The new identifier for the user", + "type": "string" + }, + "name": { + "description": "The name to display to the user when authenticating", + "type": "string" + } + }, + "type": "object" + } + }, + "securityDefinitions": { + "oauth2": { + "type": "oauth2", + "description": "OAuth2 is used for the Management and User APIs.", + "flow": "application", + "tokenUrl": "https://INTERNAL", + "scopes": { + "user-write": "Update the user object", + "user-write-status": "Update the status of a user", + "user-read": "Read the information of users" + } + }, + "basicAuth": { + "type": "basic", + "description": "Basic authentication is used to get an OAuth2 access token for the Management and User APIs.\n\nThe Management and User APIs should be used only by the integrator's backend services. Therefore, separate credentials\nare required for this purpose, instead of the `api_key` and `secret` used for the rest of the public API.\n\nTo obtain these basic authentication credentials which allow creation of the OAuth2 tokens, please request them by\ncontacting support@iproov.com.\n" + } + }, + "tags": [ + { + "name": "Enrol", + "description": "Enrol a user with iProov." + }, + { + "name": "Verify", + "description": "Verify an enrolled user with iProov." + }, + { + "name": "User", + "description": "iProov user management APIs." + }, + { + "name": "Management", + "description": "iProov system management APIs." + } + ] +} \ No newline at end of file diff --git a/enrollment-server/src/main/resources/api/api-iproov.yaml b/enrollment-server/src/main/resources/api/api-iproov.yaml new file mode 100644 index 000000000..37f20bae1 --- /dev/null +++ b/enrollment-server/src/main/resources/api/api-iproov.yaml @@ -0,0 +1,1314 @@ +swagger: '2.0' +info: + title: iProov REST API + description: > + This is the iProov API documentation guidelines, for simple integration of + the iProov system + + # Introduction + + To use the iProov service, you will need a valid account on + [portal.iproov.com](https://portal.iproov.com), where you + + will be able to set up your Service Provider API credentials. + + + For a full description of how to use these APIs in conjunction with your + user journey and the iProov service, please + + refer to + [portal.iproov.com/documentation](https://portal.iproov.com/documentation). + + # Verifier + + To use Verifier, you need to generate a token with the `/claim/TYPE/token` + endpoint, where TYPE is `verify` or `enrol`. + + Once the claim has finished, you should then make a server to server + validation call to the `/claim/TYPE/validate` + + endpoint to ensure there has been no tampering with the result from the + client. + + # Service Provider API Key and Secret Authentication + + General operations are secured by the `api_key` and `secret` parameters in + the request body. See the individual + + operation sample request bodies for example usage. The additional schemes + below are used for Management and User APIs. + contact: + name: iProov API Team + version: 2.11.0 + x-logo: + url: logo.svg +host: secure.iproov.me +basePath: /api/v2 +schemes: + - https +consumes: + - application/json +produces: + - application/json +paths: + /availability: + get: + tags: + - Management + summary: Check the platform is available + description: Check the requested platform is available. + operationId: platformAvailable + produces: + - application/json + parameters: + - name: api_key + in: query + description: The API key of the service provider + required: true + type: string + responses: + '200': + description: Success + schema: + $ref: '#/definitions/AvailabilityResponse' + '400': + description: Bad Request or Client Error + schema: + $ref: '#/definitions/ClientErrorResponse' + /claim/{token}/invalidate: + post: + tags: + - Management + summary: Invalidates a token + description: Invalidates a token + operationId: invalidateToken + produces: + - application/json + parameters: + - name: token + in: path + description: The token to invalidate + required: true + type: string + - name: claim_request + in: body + required: true + schema: + $ref: '#/definitions/InvalidateClaimRequest' + responses: + '200': + description: Success + schema: + $ref: '#/definitions/InvalidateClaimResponse' + '400': + description: Bad Request or Client Error + schema: + $ref: '#/definitions/ClientErrorResponse' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '403': + description: Access Denied + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '500': + description: Error + schema: + $ref: '#/definitions/ServerErrorResponse' + /claim/verify/token: + post: + tags: + - Verify + summary: >- + Generate a verification token for an existing user to verify with a + service provider + description: >- + Generate a verification token for an existing user to verify with a + service provider + operationId: userVerifyServerToken + produces: + - application/json + parameters: + - name: claim_request + in: body + required: true + schema: + $ref: '#/definitions/ServerClaimRequest' + responses: + '200': + description: Success + schema: + $ref: '#/definitions/ClaimResponse' + '400': + description: Bad Request or Client Error + schema: + $ref: '#/definitions/ClientErrorResponse' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '403': + description: Access Denied + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '429': + description: Rate Limit Exceeded + schema: + $ref: '#/definitions/RateLimitExceededResponse' + '500': + description: Error + schema: + $ref: '#/definitions/ServerErrorResponse' + /claim/verify/validate: + post: + tags: + - Verify + summary: Validate the verification was successful + description: >- + Validate the verification was successful. + + + This endpoint can only be called once after a full end to end + transaction has been completed. Subsequent calls will emit + a 400 response. + + Note that we strongly recommend you call this endpoint to avoid any man + in the middle attacks between the user trying + + to authenticate and the iProov service (including by the user attempting + to log in!). + operationId: userVerifyValidate + produces: + - application/json + parameters: + - name: validate_request + in: body + required: true + schema: + $ref: '#/definitions/ClaimValidateRequest' + responses: + '200': + description: Success + schema: + $ref: '#/definitions/ClaimValidateResponse' + '400': + description: Bad Request or Client Error + schema: + $ref: '#/definitions/ClientErrorResponse' + '403': + description: Access Denied + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '404': + description: Not Found + schema: + $ref: '#/definitions/ErrorResponse' + '500': + description: Error + schema: + $ref: '#/definitions/ServerErrorResponse' + /{api_key}/access_token: + post: + tags: + - Management + summary: Allows a registered application to obtain an OAuth 2 Bearer Token + description: >- + Allows a registered application to obtain an OAuth 2 Bearer Token, which + can be used to make API + + requests on an application’s own behalf, without a user context. + Currently only client credentials are supported. + + Note that the OAuth username and password are to be provided via the + Authorization header in Basic format. These are + + distinct to the service provider API key and secret and supplied + separately. + operationId: generateToken + consumes: + - application/x-www-form-urlencoded + produces: + - application/json + parameters: + - name: grant_type + in: formData + description: The type of grant being requested by the application + required: true + type: string + - name: scope + in: formData + description: >- + Optionally define the scopes being required (omitting grants all + allowed scopes) + required: false + type: array + items: + type: string + - name: api_key + in: path + description: The API key of the service provider + required: true + type: string + responses: + '200': + description: Success + schema: + $ref: '#/definitions/AuthTokenResponse' + '400': + description: Bad Request or Client Error + schema: + $ref: '#/definitions/ClientErrorResponse' + '403': + description: Access Denied + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '404': + description: Not Found + schema: + $ref: '#/definitions/ErrorResponse' + '500': + description: Server Error + schema: + $ref: '#/definitions/ServerErrorResponse' + security: + - basicAuth: [] + /claim/enrol/token: + post: + tags: + - Enrol + summary: >- + Generate an enrolment token for a new user to enrol with a service + provider + description: >- + Generate an enrolment token for a new user to enrol with a service + provider. + + - For **Capture Enrol**, this token should be used to launch the SDK. + - For **Photo Enrol**, this token should be used for the Enrol Image API below. + operationId: userEnrolServerToken + produces: + - application/json + parameters: + - name: claim_request + in: body + required: true + schema: + $ref: '#/definitions/ServerClaimRequest' + responses: + '200': + description: Success + schema: + $ref: '#/definitions/ClaimResponse' + '400': + description: Bad Request or Client Error + schema: + $ref: '#/definitions/ClientErrorResponse' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '403': + description: Access Denied + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '429': + description: Rate Limit Exceeded + schema: + $ref: '#/definitions/RateLimitExceededResponse' + '500': + description: Error + schema: + $ref: '#/definitions/ServerErrorResponse' + x-redoc-order: 1 + /users/{user_id}/activate: + post: + tags: + - User + summary: Activate a user's persona for a service provider + description: Activate a user's persona for a service provider + operationId: userActivate + produces: + - application/json + parameters: + - name: user_id + in: path + description: The asserted identifier of the user + required: true + type: string + - name: claim_request + in: body + required: true + schema: + $ref: '#/definitions/ChangeStateRequest' + responses: + '200': + description: Success + schema: + $ref: '#/definitions/UserResponse' + '400': + description: Bad Request or Client Error + schema: + $ref: '#/definitions/ClientErrorResponse' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '403': + description: Access Denied + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '500': + description: Error + schema: + $ref: '#/definitions/ServerErrorResponse' + security: + - oauth2: + - user-write-status + x-redoc-order: 1 + /claim/enrol/image: + post: + tags: + - Enrol + summary: Enrol a user through a trusted photo + description: >- + Enrols a user through a photo that is trusted by the service provider to + be a likeness of the user. + + To call this endpoint you need to perform a previous API call: + * Firstly get a token that is associated with a user ID + * Secondly upload the image with this endpoint + + Accepted media types are jpeg and png images. JP2 image support is + currently in progress. + + + Note that due to the security implications of allowing users to enrol + with just images this endpoint is restricted on a + + per service provider basis + operationId: userEnrolImage + consumes: + - multipart/form-data + produces: + - application/json + parameters: + - name: api_key + in: formData + description: The API key of the service provider + required: true + type: string + - name: secret + in: formData + description: The API secret for the service provider + required: true + type: string + format: password + - name: rotation + in: formData + description: The rotation of the image (currently only 0 is supported) + required: true + type: integer + - name: image + in: formData + description: The image to enrol the user with + required: true + type: file + - name: token + in: formData + description: The enrolment token for the user + required: true + type: string + - name: source + in: formData + description: The source of the image (i.e. Electronic ID or Optical ID) + required: false + type: string + default: eid + enum: + - eid + - oid + - selfie + responses: + '200': + description: Success + schema: + $ref: '#/definitions/EnrolResponse' + '400': + description: Bad Request or Client Error + schema: + $ref: '#/definitions/ClientErrorResponse' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '500': + description: Error + schema: + $ref: '#/definitions/ServerErrorResponse' + x-redoc-order: 2 + /users/{user_id}/suspend: + post: + tags: + - User + summary: Suspend a user's persona from accessing a service provider + description: Suspend a user's persona from accessing a service provider + operationId: userSuspend + produces: + - application/json + parameters: + - name: user_id + in: path + description: The asserted identifier of the user + required: true + type: string + - name: claim_request + in: body + required: true + schema: + $ref: '#/definitions/ChangeStateRequest' + responses: + '200': + description: Success + schema: + $ref: '#/definitions/UserResponse' + '400': + description: Bad Request or Client Error + schema: + $ref: '#/definitions/ClientErrorResponse' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '403': + description: Access Denied + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '500': + description: Error + schema: + $ref: '#/definitions/ErrorResponse' + security: + - oauth2: + - user-write-status + x-redoc-order: 2 + /claim/enrol/validate: + post: + tags: + - Enrol + summary: Validate the enrolment was successful and activate the user + description: >- + Validate the enrolment was successful and activate the user. + + + This endpoint can only be called once after a full end to end + transaction has been completed. Subsequent calls will give a 400. + + + Note that without this endpoint being called the user will not be + activated and therefore will be unable to use iProov + + unless they were enrolled using the photo enrol API. + operationId: userEnrolValidate + produces: + - application/json + parameters: + - name: validate_request + in: body + required: true + schema: + $ref: '#/definitions/EnrolValidateRequest' + responses: + '200': + description: Success + schema: + $ref: '#/definitions/ClaimValidateResponse' + '400': + description: Bad Request or Client Error + schema: + $ref: '#/definitions/ClientErrorResponse' + '403': + description: Access Denied + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '404': + description: Not Found + schema: + $ref: '#/definitions/ErrorResponse' + '500': + description: Error + schema: + $ref: '#/definitions/ServerErrorResponse' + x-redoc-order: 3 + /users/{existing_user_id}: + get: + tags: + - User + summary: Get a user's persona for a service provider + description: Get a user's persona for a service provider + operationId: userGet + produces: + - application/json + parameters: + - name: existing_user_id + in: path + description: The asserted identifier of the user + required: true + type: string + responses: + '200': + description: Success + schema: + $ref: '#/definitions/UserResponse' + '400': + description: Not Found + schema: + $ref: '#/definitions/ClientErrorResponse' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '403': + description: Access Denied + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '500': + description: Error + schema: + $ref: '#/definitions/ErrorResponse' + security: + - oauth2: + - user-read + x-redoc-order: 4 + put: + tags: + - User + summary: Update a user's identifier for a service provider + description: >- + Update a user's identifier for a service provider. Note one (or both) of + name and new user id is required. + operationId: userUpdate + produces: + - application/json + parameters: + - name: existing_user_id + in: path + description: The asserted identifier of the user + required: true + type: string + - name: claim_request + in: body + required: true + schema: + $ref: '#/definitions/UpdateUserRequest' + responses: + '200': + description: Success + schema: + $ref: '#/definitions/UserResponse' + '400': + description: Bad Request or Client Error + schema: + $ref: '#/definitions/ClientErrorResponse' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '403': + description: Access Denied + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '500': + description: Error + schema: + $ref: '#/definitions/ErrorResponse' + security: + - oauth2: + - user-write + x-redoc-order: 3 + delete: + tags: + - User + summary: Deletes a user's persona from a service provider + description: Deletes a user's persona from a service provider + operationId: userDelete + produces: + - application/json + parameters: + - name: existing_user_id + in: path + description: The asserted identifier of the user + required: true + type: string + responses: + '200': + description: Success + schema: + $ref: '#/definitions/UserResponse' + '400': + description: Bad Request or Client Error + schema: + $ref: '#/definitions/ClientErrorResponse' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '403': + description: Access Denied + schema: + $ref: '#/definitions/InvalidCredentialErrorResponse' + '500': + description: Error + schema: + $ref: '#/definitions/ServerErrorResponse' + security: + - oauth2: + - user-write + x-redoc-order: 5 +definitions: + ErrorResponse: + description: >- + The Error response contains a error constant which can be used to split up + responses and can be + + converted to translatable error messages. The error description returns a + more descriptive error response that is + + largely designed for developers to get detailed information for failures. + required: + - error + - error_description + properties: + error: + type: string + error_description: + type: string + type: object + ClientErrorResponse: + description: >- + Missing data in the request, + + or the state of the requested entity object is such that the operation + could not succeed. + required: + - error + - error_description + properties: + error: + type: string + enum: + - invalid_agent + - invalid_user_id + - invalid_token + - invalid_validation + - invalid_reason + - invalid_risk_appetite + - invalid_ip + - invalid_grant + - invalid_request + - invalid_client + - invalid_scope + - unsupported_grant_type + - missing_data + - no_user + - inactive_user + error_description: + type: string + type: object + InvalidCredentialErrorResponse: + description: >- + One of: invalid OAuth token, expired OAuth token, invalid credentials, API + Key and/or Secret is + * missing, malformed, or incorrect. + required: + - error + - error_description + properties: + error: + type: string + enum: + - invalid_key + - invalid_oauth + - token_quota_exceeded + error_description: + type: string + type: object + RateLimitExceededResponse: + description: Server Error + required: + - error + - error_description + properties: + error: + type: string + enum: + - too_many_requests + error_description: + type: string + type: object + ServerErrorResponse: + description: Server Error + required: + - error + - error_description + properties: + error: + type: string + enum: + - server_error + error_description: + type: string + type: object + AvailabilityResponse: + required: + - up + properties: + up: + description: Whether iProov service is available or not. + type: boolean + example: true + type: object + EnrolResponse: + required: + - success + - token + - user_id + properties: + token: + type: string + example: 31706131726336496d655177346e55503279616b69547344446e5258684c7542 + user_id: + type: string + example: enquiries@iproov.com + success: + type: boolean + example: true + type: object + ServerClaimRequest: + required: + - api_key + - secret + - resource + properties: + api_key: + description: The API key of the service provider + type: string + secret: + description: The API secret for the service provider + type: string + format: password + resource: + description: The resource being accessed (e.g. URL) + type: string + assurance_type: + description: The assurance type of the claim + type: string + default: genuine_presence + enum: + - genuine_presence + - liveness + success_url: + description: >- + The URL to redirect to on success. Note: This field has been + deprecated and will be omitted in the next release. + type: string + deprecated: true + failure_url: + description: >- + The URL to redirect to on failure. Note: This field has been + deprecated and will be omitted in the next release. + type: string + deprecated: true + abort_url: + description: >- + The URL to redirect to on user abort. Note: This field has been + deprecated and will be omitted in the next release. + type: string + deprecated: true + user_id: + description: The asserted identifier of the user. + type: string + example: enquiries@iproov.com + risk_profile: + description: The pre-defined risk profile to use for this claim. + type: string + type: object + ClaimResponse: + required: + - fallback + - token + - primary + - user_id + - pod + properties: + fallback: + description: >- + The fallback gives relevant fallback information. It contains a 'type' + key and a 'message' key + + that provides more information about the fallback to be optionally + displayed to the user + type: array + items: + $ref: '#/definitions/FallbackDefinition' + token: + description: >- + The token should be referenced if there are issues with an individuals + claim and is used as a + + transaction id + type: string + example: 31706131726336496d655177346e55503279616b69547344446e5258684c7542 + primary: + type: string + user_id: + description: >- + The user id of the user associated with the token. Null if no user is + associated with the + + token. + type: string + example: enquiries@iproov.com + pod: + description: The pod that will be used for the claim. + type: string + example: edge02.eu4 + redirect_domain: + description: >- + If the service provider has a dedicated landing page hosted at iProov + this will contain the URL + + to redirect to. + type: string + risk_profile: + description: >- + If the service provider has specified a risk profile then it will be + used. + type: string + type: object + FallbackDefinition: + required: + - type + - message + properties: + type: + description: The type of the fallback to be used + type: string + example: Info + message: + description: The message to show the user about the fallback + type: string + example: Sorry, only iProov is available + type: object + ClientClaimRequest: + required: + - api_key + - resource + - client + properties: + api_key: + description: The API key of the service provider + type: string + client: + description: Fingerprint or client identifier (e.g. User Agent) + type: string + resource: + description: The resource being accessed (e.g. URL) + type: string + success_url: + description: >- + The URL to redirect to on success. Note: This field has been + deprecated and will be omitted in the next release. + type: string + deprecated: true + failure_url: + description: >- + The URL to redirect to on failure. Note: This field has been + deprecated and will be omitted in the next release. + type: string + deprecated: true + abort_url: + description: >- + The URL to redirect to on user abort. Note: This field has been + deprecated and will be omitted in the next release. + type: string + deprecated: true + type: object + EnrolAssociateUserRequest: + type: object + allOf: + - $ref: '#/definitions/AssociateUserRequest' + - properties: + name: + description: >- + The display name of the user. If not provided it is parsed from + the user id. + type: string + ClaimValidateResponse: + required: + - passed + - token + - type + properties: + passed: + type: boolean + example: true + token: + type: string + example: 31706131726336496d655177346e55503279616b69547344446e5258684c7542 + type: + type: string + example: verify + frame_available: + description: >- + Present and True if there is frame available for returning to the + integrator. + + + Enabled on a per service provider basis. Contact support@iproov.com to + request this functionality. + type: string + example: false + frame: + description: >- + If `frame_available` is present and True, a base64 encoded + representation of the frame. + type: string + frame_jpeg: + description: a base64 encoded representation of the frame in JPEG format. + type: string + iso_19794_5: + description: >- + If `frame_available` is present and True, a base64 encoded string that + contains an ISO 19794_5 + + compliant image. + type: string + reason: + description: The failure reason (enabled on a per service provider basis) + type: string + example: Please Keep Still + risk_profile: + description: The pre-defined risk profile to use for this claim + type: string + assurance_type: + description: Which assurance type was utilized by the transaction + type: string + default: genuine_presence + enum: + - genuine_presence + - liveness + type: object + EnrolValidateRequest: + type: object + allOf: + - $ref: '#/definitions/ClaimValidateRequest' + - properties: + activate: + description: >- + Activate the user's account (default: true). User will be + SUSPENDED if false. + type: boolean + InvalidateClaimResponse: + required: + - claim_aborted + - user_informed + properties: + claim_aborted: + description: True if claim was invalidated. + type: boolean + example: true + user_informed: + description: >- + True if the user was successfully informed that the claim has been + invalidated. + type: boolean + example: true + type: object + InvalidateClaimRequest: + required: + - reason + properties: + reason: + description: >- + The reason to be displayed to the user for the invalidation of the + claim + type: string + type: object + AssociateUserRequest: + required: + - api_key + - secret + - user_id + - token + properties: + api_key: + description: The API key of the service provider + type: string + secret: + description: The API secret for the service provider + type: string + format: password + user_id: + description: The asserted identifier of the user. + type: string + example: enquiries@iproov.com + token: + description: The token for the claim + type: string + example: 31706131726336496d655177346e55503279616b69547344446e5258684c7542 + type: object + ClaimValidateRequest: + required: + - api_key + - secret + - user_id + - token + - ip + - client + properties: + api_key: + description: The API key of the service provider + type: string + secret: + description: The API secret for the service provider + type: string + format: password + user_id: + description: The asserted identifier of the user + type: string + example: enquiries@iproov.com + token: + description: The token for the claim + type: string + example: 31706131726336496d655177346e55503279616b69547344446e5258684c7542 + ip: + description: >- + IP address of the device making this request. Note: This field has + been deprecated and will be omitted in the next release. + type: string + deprecated: true + client: + description: >- + Fingerprint or client identifier of the device making the request + (e.g. User Agent) + type: string + risk_profile: + description: The pre-defined risk profile to use for this claim + type: string + type: object + AuthTokenResponse: + description: >- + A OAuth2 standard compliant response giving an OAuth access token. Usable + for up to 50 requests + required: + - access_token + - token_type + - expires_in + properties: + access_token: + type: string + example: zAVFcpZ1Lxa5DeFnIbOotU4QjX7GEYuonoEj2WY2VygoJzCYhjEqiQqnNrVq + token_type: + type: string + example: Bearer + expires_in: + type: integer + example: '3600' + scope: + type: array + items: + type: string + example: + - user-read + type: object + ProviderCreateRequest: + required: + - friendly_name + - description + - mode + properties: + friendly_name: + description: The friendly name of the service provider + type: string + internal_name: + description: The internal name of the service provider + type: string + mode: + description: The mode the service provider accepts + type: array + items: + type: string + enum: + - verify + - enrol + - device + flags: + description: The flags for the service provider + type: array + items: + $ref: '#/definitions/ServiceProviderFlags' + factors: + description: The factors for the service provider + type: array + items: + $ref: '#/definitions/ServiceProviderFactors' + type: object + ServiceProviderFlags: + properties: + enable_deepsplice: + description: Enable Deep Splice + type: boolean + example: false + enable_deepmorph: + description: Enable Deep Morph + type: boolean + example: false + enable_welcome_message: + description: >- + Remove the welcome message from the API response to be shown to the + user + type: boolean + example: true + enable_unvalidated_users: + description: Allow unvalidated users to verify + type: boolean + example: true + enable_anomaly: + description: Enable the anomaly module + type: boolean + example: false + enable_risk_profile: + description: >- + Enable Risk Profiles (note risk profiles must be provisioned + additionally) + type: boolean + example: false + enable_unique: + description: Enable Uniqueness Checking + type: boolean + example: false + enable_image_enrol: + description: Enable Photo Enrol + type: boolean + example: false + enable_validate_frame: + description: Enable frame return on validation of a claim + type: boolean + example: false + enable_redirect_domain: + description: Enable supplying redirect URLs + type: boolean + example: false + enable_production_mode: + description: Enable production mode (disables setting client side requests etc.) + type: boolean + example: false + type: object + ServiceProviderFactors: + properties: + welcome_message: + description: The custom welcome message to use + type: string + logo: + description: >- + The path to the logo to use (must be an iProov hosted URL via + https://secure.iproov.me) + type: string + push_message: + description: The custom push message to use + type: string + default_risk_profile: + description: >- + The default risk profile a service provider uses if one isn't + specified when setting up a claim + type: string + client_timeout: + description: The service provider specific timeout to use + type: integer + type: object + ChangeStateRequest: + properties: + date_time: + description: >- + The date from which the state change for the user is effective from + (default: now) + type: string + format: date-time + type: object + UserResponse: + description: >- + Contains a description of the user with User ID, Name, Status of the user, + suspension and activated + + date. + required: + - user_id + - name + - status + properties: + user_id: + type: string + example: enquiries@iproov.com + name: + type: string + example: John Doe + status: + description: A status of either 'inactive', 'active', 'suspended' or 'deleted'. + type: string + example: Active + suspension_date: + type: string + format: date-time + example: '2016-04-16T16:06:05Z' + activation_date: + type: string + format: date-time + example: '2016-05-16T16:06:05Z' + type: object + UpdateUserRequest: + properties: + user_id: + description: The new identifier for the user + type: string + name: + description: The name to display to the user when authenticating + type: string + type: object +securityDefinitions: + oauth2: + type: oauth2 + description: OAuth2 is used for the Management and User APIs. + flow: application + tokenUrl: https://INTERNAL + scopes: + user-write: Update the user object + user-write-status: Update the status of a user + user-read: Read the information of users + basicAuth: + type: basic + description: > + Basic authentication is used to get an OAuth2 access token for the + Management and User APIs. + + + The Management and User APIs should be used only by the integrator's + backend services. Therefore, separate credentials + + are required for this purpose, instead of the `api_key` and `secret` used + for the rest of the public API. + + + To obtain these basic authentication credentials which allow creation of + the OAuth2 tokens, please request them by + + contacting support@iproov.com. +tags: + - name: Enrol + description: Enrol a user with iProov. + - name: Verify + description: Verify an enrolled user with iProov. + - name: User + description: iProov user management APIs. + - name: Management + description: iProov system management APIs. diff --git a/enrollment-server/src/main/resources/api/api-zenid.json b/enrollment-server/src/main/resources/api/api-zenid.json new file mode 100644 index 000000000..d850bfefb --- /dev/null +++ b/enrollment-server/src/main/resources/api/api-zenid.json @@ -0,0 +1,2961 @@ +{ + "swagger": "2.0", + "info": { + "version": "v1", + "title": "ZenidWeb" + }, + "host": "raiffeisen.frauds.zenid.cz", + "schemes": [ + "https" + ], + "paths": { + "/api/sample": { + "post": { + "tags": [ + "Api" + ], + "summary": "This method uploads a sample for processing. Sample can be a single image or video file. This call gets file, normalize it (e.t. rotate etc) and OCR its content.\r\nThe file content can be sent in two different formats:\r\nRAW: send the file content as body of the request in binary form without alternations\r\nFORM: use the multipart form encoding and send the file content in file variable\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nEmptyBody - no uploaded file.", + "operationId": "Api_UploadSample", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "expectedSampleType", + "in": "query", + "description": "Expected type of sample. Set this if you know the type of sample ahead to speed up processing. Required.", + "required": true, + "type": "string", + "enum": [ + "Unknown", + "Selfie", + "DocumentPicture", + "SelfieVideo", + "DocumentVideo", + "Archived" + ] + }, + { + "name": "uploadSessionID", + "in": "query", + "description": "SessionID is GUID created by client to group multiple sample uploads together", + "required": false, + "type": "string", + "format": "uuid" + }, + { + "name": "customData", + "in": "query", + "description": "Custom data to be associated with this sample. Sample can later be located using customData. Any string.", + "required": false, + "type": "string" + }, + { + "name": "fileName", + "in": "query", + "description": "Name of the original file (for example DSC01.jpg). If form upload is needed, then this is optional", + "required": false, + "type": "string" + }, + { + "name": "country", + "in": "query", + "description": "Expected country of the uploaded document (Cz, At, Sk...)", + "required": false, + "type": "string", + "enum": [ + "Cz", + "Sk", + "At", + "Hu", + "Pl", + "De", + "Hr", + "Ro", + "Ru", + "Ua", + "It", + "Dk", + "Es", + "Fi", + "Fr", + "Gb", + "Is", + "Nl", + "Se", + "Si", + "Bg", + "Be", + "Ee", + "Ie", + "Cy", + "Lt", + "Lv", + "Lu", + "Mt", + "Pt", + "Gr" + ] + }, + { + "name": "role", + "in": "query", + "description": "Expected role of the uploaded document (IDC, Passport, Drivers licence)", + "required": false, + "type": "string", + "enum": [ + "Idc", + "Pas", + "Drv", + "Res", + "Gun", + "Hic", + "Std", + "Car", + "Birth", + "Add", + "Ide" + ] + }, + { + "name": "fileLastWriteTime", + "in": "query", + "description": "Last write time of the file uploaded. This can be determined using javascript. Value is used for fraud detection (EXIF comparison)", + "required": false, + "type": "string", + "format": "date-time" + }, + { + "name": "async", + "in": "query", + "description": "Set this true if you want this request to respond as soon as the data are uploaded for processing, not waiting for result.", + "required": false, + "type": "boolean" + }, + { + "name": "callbackUrl", + "in": "query", + "description": "Set URL for call back here, if you want to receive JSON object with response there.", + "required": false, + "type": "string" + }, + { + "name": "searchForSubsamples", + "in": "query", + "description": "For videos, this makes ZenID extract static picture of the card from the video of card. For document pictures, if single sample contains multiple card pictures (for example multi-page PDF or scan with multiple picture), set this to true to search for them and extract as separate \"subsamples\"", + "required": false, + "type": "boolean" + }, + { + "name": "anonymizeImage", + "in": "query", + "description": "ZenID can optionally anonymize specific documents by blackening certain fields. Set this to true to perform anonymization", + "required": false, + "type": "boolean" + }, + { + "name": "documentCode", + "in": "query", + "description": "Code of the specific document if you know it", + "required": false, + "type": "string", + "enum": [ + "IDC2", + "DRV", + "IDC1", + "PAS", + "SK_IDC_2008plus", + "SK_DRV_2004_08_09", + "SK_DRV_2013", + "SK_DRV_2015", + "SK_PAS_2008_14", + "SK_DRV_1993", + "PL_IDC_2015", + "DE_IDC_2010", + "DE_IDC_2001", + "HR_IDC_2013_15", + "AT_IDE_2000", + "HU_IDC_2000_01_12", + "HU_IDC_2016", + "AT_IDC_2002_05_10", + "HU_ADD_2012", + "AT_PAS_2006_14", + "AT_DRV_2006", + "AT_DRV_2013", + "CZ_RES_2011_14", + "CZ_RES_2006_T", + "CZ_RES_2006_07", + "CZ_GUN_2014", + "HU_PAS_2006_12", + "HU_DRV_2012_13", + "HU_DRV_2012_B", + "EU_EHIC_2004_A", + "Unknown", + "CZ_GUN_2017", + "CZ_RES_2020", + "PL_IDC_2019", + "IT_PAS_2006_10", + "INT_ISIC_2008", + "DE_PAS", + "DK_PAS", + "ES_PAS", + "FI_PAS", + "FR_PAS", + "GB_PAS", + "IS_PAS", + "NL_PAS", + "RO_PAS", + "SE_PAS", + "PL_PAS", + "PL_DRV_2013", + "CZ_BIRTH", + "CZ_VEHICLE_I", + "INT_ISIC_2019", + "SI_PAS", + "SI_IDC", + "SI_DRV", + "EU_EHIC_2004_B", + "PL_IDC_2001_02_13", + "IT_IDC_2016", + "HR_PAS_2009_15", + "HR_DRV_2013", + "HR_IDC_2003", + "SI_DRV_2009", + "BG_PAS_2010", + "BG_IDC_2010", + "BG_DRV_2010_13", + "HR_IDC_2021", + "AT_IDC_2021", + "DE_PAS_2007", + "DE_DRV_2013_21", + "DE_DRV_1999_01_04_11", + "FR_IDC_2021", + "FR_IDC_1988_94", + "ES_PAS_2003_06", + "ES_IDC_2015", + "ES_IDC_2006", + "IT_IDC_2004", + "RO_IDC_2001_06_09_17_21", + "NL_IDC_2014_17_21", + "BE_PAS_2014_17_19", + "BE_IDC_2013_15", + "BE_IDC_2020_21", + "GR_PAS_2020", + "PT_PAS_2006_09", + "PT_PAS_2017", + "PT_IDC_2007_08_09_15", + "SE_IDC_2012_21", + "FI_IDC_2017_21", + "IE_PAS_2006_13", + "LT_PAS_2008_09_11_19", + "LT_IDC_2009_12", + "LV_PAS_2015", + "LV_PAS_2007", + "LV_IDC_2012", + "LV_IDC_2019", + "EE_PAS_2014", + "EE_PAS_2021", + "EE_IDC_2011", + "EE_IDC_2018_21", + "CY_PAS_2010_20", + "CY_IDC_2000_08", + "CY_IDC_2015_20", + "LU_PAS_2015", + "LU_IDC_2014_21", + "LU_IDC_2008_13", + "MT_PAS_2008", + "MT_IDC_2014", + "PL_PAS_2011", + "PL_DRV_1999", + "LT_IDC_2021" + ] + }, + { + "name": "pageCode", + "in": "query", + "description": "Side of the document to seek if you know it", + "required": false, + "type": "string", + "enum": [ + "F", + "B" + ] + }, + { + "name": "priorityQueueName", + "in": "query", + "description": "Setting this puts the request into a separate queue for processing, ignoring standard queue.", + "required": false, + "type": "string" + }, + { + "name": "profile", + "in": "query", + "description": "Optional name of profile. Use it to sort samples by different input channels for example.", + "required": false, + "type": "string" + }, + { + "name": "processingMode", + "in": "query", + "description": "Fast (default) or Slow. Slow process mode might fix some alignment problems but is slower.", + "required": false, + "type": "string", + "enum": [ + "Fast", + "Slow" + ] + }, + { + "name": "sdkSignature", + "in": "query", + "description": "Optional SDK signature for pictures. Generated by SDK", + "required": false, + "type": "string" + }, + { + "name": "File", + "in": "formData", + "description": "Upload samples file", + "required": true, + "type": "file" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.UploadSampleResponse" + } + } + } + } + }, + "/api/investigateSamples": { + "get": { + "tags": [ + "Api" + ], + "summary": "Investigation node. Investigation gets list of samples, combine mined data from them in one combined object (see MinedAllData), and validate it with set of validators.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nUnknownSampleID - any of sampleIDs is not stored in DB.\r\nSampleInInvalidState - sample is in invalid state - for example it is waiting for operators or processing of sample ended with error\r\nInvalidSampleCombination - investigation must contain samples from single person/customer", + "operationId": "Api_InvestigateSamples", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "sampleIDs", + "in": "query", + "description": "List of strings - sample IDs. Required.", + "required": true, + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi" + }, + { + "name": "profile", + "in": "query", + "description": "Optional name of profile. Each profile defines settings for validators.", + "required": false, + "type": "string" + }, + { + "name": "customData", + "in": "query", + "description": "Custom data to be associated with this sample. Sample can later be located using customData. Any string.", + "required": false, + "type": "string" + }, + { + "name": "async", + "in": "query", + "description": "Set this true if you want this request to respond as soon as the data are uploaded for processing, not waiting for result.", + "required": false, + "type": "boolean" + }, + { + "name": "callbackUrl", + "in": "query", + "description": "Set URL for call back here, if you want to receive JSON object with response there.", + "required": false, + "type": "string" + }, + { + "name": "language", + "in": "query", + "description": "Language of the output (Czech/English).", + "required": false, + "type": "string", + "enum": [ + "English", + "Czech", + "Polish", + "German" + ] + }, + { + "name": "priorityQueueName", + "in": "query", + "description": "Setting this puts the request into a separate queue for processing, ignoring standard queue.", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.InvestigateResponse" + } + } + } + } + }, + "/api/investigateUploadSession": { + "get": { + "tags": [ + "Api" + ], + "summary": "Investigation node. Investigation gets GUID, unique identifier, gets all samples tagged with this UploadSessionID, combine mined data from them in one combined object (see MinedAllData), and validate it with set of validators.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nUnknownUploadSessionID - UploadSessionID is not known\r\nSampleInInvalidState - sample is in invalid state - for example it is waiting for operators or processing of sample ended with error\r\nInvalidSampleCombination - investigation must contain samples from single person/customer", + "operationId": "Api_InvestigateUploadSession", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "uploadSessionID", + "in": "query", + "description": "User identification of list of samples. Required.", + "required": true, + "type": "string", + "format": "uuid" + }, + { + "name": "profile", + "in": "query", + "description": "Optional name of profile. Each profile defines settings for validators.", + "required": false, + "type": "string" + }, + { + "name": "customData", + "in": "query", + "description": "Custom data to be associated with this sample. Sample can later be located using customData. Any string.", + "required": false, + "type": "string" + }, + { + "name": "async", + "in": "query", + "description": "Set this true if you want this request to respond as soon as the data are uploaded for processing, not waiting for result.", + "required": false, + "type": "boolean" + }, + { + "name": "callbackUrl", + "in": "query", + "description": "Set URL for call back here, if you want to receive JSON object with response there.", + "required": false, + "type": "string" + }, + { + "name": "language", + "in": "query", + "description": "Language of the output (Czech/English).", + "required": false, + "type": "string", + "enum": [ + "English", + "Czech", + "Polish", + "German" + ] + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.InvestigateResponse" + } + } + } + } + }, + "/api/sample/{sampleID}": { + "get": { + "tags": [ + "Api" + ], + "summary": "Node for synchronizing samples - returns sample for given ID. This call can be used for synchronizing information about samples with external systems.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nUnknownSampleID - if that ID is not used in DB for any sample.", + "operationId": "Api_GetSample", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "sampleID", + "in": "path", + "description": "ID of the sample", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.UploadSampleResponse" + } + } + } + } + }, + "/api/deletePerson": { + "get": { + "tags": [ + "Api" + ], + "summary": "Deletes all information related to a person", + "operationId": "Api_DeletePerson", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "sampleId", + "in": "query", + "description": "ID of the sample to start person search", + "required": false, + "type": "string" + }, + { + "name": "cardIdentifier", + "in": "query", + "description": "ID of the card for which we search the related documents", + "required": false, + "type": "string" + }, + { + "name": "firstName", + "in": "query", + "description": "First name of the person whose documents are searched for", + "required": false, + "type": "string" + }, + { + "name": "lastName", + "in": "query", + "description": "Surname of the person whose documents are searched for", + "required": false, + "type": "string" + }, + { + "name": "birthNumber", + "in": "query", + "description": "Birth Number (RČ in czech) of the person whose documents are searched for", + "required": false, + "type": "string" + }, + { + "name": "birthDate", + "in": "query", + "description": "Date of birth of the person whose documents are searched for", + "required": false, + "type": "string", + "format": "date-time" + }, + { + "name": "deleteType", + "in": "query", + "description": "Set if you want delete only samples and investigations or face from FaceDB or everything", + "required": false, + "type": "string", + "enum": [ + "Everything", + "FacesOnly", + "SamplesAndInvestigationsOnly" + ] + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.DeletePersonResponse" + } + } + } + } + }, + "/api/deleteSample": { + "get": { + "tags": [ + "Api" + ], + "summary": "Deletes a sample. Also deletes an investigation in which this sample was used", + "operationId": "Api_DeleteSample", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "sampleId", + "in": "query", + "description": "ID of the sample to delete", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.DeleteSampleResponse" + } + } + } + } + }, + "/api/investigation/{investigationID}": { + "get": { + "tags": [ + "Api" + ], + "summary": "Node for synchronizing investigations - returns investigations for given ID. This call can be used for synchronizing information about samples with external systems.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nUnknownSampleID - if Investigation ID is not stored in DB.", + "operationId": "Api_GetInvestigation", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "investigationID", + "in": "path", + "description": "ID of the investigation", + "required": true, + "type": "integer", + "format": "int32" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.InvestigateResponse" + } + } + } + } + }, + "/api/samples": { + "get": { + "tags": [ + "Api" + ], + "summary": "Get list of samples (newer than timestamp). This call can be used for synchronizing information about samples with external systems.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nInvalidTimeStamp - error while decoding timestamp.", + "operationId": "Api_GetSamples", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "timestamp", + "in": "query", + "description": "if defined, list is limited to samples newer than given timestamp", + "required": false, + "type": "integer", + "format": "int64" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.ListSamplesResponse" + } + } + } + } + }, + "/api/investigations": { + "get": { + "tags": [ + "Api" + ], + "summary": "Get list of investigations (newer than timestamp). This call can be used for synchronizing information about samples with external systems.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nInvalidTimeStamp - error while decoding timestamp.", + "operationId": "Api_GetInvestigations", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "timestamp", + "in": "query", + "description": "if defined, list is limited to investigations newer than given timestamp", + "required": false, + "type": "integer", + "format": "int64" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.ListInvestigationsResponse" + } + } + } + } + }, + "/api/profiles": { + "get": { + "tags": [ + "Api" + ], + "summary": "Get list of names of profiles, defined in system. This call can be used for selecting profile in investigation.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,", + "operationId": "Api_GetProfiles", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.ListProfilesResponse" + } + } + } + } + }, + "/api/validators": { + "get": { + "tags": [ + "Api" + ], + "summary": "Returns list of validators - their id and text description.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,", + "operationId": "Api_GetValidatorEnum", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "language", + "in": "query", + "description": "Optional parameter for defining output language (Czech, German).", + "required": false, + "type": "string", + "enum": [ + "English", + "Czech", + "Polish", + "German" + ] + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/diagnostics": { + "get": { + "tags": [ + "Api" + ], + "summary": "Performs diagnostics test. Note depending on parameters, license might be consumed", + "operationId": "Api_Diagnostics", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "checkSelfie", + "in": "query", + "description": "Checks face API is working - THIS CAUSES LICENSE USAGE", + "required": false, + "type": "boolean" + }, + { + "name": "checkCloud", + "in": "query", + "description": "Checks cloud services are working - THIS CAUSES LICENSE USAGE", + "required": false, + "type": "boolean" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.DiagnosticsResponse" + } + } + } + } + }, + "/api/initSdk": { + "get": { + "tags": [ + "Api" + ], + "summary": "Returns string required for a correct function of mobile and Web SDK.", + "operationId": "Api_InitSdk", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "token", + "in": "query", + "description": "Challenge token generated by SDK function.", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.InitSdkResponse" + } + } + } + } + }, + "/api/face": { + "post": { + "tags": [ + "Api" + ], + "summary": "Loads face image in the image repository (for comparing faces for validation).\r\nIn most cases, /api/sample with sampleType=Selfie should be used instead.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these):\r\nInternalServerError - problem while preprocessing or unknown problem while processing or disabled face database.", + "operationId": "Api_UploadFace", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "birthNumber", + "in": "query", + "description": "Birth namber, required for image/face coupling.", + "required": true, + "type": "string" + }, + { + "name": "fileName", + "in": "query", + "description": "Name of the input file", + "required": false, + "type": "string" + }, + { + "name": "async", + "in": "query", + "description": "Set this true if you want this request to respond as soon as the data are uploaded for processing, not waiting for result.", + "required": false, + "type": "boolean" + }, + { + "name": "callbackUrl", + "in": "query", + "description": "Optional, used if api/face called asynchroniously", + "required": false, + "type": "string" + }, + { + "name": "File", + "in": "formData", + "description": "Upload face", + "required": true, + "type": "file" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.UploadFaceResponse" + } + } + } + } + }, + "/api/verifyCardsRecalled": { + "post": { + "tags": [ + "Api" + ], + "summary": "Verifies card validity using same means as existing CardRecalled validator\r\nIt takes a List of cards to verify - each with DocumentCode and card number and returns same list with Recalled status. Recalled status can be either True = recalled, False = not recalled, Null = could not determine/not supported document code.", + "operationId": "Api_VerifyCardsRecalled", + "consumes": [ + "application/json", + "text/json", + "application/xml", + "text/xml", + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "request", + "in": "body", + "description": "List of cards to verify - each with DocumentCode and card number", + "required": true, + "schema": { + "$ref": "#/definitions/ZenidWeb.VerifyCardsRecalledRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ZenidWeb.VerifyCardsRecalledResponse" + } + } + } + } + } + }, + "definitions": { + "ZenidWeb.UploadSampleResponse": { + "description": "Response object for UploadSample", + "type": "object", + "properties": { + "SampleID": { + "description": "Unique ID of the sample in ZenID system.", + "type": "string" + }, + "CustomData": { + "description": "Copy of the input parameter CustomData", + "type": "string" + }, + "UploadSessionID": { + "format": "uuid", + "description": "Copy of the input parameter UploadSessionID", + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + }, + "SampleType": { + "description": "Real SampleType", + "enum": [ + "Unknown", + "Selfie", + "DocumentPicture", + "SelfieVideo", + "DocumentVideo", + "Archived" + ], + "type": "string" + }, + "MinedData": { + "$ref": "#/definitions/ZenidShared.MineAllResult", + "description": "Structure of data, mined from sample - {ZenidShared.MineAllResult}." + }, + "State": { + "description": "State of the request - NotDone/Done/Error", + "enum": [ + "NotDone", + "Done", + "Error", + "Operator", + "Rejected" + ], + "type": "string" + }, + "ProjectedImage": { + "$ref": "#/definitions/ZenidShared.Hash", + "description": "hash of the source projected image" + }, + "ParentSampleID": { + "description": "hash of the parent sampleID if this is a subsample", + "type": "string" + }, + "AnonymizedImage": { + "$ref": "#/definitions/ZenidShared.Hash", + "description": "Hash of the censored projected image" + }, + "ImageUrlFormat": { + "description": "link to the source projected image", + "type": "string" + }, + "ImagePageCount": { + "format": "int32", + "description": "Number of pages this document has (in case of PDF or TIFF). This can be used in history URL /history/image/{hash}?page=1", + "type": "integer" + }, + "Subsamples": { + "description": "If subsample processing is enable, this list contains further images extracted from the primary image, each with extra document image", + "type": "array", + "items": { + "$ref": "#/definitions/ZenidWeb.UploadSampleResponse" + } + }, + "ErrorCode": { + "description": "If throght processing some error occurs, ErrorCode property is set.", + "enum": [ + "UnknownSampleID", + "UnknownUploadSessionID", + "EmptyBody", + "InternalServerError", + "InvalidTimeStamp", + "SampleInInvalidState", + "InvalidSampleCombination", + "AccessDenied", + "UnknownPerson", + "InvalidInputData" + ], + "type": "string" + }, + "ErrorText": { + "description": "Error text", + "type": "string" + }, + "MessageType": { + "type": "string", + "readOnly": true + } + } + }, + "ZenidShared.MineAllResult": { + "type": "object", + "properties": { + "FirstName": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "LastName": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "Address": { + "$ref": "#/definitions/ZenidShared.MinedAddress" + }, + "BirthAddress": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "BirthLastName": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "BirthNumber": { + "$ref": "#/definitions/ZenidShared.MinedRc" + }, + "BirthDate": { + "$ref": "#/definitions/ZenidShared.MinedDate" + }, + "ExpiryDate": { + "$ref": "#/definitions/ZenidShared.MinedDate" + }, + "IssueDate": { + "$ref": "#/definitions/ZenidShared.MinedDate" + }, + "IdcardNumber": { + "$ref": "#/definitions/ZenidShared.MinedText", + "description": "identification number for id card - set only on id cards" + }, + "DrivinglicenseNumber": { + "$ref": "#/definitions/ZenidShared.MinedText", + "description": "identification number for driving licence - set only on driving licences" + }, + "PassportNumber": { + "$ref": "#/definitions/ZenidShared.MinedText", + "description": "identification number for passport - set only on passports" + }, + "Sex": { + "$ref": "#/definitions/ZenidShared.MinedSex" + }, + "Nationality": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "Authority": { + "$ref": "#/definitions/ZenidShared.MinedText", + "description": "Authority (state agency) issued this document" + }, + "MaritalStatus": { + "$ref": "#/definitions/ZenidShared.MinedMaritalStatus" + }, + "Photo": { + "$ref": "#/definitions/ZenidShared.MinedPhoto" + }, + "Mrz": { + "$ref": "#/definitions/ZenidShared.MinedMrz", + "description": "Machine readable zone" + }, + "DocumentCode": { + "description": "Code identificating document (when combining from more samples the most probable version is set)", + "enum": [ + "IDC2", + "DRV", + "IDC1", + "PAS", + "SK_IDC_2008plus", + "SK_DRV_2004_08_09", + "SK_DRV_2013", + "SK_DRV_2015", + "SK_PAS_2008_14", + "SK_DRV_1993", + "PL_IDC_2015", + "DE_IDC_2010", + "DE_IDC_2001", + "HR_IDC_2013_15", + "AT_IDE_2000", + "HU_IDC_2000_01_12", + "HU_IDC_2016", + "AT_IDC_2002_05_10", + "HU_ADD_2012", + "AT_PAS_2006_14", + "AT_DRV_2006", + "AT_DRV_2013", + "CZ_RES_2011_14", + "CZ_RES_2006_T", + "CZ_RES_2006_07", + "CZ_GUN_2014", + "HU_PAS_2006_12", + "HU_DRV_2012_13", + "HU_DRV_2012_B", + "EU_EHIC_2004_A", + "Unknown", + "CZ_GUN_2017", + "CZ_RES_2020", + "PL_IDC_2019", + "IT_PAS_2006_10", + "INT_ISIC_2008", + "DE_PAS", + "DK_PAS", + "ES_PAS", + "FI_PAS", + "FR_PAS", + "GB_PAS", + "IS_PAS", + "NL_PAS", + "RO_PAS", + "SE_PAS", + "PL_PAS", + "PL_DRV_2013", + "CZ_BIRTH", + "CZ_VEHICLE_I", + "INT_ISIC_2019", + "SI_PAS", + "SI_IDC", + "SI_DRV", + "EU_EHIC_2004_B", + "PL_IDC_2001_02_13", + "IT_IDC_2016", + "HR_PAS_2009_15", + "HR_DRV_2013", + "HR_IDC_2003", + "SI_DRV_2009", + "BG_PAS_2010", + "BG_IDC_2010", + "BG_DRV_2010_13", + "HR_IDC_2021", + "AT_IDC_2021", + "DE_PAS_2007", + "DE_DRV_2013_21", + "DE_DRV_1999_01_04_11", + "FR_IDC_2021", + "FR_IDC_1988_94", + "ES_PAS_2003_06", + "ES_IDC_2015", + "ES_IDC_2006", + "IT_IDC_2004", + "RO_IDC_2001_06_09_17_21", + "NL_IDC_2014_17_21", + "BE_PAS_2014_17_19", + "BE_IDC_2013_15", + "BE_IDC_2020_21", + "GR_PAS_2020", + "PT_PAS_2006_09", + "PT_PAS_2017", + "PT_IDC_2007_08_09_15", + "SE_IDC_2012_21", + "FI_IDC_2017_21", + "IE_PAS_2006_13", + "LT_PAS_2008_09_11_19", + "LT_IDC_2009_12", + "LV_PAS_2015", + "LV_PAS_2007", + "LV_IDC_2012", + "LV_IDC_2019", + "EE_PAS_2014", + "EE_PAS_2021", + "EE_IDC_2011", + "EE_IDC_2018_21", + "CY_PAS_2010_20", + "CY_IDC_2000_08", + "CY_IDC_2015_20", + "LU_PAS_2015", + "LU_IDC_2014_21", + "LU_IDC_2008_13", + "MT_PAS_2008", + "MT_IDC_2014", + "PL_PAS_2011", + "PL_DRV_1999", + "LT_IDC_2021" + ], + "type": "string" + }, + "DocumentCountry": { + "description": "Country associated with this document type", + "enum": [ + "Cz", + "Sk", + "At", + "Hu", + "Pl", + "De", + "Hr", + "Ro", + "Ru", + "Ua", + "It", + "Dk", + "Es", + "Fi", + "Fr", + "Gb", + "Is", + "Nl", + "Se", + "Si", + "Bg", + "Be", + "Ee", + "Ie", + "Cy", + "Lt", + "Lv", + "Lu", + "Mt", + "Pt", + "Gr" + ], + "type": "string" + }, + "DocumentRole": { + "description": "General role of this document (ID card vs Passport vs Driver license etc)", + "enum": [ + "Idc", + "Pas", + "Drv", + "Res", + "Gun", + "Hic", + "Std", + "Car", + "Birth", + "Add", + "Ide" + ], + "type": "string" + }, + "PageCode": { + "description": "identification of page of document", + "enum": [ + "F", + "B" + ], + "type": "string" + }, + "Height": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "EyesColor": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "CarNumber": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "FirstNameOfParents": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "ResidencyNumber": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "ResidencyNumberPhoto": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "ResidencyPermitDescription": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "ResidencyPermitCode": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "GunlicenseNumber": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "Titles": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "TitlesAfter": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "SpecialRemarks": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "MothersName": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "HealthInsuranceCardNumber": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "InsuranceCompanyCode": { + "$ref": "#/definitions/ZenidShared.MinedText" + }, + "IssuingCountry": { + "$ref": "#/definitions/ZenidShared.MinedText" + } + } + }, + "ZenidShared.Hash": { + "description": "Simple MD5 hash wrapper with easy compare/text conversions", + "type": "object", + "properties": { + "AsText": { + "type": "string" + }, + "IsNull": { + "type": "boolean", + "readOnly": true + } + } + }, + "ZenidShared.MinedText": { + "description": "Identifies mined text - its value and confidence", + "type": "object", + "properties": { + "Text": { + "type": "string" + }, + "Confidence": { + "format": "int32", + "type": "integer" + } + } + }, + "ZenidShared.MinedAddress": { + "type": "object", + "properties": { + "ID": { + "type": "string" + }, + "A1": { + "description": "physical first row of address on card", + "type": "string" + }, + "A2": { + "description": "physical second row of address on card", + "type": "string" + }, + "A3": { + "description": "physical third row of address on card", + "type": "string" + }, + "AdministrativeAreaLevel1": { + "description": "main admin. area - in CZ - kraj", + "type": "string" + }, + "AdministrativeAreaLevel2": { + "description": "secondary admin. area - in CZ - okres or towns behaves also as okres - like Brno", + "type": "string" + }, + "Locality": { + "description": "identification of town/city/village (if not already defined up - Brno, Praha) / OSM: boundary=administrative+ admin_level=8", + "type": "string" + }, + "Sublocality": { + "description": "town-subdivision\r\nCZ - čtvrť/katastrální území (Neighborhood/Cadastral place) / OSM: boundary=administrative+ admin_level=10\r\nSK - čtvrť/katastrální území (Neighborhood/Cadastral place) / OSM: boundary=administrative+ admin_level=10\r\nDE - stadtteil without selfgovernment / OSM: boundary=administrative+ admin_level=10\r\nHU - admin-level 9\r\n \r\ntodo slovak: Valaská - Piesok is in addess, but Piesok is just place=village, no admin_level=10", + "type": "string" + }, + "Suburb": { + "description": "town-subdivision - selfgoverning - probably used only in CZ and maybe DE\r\nCZ - městská část/obvod / OSM: addr:suburb - it can be in multiple cadastral places (parts cadastral place Trnitá is in suburb Brno-střed and Brno-jih)\r\nDE - stadtteil without selfgovernment / OSM: boundary=administrative+ admin_level=9\r\n \r\ntodo not used outside CZ right now, so it is not searched/mined from osm, just ruian", + "type": "string" + }, + "Street": { + "description": "in CZ - ulice", + "type": "string" + }, + "HouseNumber": { + "description": "descriptive house number in town - used in Czechia, Slovakia, Austria (číslo popisné, číslo súpisné, Konskriptionsnummer)", + "type": "string" + }, + "StreetNumber": { + "description": "descriptive number of house on the street - in CZ - číslo orientační", + "type": "string" + }, + "PostalCode": { + "description": "in CZ - poštovní směrovací číslo - PSČ", + "type": "string" + }, + "GoogleSearchable": { + "type": "string", + "readOnly": true + }, + "Text": { + "type": "string" + }, + "Confidence": { + "format": "int32", + "type": "integer" + } + } + }, + "ZenidShared.MinedRc": { + "description": "Object containing mined information about birth-number - checksum, date, sex...", + "type": "object", + "properties": { + "BirthDate": { + "format": "date-time", + "description": "Date of the birth - can be parsed from RC identifier", + "type": "string" + }, + "Checksum": { + "format": "int32", + "type": "integer", + "readOnly": true + }, + "Sex": { + "enum": [ + "F", + "M" + ], + "type": "string" + }, + "Text": { + "type": "string" + }, + "Confidence": { + "format": "int32", + "type": "integer" + } + } + }, + "ZenidShared.MinedDate": { + "description": "object for storing Mined Date - Date, default Format, Test and Confidence.", + "type": "object", + "properties": { + "Date": { + "format": "date-time", + "type": "string" + }, + "Text": { + "type": "string" + }, + "Confidence": { + "format": "int32", + "type": "integer" + } + } + }, + "ZenidShared.MinedSex": { + "description": "MinedSex - test of field, its confidence and property Sex (parsed text)", + "type": "object", + "properties": { + "Sex": { + "enum": [ + "F", + "M" + ], + "type": "string" + }, + "Text": { + "type": "string" + }, + "Confidence": { + "format": "int32", + "type": "integer" + } + } + }, + "ZenidShared.MinedMaritalStatus": { + "description": "MinedMaritalStatus - test of field, its confidence and property MaritalStatus (parsed text)", + "type": "object", + "properties": { + "MaritalStatus": { + "enum": [ + "Single", + "Married", + "Divorced", + "Widowed", + "Partnership" + ], + "type": "string" + }, + "ImpliedSex": { + "enum": [ + "F", + "M" + ], + "type": "string" + }, + "Text": { + "type": "string" + }, + "Confidence": { + "format": "int32", + "type": "integer" + } + } + }, + "ZenidShared.MinedPhoto": { + "description": "MinedPhoto - shows image data, and also two face-related values - estimated age and sex.", + "type": "object", + "properties": { + "ImageData": { + "$ref": "#/definitions/ZenidShared.LazyMatImage" + }, + "EstimatedAge": { + "format": "double", + "type": "number" + }, + "EstimatedSex": { + "enum": [ + "F", + "M" + ], + "type": "string" + }, + "HasOccludedMouth": { + "type": "boolean" + }, + "HasSunGlasses": { + "type": "boolean" + }, + "HasHeadWear": { + "type": "boolean" + }, + "Text": { + "type": "string" + }, + "Confidence": { + "format": "int32", + "type": "integer" + } + } + }, + "ZenidShared.MinedMrz": { + "description": "Declare mined Text, Confidence, and also structure Mrz.", + "type": "object", + "properties": { + "Mrz": { + "$ref": "#/definitions/ZenidShared.Mrz" + }, + "Text": { + "type": "string" + }, + "Confidence": { + "format": "int32", + "type": "integer" + } + } + }, + "ZenidShared.LazyMatImage": { + "type": "object", + "properties": { + "ImageHash": { + "$ref": "#/definitions/ZenidShared.Hash" + } + } + }, + "ZenidShared.Mrz": { + "type": "object", + "properties": { + "Type": { + "enum": [ + "ID_v2000", + "ID_v2012", + "PAS_v2006", + "Unknown", + "AUT_IDC2002", + "AUT_PAS2006", + "SVK_IDC2008", + "SVK_DL2013", + "SVK_PAS2008", + "POL_IDC2015", + "HRV_IDC2003", + "CZE_RES_2011_14", + "HUN_PAS_2006_12", + "HU_IDC_2000_01_12_16" + ], + "type": "string" + }, + "Subtype": { + "enum": [ + "OP", + "R", + "D", + "S", + "Default", + "Unknown" + ], + "type": "string" + }, + "BirthDate": { + "description": "Inner Birth date string of MRZ. Low-level data, ignore it. Use BirthDate from MineAllResult object.", + "type": "string" + }, + "BirthDateVerified": { + "description": "Inner flag, if MRZ BirthDate checksum is ok. Low-level check, ignore it. Use Validators.", + "type": "boolean" + }, + "DocumentNumber": { + "description": "Inner Document number string of MRZ. Low-level data, ignore it. Use value from MineAllResult object.", + "type": "string" + }, + "DocumentNumberVerified": { + "description": "Inner flag, if MRZ DocumentNumber checksum is ok. Low-level check, ignore it. Use Validators.", + "type": "boolean" + }, + "ExpiryDate": { + "description": "Inner Expiry date string of MRZ. Low-level data, ignore it. Use value from MineAllResult object.", + "type": "string" + }, + "ExpiryDateVerified": { + "description": "Inner flag, if MRZ ExpiryDate checksum is ok. Low-level check, ignore it. Use Validators.", + "type": "boolean" + }, + "GivenName": { + "description": "Inner Given name string of MRZ. Low-level data, ignore it. Use value from MineAllResult object.", + "type": "string" + }, + "ChecksumVerified": { + "description": "Inner flag, if checksum of MRZ itself is ok. Low-level check, ignore it. Use Validators.", + "type": "boolean" + }, + "ChecksumDigit": { + "format": "int32", + "description": "Inner value of global MRZ checksum.", + "type": "integer" + }, + "LastName": { + "description": "Inner Last name string of MRZ. Low-level data, ignore it. Use value from MineAllResult object.", + "type": "string" + }, + "Nationality": { + "description": "Inner Nationality string of MRZ. Low-level data, ignore it. Use value from MineAllResult object.", + "type": "string" + }, + "Sex": { + "description": "Inner Sex string of MRZ. Low-level data, ignore it. Use value from MineAllResult object.", + "type": "string" + }, + "BirthNumber": { + "description": "Inner Birthnumber string of MRZ (used on Czech passports). Low-level data, ignore it. Use value from MineAllResult object.", + "type": "string" + }, + "BirthNumberChecksum": { + "format": "int32", + "description": "Inner value of Birthnumber checksum in MRZ (on Czech passports). Low-level check, ignore it. Use Validators.", + "type": "integer" + }, + "BirthNumberVerified": { + "description": "Inner flag, if MRZ BirthNumber checksum is ok (used on Czech passports). Low-level check, ignore it. Use Validators.", + "type": "boolean" + }, + "BirthdateChecksum": { + "format": "int32", + "description": "Inner value of MRZ BirthDate checksum.", + "type": "integer" + }, + "DocumentNumChecksum": { + "format": "int32", + "description": "Inner value of MRZ DocumentNumber checksum.", + "type": "integer" + }, + "ExpiryChecksum": { + "format": "int32", + "description": "Inner value of MRZ ExpiryDate checksum.", + "type": "integer" + }, + "IssueDate": { + "description": "Prefix of the MRZ (type of the MRZ + subtype (differs, some ID cards have ID, other I_ or IO) + country issuer. Low-level data, can be ignored.", + "type": "string" + }, + "IssueDateParsed": { + "format": "date-time", + "type": "string", + "readOnly": true + }, + "AdditionalData": { + "description": "Output of OptionalSubstructure dont fitting in IssueDate or BirthNumber", + "type": "string" + }, + "BirthDateParsed": { + "format": "date-time", + "description": "Inner machine-readable value of BirthDate (in DateTime structure). Low-level data, use value from MineAllResult object.", + "type": "string", + "readOnly": true + }, + "ExpiryDateParsed": { + "format": "date-time", + "description": "Inner machine-readable value of ExpiryDate (in DateTime structure). Low-level data, use value from MineAllResult object.", + "type": "string", + "readOnly": true + }, + "MrzLength": { + "$ref": "#/definitions/System.ValueTuple[System.Int32,System.Int32]", + "readOnly": true + }, + "MrzDefType": { + "enum": [ + "TD1_IDC", + "TD2_IDC2000", + "TD3_PAS", + "SKDRV", + "None" + ], + "type": "string" + } + } + }, + "System.ValueTuple[System.Int32,System.Int32]": { + "type": "object", + "properties": { + "Item1": { + "format": "int32", + "type": "integer" + }, + "Item2": { + "format": "int32", + "type": "integer" + } + } + }, + "ZenidWeb.InvestigateResponse": { + "description": "Response object for the investigation nodes.", + "type": "object", + "properties": { + "InvestigationID": { + "format": "int32", + "description": "Unique identification of the investigation (set of samples)", + "type": "integer" + }, + "CustomData": { + "description": "Copy of the input parameter CustomData", + "type": "string" + }, + "MinedData": { + "$ref": "#/definitions/ZenidShared.MineAllResult", + "description": "Structure of data, mined from sample - {ZenidShared.MineAllResult}." + }, + "DocumentsData": { + "description": "If investigation covers multiple documents, each will have their own entry here", + "type": "array", + "items": { + "$ref": "#/definitions/ZenidShared.MineAllResult" + } + }, + "InvestigationUrl": { + "description": "URL of the investigation detail", + "type": "string" + }, + "ValidatorResults": { + "description": "Result of the all validators - List of {ZenidWeb.InvestigationValidatorResponse}", + "type": "array", + "items": { + "$ref": "#/definitions/ZenidWeb.InvestigationValidatorResponse" + } + }, + "State": { + "description": "State of the request - NotDone/Done/Error", + "enum": [ + "NotDone", + "Done", + "Error", + "Operator", + "Rejected" + ], + "type": "string" + }, + "ErrorCode": { + "description": "If throght processing some error occurs, ErrorCode property is set.", + "enum": [ + "UnknownSampleID", + "UnknownUploadSessionID", + "EmptyBody", + "InternalServerError", + "InvalidTimeStamp", + "SampleInInvalidState", + "InvalidSampleCombination", + "AccessDenied", + "UnknownPerson", + "InvalidInputData" + ], + "type": "string" + }, + "ErrorText": { + "description": "Error text", + "type": "string" + }, + "MessageType": { + "type": "string", + "readOnly": true + } + } + }, + "ZenidWeb.InvestigationValidatorResponse": { + "type": "object", + "properties": { + "Name": { + "type": "string" + }, + "Code": { + "format": "int32", + "description": "Code identification of validator in external system", + "type": "integer" + }, + "Score": { + "format": "int32", + "description": "Score of the validator for given input", + "type": "integer" + }, + "AcceptScore": { + "format": "int32", + "description": "Accept score - if score is higher than accept score, Validator response OK is set to true", + "type": "integer" + }, + "Issues": { + "description": "Description of the issues of validation (why score is lower)", + "type": "array", + "items": { + "$ref": "#/definitions/ZenidWeb.InvestigationIssueResponse" + } + }, + "Ok": { + "type": "boolean" + } + } + }, + "ZenidWeb.InvestigationIssueResponse": { + "type": "object", + "properties": { + "IssueUrl": { + "description": "Url with detailed visualization of the issue.", + "type": "string" + }, + "IssueDescription": { + "description": "Description of issue", + "type": "string" + }, + "DocumentCode": { + "description": "Document code of sample, where issue is present", + "enum": [ + "IDC2", + "DRV", + "IDC1", + "PAS", + "SK_IDC_2008plus", + "SK_DRV_2004_08_09", + "SK_DRV_2013", + "SK_DRV_2015", + "SK_PAS_2008_14", + "SK_DRV_1993", + "PL_IDC_2015", + "DE_IDC_2010", + "DE_IDC_2001", + "HR_IDC_2013_15", + "AT_IDE_2000", + "HU_IDC_2000_01_12", + "HU_IDC_2016", + "AT_IDC_2002_05_10", + "HU_ADD_2012", + "AT_PAS_2006_14", + "AT_DRV_2006", + "AT_DRV_2013", + "CZ_RES_2011_14", + "CZ_RES_2006_T", + "CZ_RES_2006_07", + "CZ_GUN_2014", + "HU_PAS_2006_12", + "HU_DRV_2012_13", + "HU_DRV_2012_B", + "EU_EHIC_2004_A", + "Unknown", + "CZ_GUN_2017", + "CZ_RES_2020", + "PL_IDC_2019", + "IT_PAS_2006_10", + "INT_ISIC_2008", + "DE_PAS", + "DK_PAS", + "ES_PAS", + "FI_PAS", + "FR_PAS", + "GB_PAS", + "IS_PAS", + "NL_PAS", + "RO_PAS", + "SE_PAS", + "PL_PAS", + "PL_DRV_2013", + "CZ_BIRTH", + "CZ_VEHICLE_I", + "INT_ISIC_2019", + "SI_PAS", + "SI_IDC", + "SI_DRV", + "EU_EHIC_2004_B", + "PL_IDC_2001_02_13", + "IT_IDC_2016", + "HR_PAS_2009_15", + "HR_DRV_2013", + "HR_IDC_2003", + "SI_DRV_2009", + "BG_PAS_2010", + "BG_IDC_2010", + "BG_DRV_2010_13", + "HR_IDC_2021", + "AT_IDC_2021", + "DE_PAS_2007", + "DE_DRV_2013_21", + "DE_DRV_1999_01_04_11", + "FR_IDC_2021", + "FR_IDC_1988_94", + "ES_PAS_2003_06", + "ES_IDC_2015", + "ES_IDC_2006", + "IT_IDC_2004", + "RO_IDC_2001_06_09_17_21", + "NL_IDC_2014_17_21", + "BE_PAS_2014_17_19", + "BE_IDC_2013_15", + "BE_IDC_2020_21", + "GR_PAS_2020", + "PT_PAS_2006_09", + "PT_PAS_2017", + "PT_IDC_2007_08_09_15", + "SE_IDC_2012_21", + "FI_IDC_2017_21", + "IE_PAS_2006_13", + "LT_PAS_2008_09_11_19", + "LT_IDC_2009_12", + "LV_PAS_2015", + "LV_PAS_2007", + "LV_IDC_2012", + "LV_IDC_2019", + "EE_PAS_2014", + "EE_PAS_2021", + "EE_IDC_2011", + "EE_IDC_2018_21", + "CY_PAS_2010_20", + "CY_IDC_2000_08", + "CY_IDC_2015_20", + "LU_PAS_2015", + "LU_IDC_2014_21", + "LU_IDC_2008_13", + "MT_PAS_2008", + "MT_IDC_2014", + "PL_PAS_2011", + "PL_DRV_1999", + "LT_IDC_2021" + ], + "type": "string" + }, + "FieldID": { + "description": "FieldID wher issue is present", + "enum": [ + "A1", + "A2", + "A3", + "FirstName", + "LastName", + "Photo", + "BirthDate", + "BirthNumber", + "Authority", + "Mrz1", + "Mrz2", + "Mrz3", + "IdcardNumber", + "Sex", + "MaritalStatus", + "BirthAddress", + "BA1", + "BA2", + "IssueDate", + "ExpiryDate", + "PassportNumber", + "DrivinglicenseNumber", + "Barcode", + "BirthLastName", + "SpecialRemarks", + "Height", + "EyesColor", + "Titles", + "Authority1", + "Authority2", + "LastName1", + "LastName2TitlesAfter", + "DrvCodes", + "Signature", + "OtherInfo", + "MiniHologram", + "MiniPhoto", + "CarNumber", + "LicenseTypes", + "FirstNameOfParents", + "BirthDateNumber", + "DrivinglicenseNumber2", + "RDIFChipAccess", + "Pseudonym", + "ResidencyPermitDescription", + "ResidencyPermitCode", + "ResidencyNumber", + "AuthorityAndIssueDate", + "Nationality", + "GunlicenseNumber", + "Stamp", + "Stamp2", + "SurnameAndName1", + "SurnameAndName2", + "SurnameAndName3", + "MothersSurnameAndName", + "TemporaryAddress1", + "TemporaryAddress2", + "AddressStartingDate", + "TemporaryAddressStartingDate", + "TemporaryAddressEndingDate", + "NameInNationalLanguage", + "BirthDateAndAddress", + "SpecialRemarks2", + "SpecialRemarks3", + "Unknown", + "HealthInsuranceCardNumber", + "InsuranceCompanyCode", + "IssuingCountry", + "ResidencyNumberPhoto", + "IssueDateAndAuthority", + "TitlesAfter", + "PlaceOfIssue", + "BirthAddressAndDate", + "IssueDateAndPlaceOfIssue", + "MothersSurname", + "MothersName", + "FathersSurname", + "FathersName", + "LastName2", + "A4", + "FirstName2", + "IssueAndExpiryDate", + "FiscalNumber", + "SocialNumber", + "AlternativeName" + ], + "type": "string" + }, + "SampleID": { + "description": "ID of the identification issue", + "type": "string" + }, + "PageCode": { + "description": "Identification of the page type for issue", + "enum": [ + "F", + "B" + ], + "type": "string" + }, + "SampleType": { + "description": "Type of sample", + "enum": [ + "Unknown", + "Selfie", + "DocumentPicture", + "SelfieVideo", + "DocumentVideo", + "Archived" + ], + "type": "string" + } + } + }, + "ZenidWeb.DeletePersonResponse": { + "type": "object", + "properties": { + "DeletedSampleIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "DeletedFacesFromSampleIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "ErrorCode": { + "description": "If throght processing some error occurs, ErrorCode property is set.", + "enum": [ + "UnknownSampleID", + "UnknownUploadSessionID", + "EmptyBody", + "InternalServerError", + "InvalidTimeStamp", + "SampleInInvalidState", + "InvalidSampleCombination", + "AccessDenied", + "UnknownPerson", + "InvalidInputData" + ], + "type": "string" + }, + "ErrorText": { + "description": "Error text", + "type": "string" + }, + "MessageType": { + "type": "string", + "readOnly": true + } + } + }, + "ZenidWeb.DeleteSampleResponse": { + "type": "object", + "properties": { + "ErrorCode": { + "description": "If throght processing some error occurs, ErrorCode property is set.", + "enum": [ + "UnknownSampleID", + "UnknownUploadSessionID", + "EmptyBody", + "InternalServerError", + "InvalidTimeStamp", + "SampleInInvalidState", + "InvalidSampleCombination", + "AccessDenied", + "UnknownPerson", + "InvalidInputData" + ], + "type": "string" + }, + "ErrorText": { + "description": "Error text", + "type": "string" + }, + "MessageType": { + "type": "string", + "readOnly": true + } + } + }, + "ZenidWeb.ListSamplesResponse": { + "description": "Return value of api/samples", + "type": "object", + "properties": { + "Results": { + "description": "List of declarations of samples - ID, CustomData, UploadSessionID, State", + "type": "array", + "items": { + "$ref": "#/definitions/ZenidWeb.ListSamplesResponse.SampleItem" + } + }, + "TimeStamp": { + "format": "int64", + "description": "Timestamp limit (if defined as input)", + "type": "integer" + }, + "ErrorCode": { + "description": "If throght processing some error occurs, ErrorCode property is set.", + "enum": [ + "UnknownSampleID", + "UnknownUploadSessionID", + "EmptyBody", + "InternalServerError", + "InvalidTimeStamp", + "SampleInInvalidState", + "InvalidSampleCombination", + "AccessDenied", + "UnknownPerson", + "InvalidInputData" + ], + "type": "string" + }, + "ErrorText": { + "description": "Error text", + "type": "string" + }, + "MessageType": { + "type": "string", + "readOnly": true + } + } + }, + "ZenidWeb.ListSamplesResponse.SampleItem": { + "type": "object", + "properties": { + "SampleID": { + "description": "DB ID of given sample.", + "type": "string" + }, + "ParentSampleID": { + "description": "If the sample is subsample image created from primary one, this is the ID of primary image", + "type": "string" + }, + "CustomData": { + "description": "CustomData attribute (copied from Request)", + "type": "string" + }, + "UploadSessionID": { + "format": "uuid", + "description": "GUID of upload session set.", + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + }, + "State": { + "description": "State of the investigation", + "enum": [ + "NotDone", + "Done", + "Error", + "Operator", + "Rejected" + ], + "type": "string" + } + } + }, + "ZenidWeb.ListInvestigationsResponse": { + "description": "Return value of api/investigation", + "type": "object", + "properties": { + "Results": { + "description": "List of declarations of samples - ID, CustpmData, State", + "type": "array", + "items": { + "$ref": "#/definitions/ZenidWeb.ListInvestigationsResponse.InvestigateItem" + } + }, + "TimeStamp": { + "format": "int64", + "description": "Timestamp limit (if defined as input)", + "type": "integer" + }, + "ErrorCode": { + "description": "If throght processing some error occurs, ErrorCode property is set.", + "enum": [ + "UnknownSampleID", + "UnknownUploadSessionID", + "EmptyBody", + "InternalServerError", + "InvalidTimeStamp", + "SampleInInvalidState", + "InvalidSampleCombination", + "AccessDenied", + "UnknownPerson", + "InvalidInputData" + ], + "type": "string" + }, + "ErrorText": { + "description": "Error text", + "type": "string" + }, + "MessageType": { + "type": "string", + "readOnly": true + } + } + }, + "ZenidWeb.ListInvestigationsResponse.InvestigateItem": { + "description": "Short description of investigation (its ID, State and CUstomData)", + "type": "object", + "properties": { + "InvestigationID": { + "format": "int32", + "description": "DB ID of investigation", + "type": "integer" + }, + "CustomData": { + "description": "CustomData attribute (copied from Request)", + "type": "string" + }, + "State": { + "description": "State of the investigation", + "enum": [ + "NotDone", + "Done", + "Error", + "Operator", + "Rejected" + ], + "type": "string" + } + } + }, + "ZenidWeb.ListProfilesResponse": { + "description": "Return value of api/profiles", + "type": "object", + "properties": { + "Results": { + "description": "List of names of profiles", + "type": "array", + "items": { + "type": "string" + } + }, + "ErrorCode": { + "description": "If throght processing some error occurs, ErrorCode property is set.", + "enum": [ + "UnknownSampleID", + "UnknownUploadSessionID", + "EmptyBody", + "InternalServerError", + "InvalidTimeStamp", + "SampleInInvalidState", + "InvalidSampleCombination", + "AccessDenied", + "UnknownPerson", + "InvalidInputData" + ], + "type": "string" + }, + "ErrorText": { + "description": "Error text", + "type": "string" + }, + "MessageType": { + "type": "string", + "readOnly": true + } + } + }, + "ZenidWeb.DiagnosticsResponse": { + "description": "Response object for UploadSample", + "type": "object", + "properties": { + "IsAllOk": { + "type": "boolean" + }, + "SelfCheckItems": { + "type": "array", + "items": { + "$ref": "#/definitions/ZenidWeb.Controllers.SelfCheck.SelfCheckItem" + } + }, + "LicenseExpiration": { + "format": "date-time", + "type": "string" + }, + "LicenseRemaining": { + "$ref": "#/definitions/ZenidShared.LicenseCountables" + }, + "SupportedDocuments": { + "type": "array", + "items": { + "enum": [ + "IDC2", + "DRV", + "IDC1", + "PAS", + "SK_IDC_2008plus", + "SK_DRV_2004_08_09", + "SK_DRV_2013", + "SK_DRV_2015", + "SK_PAS_2008_14", + "SK_DRV_1993", + "PL_IDC_2015", + "DE_IDC_2010", + "DE_IDC_2001", + "HR_IDC_2013_15", + "AT_IDE_2000", + "HU_IDC_2000_01_12", + "HU_IDC_2016", + "AT_IDC_2002_05_10", + "HU_ADD_2012", + "AT_PAS_2006_14", + "AT_DRV_2006", + "AT_DRV_2013", + "CZ_RES_2011_14", + "CZ_RES_2006_T", + "CZ_RES_2006_07", + "CZ_GUN_2014", + "HU_PAS_2006_12", + "HU_DRV_2012_13", + "HU_DRV_2012_B", + "EU_EHIC_2004_A", + "Unknown", + "CZ_GUN_2017", + "CZ_RES_2020", + "PL_IDC_2019", + "IT_PAS_2006_10", + "INT_ISIC_2008", + "DE_PAS", + "DK_PAS", + "ES_PAS", + "FI_PAS", + "FR_PAS", + "GB_PAS", + "IS_PAS", + "NL_PAS", + "RO_PAS", + "SE_PAS", + "PL_PAS", + "PL_DRV_2013", + "CZ_BIRTH", + "CZ_VEHICLE_I", + "INT_ISIC_2019", + "SI_PAS", + "SI_IDC", + "SI_DRV", + "EU_EHIC_2004_B", + "PL_IDC_2001_02_13", + "IT_IDC_2016", + "HR_PAS_2009_15", + "HR_DRV_2013", + "HR_IDC_2003", + "SI_DRV_2009", + "BG_PAS_2010", + "BG_IDC_2010", + "BG_DRV_2010_13", + "HR_IDC_2021", + "AT_IDC_2021", + "DE_PAS_2007", + "DE_DRV_2013_21", + "DE_DRV_1999_01_04_11", + "FR_IDC_2021", + "FR_IDC_1988_94", + "ES_PAS_2003_06", + "ES_IDC_2015", + "ES_IDC_2006", + "IT_IDC_2004", + "RO_IDC_2001_06_09_17_21", + "NL_IDC_2014_17_21", + "BE_PAS_2014_17_19", + "BE_IDC_2013_15", + "BE_IDC_2020_21", + "GR_PAS_2020", + "PT_PAS_2006_09", + "PT_PAS_2017", + "PT_IDC_2007_08_09_15", + "SE_IDC_2012_21", + "FI_IDC_2017_21", + "IE_PAS_2006_13", + "LT_PAS_2008_09_11_19", + "LT_IDC_2009_12", + "LV_PAS_2015", + "LV_PAS_2007", + "LV_IDC_2012", + "LV_IDC_2019", + "EE_PAS_2014", + "EE_PAS_2021", + "EE_IDC_2011", + "EE_IDC_2018_21", + "CY_PAS_2010_20", + "CY_IDC_2000_08", + "CY_IDC_2015_20", + "LU_PAS_2015", + "LU_IDC_2014_21", + "LU_IDC_2008_13", + "MT_PAS_2008", + "MT_IDC_2014", + "PL_PAS_2011", + "PL_DRV_1999", + "LT_IDC_2021" + ], + "type": "string" + } + }, + "ErrorCode": { + "description": "If throght processing some error occurs, ErrorCode property is set.", + "enum": [ + "UnknownSampleID", + "UnknownUploadSessionID", + "EmptyBody", + "InternalServerError", + "InvalidTimeStamp", + "SampleInInvalidState", + "InvalidSampleCombination", + "AccessDenied", + "UnknownPerson", + "InvalidInputData" + ], + "type": "string" + }, + "ErrorText": { + "description": "Error text", + "type": "string" + }, + "MessageType": { + "type": "string", + "readOnly": true + } + } + }, + "ZenidWeb.Controllers.SelfCheck.SelfCheckItem": { + "type": "object", + "properties": { + "Name": { + "type": "string" + }, + "Status": { + "type": "boolean" + }, + "Comment": { + "type": "string" + } + } + }, + "ZenidShared.LicenseCountables": { + "type": "object", + "properties": { + "PageCount": { + "format": "int32", + "description": "Note this is actually \"document count\"", + "type": "integer" + }, + "SelfieCount": { + "format": "int32", + "type": "integer" + }, + "FraudCount": { + "format": "int32", + "type": "integer" + } + } + }, + "ZenidWeb.InitSdkResponse": { + "type": "object", + "properties": { + "Response": { + "type": "string" + }, + "ErrorCode": { + "description": "If throght processing some error occurs, ErrorCode property is set.", + "enum": [ + "UnknownSampleID", + "UnknownUploadSessionID", + "EmptyBody", + "InternalServerError", + "InvalidTimeStamp", + "SampleInInvalidState", + "InvalidSampleCombination", + "AccessDenied", + "UnknownPerson", + "InvalidInputData" + ], + "type": "string" + }, + "ErrorText": { + "description": "Error text", + "type": "string" + }, + "MessageType": { + "type": "string", + "readOnly": true + } + } + }, + "ZenidWeb.UploadFaceResponse": { + "description": "Return object for /api/face", + "type": "object", + "properties": { + "UploadFaceResult": { + "description": "Possibly result of the upload face photo", + "enum": [ + "Ok", + "FaceNotDetected", + "ImageExistsWithDifferentCustomerData" + ], + "type": "string" + }, + "OriginalImageHash": { + "description": "hash of the original image", + "type": "string" + }, + "PersistedFace": { + "format": "uuid", + "description": "GUID - link of the face image in the Oxford API repository", + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + }, + "ErrorCode": { + "description": "If throght processing some error occurs, ErrorCode property is set.", + "enum": [ + "UnknownSampleID", + "UnknownUploadSessionID", + "EmptyBody", + "InternalServerError", + "InvalidTimeStamp", + "SampleInInvalidState", + "InvalidSampleCombination", + "AccessDenied", + "UnknownPerson", + "InvalidInputData" + ], + "type": "string" + }, + "ErrorText": { + "description": "Error text", + "type": "string" + }, + "MessageType": { + "type": "string", + "readOnly": true + } + } + }, + "ZenidWeb.VerifyCardsRecalledRequest": { + "type": "object", + "properties": { + "CardsToVerify": { + "type": "array", + "items": { + "$ref": "#/definitions/ZenidWeb.VerifyCardsRecalledRequest.CardInfo" + } + } + } + }, + "ZenidWeb.VerifyCardsRecalledRequest.CardInfo": { + "type": "object", + "properties": { + "DocumentCode": { + "enum": [ + "IDC2", + "DRV", + "IDC1", + "PAS", + "SK_IDC_2008plus", + "SK_DRV_2004_08_09", + "SK_DRV_2013", + "SK_DRV_2015", + "SK_PAS_2008_14", + "SK_DRV_1993", + "PL_IDC_2015", + "DE_IDC_2010", + "DE_IDC_2001", + "HR_IDC_2013_15", + "AT_IDE_2000", + "HU_IDC_2000_01_12", + "HU_IDC_2016", + "AT_IDC_2002_05_10", + "HU_ADD_2012", + "AT_PAS_2006_14", + "AT_DRV_2006", + "AT_DRV_2013", + "CZ_RES_2011_14", + "CZ_RES_2006_T", + "CZ_RES_2006_07", + "CZ_GUN_2014", + "HU_PAS_2006_12", + "HU_DRV_2012_13", + "HU_DRV_2012_B", + "EU_EHIC_2004_A", + "Unknown", + "CZ_GUN_2017", + "CZ_RES_2020", + "PL_IDC_2019", + "IT_PAS_2006_10", + "INT_ISIC_2008", + "DE_PAS", + "DK_PAS", + "ES_PAS", + "FI_PAS", + "FR_PAS", + "GB_PAS", + "IS_PAS", + "NL_PAS", + "RO_PAS", + "SE_PAS", + "PL_PAS", + "PL_DRV_2013", + "CZ_BIRTH", + "CZ_VEHICLE_I", + "INT_ISIC_2019", + "SI_PAS", + "SI_IDC", + "SI_DRV", + "EU_EHIC_2004_B", + "PL_IDC_2001_02_13", + "IT_IDC_2016", + "HR_PAS_2009_15", + "HR_DRV_2013", + "HR_IDC_2003", + "SI_DRV_2009", + "BG_PAS_2010", + "BG_IDC_2010", + "BG_DRV_2010_13", + "HR_IDC_2021", + "AT_IDC_2021", + "DE_PAS_2007", + "DE_DRV_2013_21", + "DE_DRV_1999_01_04_11", + "FR_IDC_2021", + "FR_IDC_1988_94", + "ES_PAS_2003_06", + "ES_IDC_2015", + "ES_IDC_2006", + "IT_IDC_2004", + "RO_IDC_2001_06_09_17_21", + "NL_IDC_2014_17_21", + "BE_PAS_2014_17_19", + "BE_IDC_2013_15", + "BE_IDC_2020_21", + "GR_PAS_2020", + "PT_PAS_2006_09", + "PT_PAS_2017", + "PT_IDC_2007_08_09_15", + "SE_IDC_2012_21", + "FI_IDC_2017_21", + "IE_PAS_2006_13", + "LT_PAS_2008_09_11_19", + "LT_IDC_2009_12", + "LV_PAS_2015", + "LV_PAS_2007", + "LV_IDC_2012", + "LV_IDC_2019", + "EE_PAS_2014", + "EE_PAS_2021", + "EE_IDC_2011", + "EE_IDC_2018_21", + "CY_PAS_2010_20", + "CY_IDC_2000_08", + "CY_IDC_2015_20", + "LU_PAS_2015", + "LU_IDC_2014_21", + "LU_IDC_2008_13", + "MT_PAS_2008", + "MT_IDC_2014", + "PL_PAS_2011", + "PL_DRV_1999", + "LT_IDC_2021" + ], + "type": "string" + }, + "CardNumber": { + "type": "string" + } + } + }, + "ZenidWeb.VerifyCardsRecalledResponse": { + "type": "object", + "properties": { + "VerifiedCards": { + "type": "array", + "items": { + "$ref": "#/definitions/ZenidWeb.VerifyCardsRecalledResponse.VerifiedCard" + } + }, + "ErrorCode": { + "description": "If throght processing some error occurs, ErrorCode property is set.", + "enum": [ + "UnknownSampleID", + "UnknownUploadSessionID", + "EmptyBody", + "InternalServerError", + "InvalidTimeStamp", + "SampleInInvalidState", + "InvalidSampleCombination", + "AccessDenied", + "UnknownPerson", + "InvalidInputData" + ], + "type": "string" + }, + "ErrorText": { + "description": "Error text", + "type": "string" + }, + "MessageType": { + "type": "string", + "readOnly": true + } + } + }, + "ZenidWeb.VerifyCardsRecalledResponse.VerifiedCard": { + "type": "object", + "properties": { + "Recalled": { + "type": "boolean" + }, + "DocumentCode": { + "enum": [ + "IDC2", + "DRV", + "IDC1", + "PAS", + "SK_IDC_2008plus", + "SK_DRV_2004_08_09", + "SK_DRV_2013", + "SK_DRV_2015", + "SK_PAS_2008_14", + "SK_DRV_1993", + "PL_IDC_2015", + "DE_IDC_2010", + "DE_IDC_2001", + "HR_IDC_2013_15", + "AT_IDE_2000", + "HU_IDC_2000_01_12", + "HU_IDC_2016", + "AT_IDC_2002_05_10", + "HU_ADD_2012", + "AT_PAS_2006_14", + "AT_DRV_2006", + "AT_DRV_2013", + "CZ_RES_2011_14", + "CZ_RES_2006_T", + "CZ_RES_2006_07", + "CZ_GUN_2014", + "HU_PAS_2006_12", + "HU_DRV_2012_13", + "HU_DRV_2012_B", + "EU_EHIC_2004_A", + "Unknown", + "CZ_GUN_2017", + "CZ_RES_2020", + "PL_IDC_2019", + "IT_PAS_2006_10", + "INT_ISIC_2008", + "DE_PAS", + "DK_PAS", + "ES_PAS", + "FI_PAS", + "FR_PAS", + "GB_PAS", + "IS_PAS", + "NL_PAS", + "RO_PAS", + "SE_PAS", + "PL_PAS", + "PL_DRV_2013", + "CZ_BIRTH", + "CZ_VEHICLE_I", + "INT_ISIC_2019", + "SI_PAS", + "SI_IDC", + "SI_DRV", + "EU_EHIC_2004_B", + "PL_IDC_2001_02_13", + "IT_IDC_2016", + "HR_PAS_2009_15", + "HR_DRV_2013", + "HR_IDC_2003", + "SI_DRV_2009", + "BG_PAS_2010", + "BG_IDC_2010", + "BG_DRV_2010_13", + "HR_IDC_2021", + "AT_IDC_2021", + "DE_PAS_2007", + "DE_DRV_2013_21", + "DE_DRV_1999_01_04_11", + "FR_IDC_2021", + "FR_IDC_1988_94", + "ES_PAS_2003_06", + "ES_IDC_2015", + "ES_IDC_2006", + "IT_IDC_2004", + "RO_IDC_2001_06_09_17_21", + "NL_IDC_2014_17_21", + "BE_PAS_2014_17_19", + "BE_IDC_2013_15", + "BE_IDC_2020_21", + "GR_PAS_2020", + "PT_PAS_2006_09", + "PT_PAS_2017", + "PT_IDC_2007_08_09_15", + "SE_IDC_2012_21", + "FI_IDC_2017_21", + "IE_PAS_2006_13", + "LT_PAS_2008_09_11_19", + "LT_IDC_2009_12", + "LV_PAS_2015", + "LV_PAS_2007", + "LV_IDC_2012", + "LV_IDC_2019", + "EE_PAS_2014", + "EE_PAS_2021", + "EE_IDC_2011", + "EE_IDC_2018_21", + "CY_PAS_2010_20", + "CY_IDC_2000_08", + "CY_IDC_2015_20", + "LU_PAS_2015", + "LU_IDC_2014_21", + "LU_IDC_2008_13", + "MT_PAS_2008", + "MT_IDC_2014", + "PL_PAS_2011", + "PL_DRV_1999", + "LT_IDC_2021" + ], + "type": "string" + }, + "CardNumber": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/enrollment-server/src/main/resources/api/api-zenid.yaml b/enrollment-server/src/main/resources/api/api-zenid.yaml new file mode 100644 index 000000000..78eff50e6 --- /dev/null +++ b/enrollment-server/src/main/resources/api/api-zenid.yaml @@ -0,0 +1,2511 @@ +swagger: '2.0' +info: + version: v1 + title: ZenidWeb +host: raiffeisen.frauds.zenid.cz +schemes: + - https +paths: + /api/sample: + post: + tags: + - Api + summary: "This method uploads a sample for processing. Sample can be a single image or video file. This call gets file, normalize it (e.t. rotate etc) and OCR its content.\r\nThe file content can be sent in two different formats:\r\nRAW: send the file content as body of the request in binary form without alternations\r\nFORM: use the multipart form encoding and send the file content in file variable\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nEmptyBody - no uploaded file." + operationId: Api_UploadSample + consumes: + - multipart/form-data + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: expectedSampleType + in: query + description: >- + Expected type of sample. Set this if you know the type of sample + ahead to speed up processing. Required. + required: true + type: string + enum: + - Unknown + - Selfie + - DocumentPicture + - SelfieVideo + - DocumentVideo + - Archived + - name: uploadSessionID + in: query + description: >- + SessionID is GUID created by client to group multiple sample uploads + together + required: false + type: string + format: uuid + - name: customData + in: query + description: >- + Custom data to be associated with this sample. Sample can later be + located using customData. Any string. + required: false + type: string + - name: fileName + in: query + description: >- + Name of the original file (for example DSC01.jpg). If form upload is + needed, then this is optional + required: false + type: string + - name: country + in: query + description: Expected country of the uploaded document (Cz, At, Sk...) + required: false + type: string + enum: + - Cz + - Sk + - At + - Hu + - Pl + - De + - Hr + - Ro + - Ru + - Ua + - It + - Dk + - Es + - Fi + - Fr + - Gb + - Is + - Nl + - Se + - Si + - Bg + - Be + - Ee + - Ie + - Cy + - Lt + - Lv + - Lu + - Mt + - Pt + - Gr + - name: role + in: query + description: >- + Expected role of the uploaded document (IDC, Passport, Drivers + licence) + required: false + type: string + enum: + - Idc + - Pas + - Drv + - Res + - Gun + - Hic + - Std + - Car + - Birth + - Add + - Ide + - name: fileLastWriteTime + in: query + description: >- + Last write time of the file uploaded. This can be determined using + javascript. Value is used for fraud detection (EXIF comparison) + required: false + type: string + format: date-time + - name: async + in: query + description: >- + Set this true if you want this request to respond as soon as the + data are uploaded for processing, not waiting for result. + required: false + type: boolean + - name: callbackUrl + in: query + description: >- + Set URL for call back here, if you want to receive JSON object with + response there. + required: false + type: string + - name: searchForSubsamples + in: query + description: >- + For videos, this makes ZenID extract static picture of the card from + the video of card. For document pictures, if single sample contains + multiple card pictures (for example multi-page PDF or scan with + multiple picture), set this to true to search for them and extract + as separate "subsamples" + required: false + type: boolean + - name: anonymizeImage + in: query + description: >- + ZenID can optionally anonymize specific documents by blackening + certain fields. Set this to true to perform anonymization + required: false + type: boolean + - name: documentCode + in: query + description: Code of the specific document if you know it + required: false + type: string + enum: + - IDC2 + - DRV + - IDC1 + - PAS + - SK_IDC_2008plus + - SK_DRV_2004_08_09 + - SK_DRV_2013 + - SK_DRV_2015 + - SK_PAS_2008_14 + - SK_DRV_1993 + - PL_IDC_2015 + - DE_IDC_2010 + - DE_IDC_2001 + - HR_IDC_2013_15 + - AT_IDE_2000 + - HU_IDC_2000_01_12 + - HU_IDC_2016 + - AT_IDC_2002_05_10 + - HU_ADD_2012 + - AT_PAS_2006_14 + - AT_DRV_2006 + - AT_DRV_2013 + - CZ_RES_2011_14 + - CZ_RES_2006_T + - CZ_RES_2006_07 + - CZ_GUN_2014 + - HU_PAS_2006_12 + - HU_DRV_2012_13 + - HU_DRV_2012_B + - EU_EHIC_2004_A + - Unknown + - CZ_GUN_2017 + - CZ_RES_2020 + - PL_IDC_2019 + - IT_PAS_2006_10 + - INT_ISIC_2008 + - DE_PAS + - DK_PAS + - ES_PAS + - FI_PAS + - FR_PAS + - GB_PAS + - IS_PAS + - NL_PAS + - RO_PAS + - SE_PAS + - PL_PAS + - PL_DRV_2013 + - CZ_BIRTH + - CZ_VEHICLE_I + - INT_ISIC_2019 + - SI_PAS + - SI_IDC + - SI_DRV + - EU_EHIC_2004_B + - PL_IDC_2001_02_13 + - IT_IDC_2016 + - HR_PAS_2009_15 + - HR_DRV_2013 + - HR_IDC_2003 + - SI_DRV_2009 + - BG_PAS_2010 + - BG_IDC_2010 + - BG_DRV_2010_13 + - HR_IDC_2021 + - AT_IDC_2021 + - DE_PAS_2007 + - DE_DRV_2013_21 + - DE_DRV_1999_01_04_11 + - FR_IDC_2021 + - FR_IDC_1988_94 + - ES_PAS_2003_06 + - ES_IDC_2015 + - ES_IDC_2006 + - IT_IDC_2004 + - RO_IDC_2001_06_09_17_21 + - NL_IDC_2014_17_21 + - BE_PAS_2014_17_19 + - BE_IDC_2013_15 + - BE_IDC_2020_21 + - GR_PAS_2020 + - PT_PAS_2006_09 + - PT_PAS_2017 + - PT_IDC_2007_08_09_15 + - SE_IDC_2012_21 + - FI_IDC_2017_21 + - IE_PAS_2006_13 + - LT_PAS_2008_09_11_19 + - LT_IDC_2009_12 + - LV_PAS_2015 + - LV_PAS_2007 + - LV_IDC_2012 + - LV_IDC_2019 + - EE_PAS_2014 + - EE_PAS_2021 + - EE_IDC_2011 + - EE_IDC_2018_21 + - CY_PAS_2010_20 + - CY_IDC_2000_08 + - CY_IDC_2015_20 + - LU_PAS_2015 + - LU_IDC_2014_21 + - LU_IDC_2008_13 + - MT_PAS_2008 + - MT_IDC_2014 + - PL_PAS_2011 + - PL_DRV_1999 + - LT_IDC_2021 + - name: pageCode + in: query + description: Side of the document to seek if you know it + required: false + type: string + enum: + - F + - B + - name: priorityQueueName + in: query + description: >- + Setting this puts the request into a separate queue for processing, + ignoring standard queue. + required: false + type: string + - name: profile + in: query + description: >- + Optional name of profile. Use it to sort samples by different input + channels for example. + required: false + type: string + - name: processingMode + in: query + description: >- + Fast (default) or Slow. Slow process mode might fix some alignment + problems but is slower. + required: false + type: string + enum: + - Fast + - Slow + - name: sdkSignature + in: query + description: Optional SDK signature for pictures. Generated by SDK + required: false + type: string + - name: File + in: formData + description: Upload samples file + required: true + type: file + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.UploadSampleResponse' + /api/investigateSamples: + get: + tags: + - Api + summary: "Investigation node. Investigation gets list of samples, combine mined data from them in one combined object (see MinedAllData), and validate it with set of validators.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nUnknownSampleID - any of sampleIDs is not stored in DB.\r\nSampleInInvalidState - sample is in invalid state - for example it is waiting for operators or processing of sample ended with error\r\nInvalidSampleCombination - investigation must contain samples from single person/customer" + operationId: Api_InvestigateSamples + consumes: [] + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: sampleIDs + in: query + description: List of strings - sample IDs. Required. + required: true + type: array + items: + type: string + collectionFormat: multi + - name: profile + in: query + description: >- + Optional name of profile. Each profile defines settings for + validators. + required: false + type: string + - name: customData + in: query + description: >- + Custom data to be associated with this sample. Sample can later be + located using customData. Any string. + required: false + type: string + - name: async + in: query + description: >- + Set this true if you want this request to respond as soon as the + data are uploaded for processing, not waiting for result. + required: false + type: boolean + - name: callbackUrl + in: query + description: >- + Set URL for call back here, if you want to receive JSON object with + response there. + required: false + type: string + - name: language + in: query + description: Language of the output (Czech/English). + required: false + type: string + enum: + - English + - Czech + - Polish + - German + - name: priorityQueueName + in: query + description: >- + Setting this puts the request into a separate queue for processing, + ignoring standard queue. + required: false + type: string + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.InvestigateResponse' + /api/investigateUploadSession: + get: + tags: + - Api + summary: "Investigation node. Investigation gets GUID, unique identifier, gets all samples tagged with this UploadSessionID, combine mined data from them in one combined object (see MinedAllData), and validate it with set of validators.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nUnknownUploadSessionID - UploadSessionID is not known\r\nSampleInInvalidState - sample is in invalid state - for example it is waiting for operators or processing of sample ended with error\r\nInvalidSampleCombination - investigation must contain samples from single person/customer" + operationId: Api_InvestigateUploadSession + consumes: [] + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: uploadSessionID + in: query + description: User identification of list of samples. Required. + required: true + type: string + format: uuid + - name: profile + in: query + description: >- + Optional name of profile. Each profile defines settings for + validators. + required: false + type: string + - name: customData + in: query + description: >- + Custom data to be associated with this sample. Sample can later be + located using customData. Any string. + required: false + type: string + - name: async + in: query + description: >- + Set this true if you want this request to respond as soon as the + data are uploaded for processing, not waiting for result. + required: false + type: boolean + - name: callbackUrl + in: query + description: >- + Set URL for call back here, if you want to receive JSON object with + response there. + required: false + type: string + - name: language + in: query + description: Language of the output (Czech/English). + required: false + type: string + enum: + - English + - Czech + - Polish + - German + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.InvestigateResponse' + /api/sample/{sampleID}: + get: + tags: + - Api + summary: "Node for synchronizing samples - returns sample for given ID. This call can be used for synchronizing information about samples with external systems.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nUnknownSampleID - if that ID is not used in DB for any sample." + operationId: Api_GetSample + consumes: [] + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: sampleID + in: path + description: ID of the sample + required: true + type: string + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.UploadSampleResponse' + /api/deletePerson: + get: + tags: + - Api + summary: Deletes all information related to a person + operationId: Api_DeletePerson + consumes: [] + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: sampleId + in: query + description: ID of the sample to start person search + required: false + type: string + - name: cardIdentifier + in: query + description: ID of the card for which we search the related documents + required: false + type: string + - name: firstName + in: query + description: First name of the person whose documents are searched for + required: false + type: string + - name: lastName + in: query + description: Surname of the person whose documents are searched for + required: false + type: string + - name: birthNumber + in: query + description: >- + Birth Number (RČ in czech) of the person whose documents are + searched for + required: false + type: string + - name: birthDate + in: query + description: Date of birth of the person whose documents are searched for + required: false + type: string + format: date-time + - name: deleteType + in: query + description: >- + Set if you want delete only samples and investigations or face from + FaceDB or everything + required: false + type: string + enum: + - Everything + - FacesOnly + - SamplesAndInvestigationsOnly + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.DeletePersonResponse' + /api/deleteSample: + get: + tags: + - Api + summary: >- + Deletes a sample. Also deletes an investigation in which this sample was + used + operationId: Api_DeleteSample + consumes: [] + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: sampleId + in: query + description: ID of the sample to delete + required: false + type: string + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.DeleteSampleResponse' + /api/investigation/{investigationID}: + get: + tags: + - Api + summary: "Node for synchronizing investigations - returns investigations for given ID. This call can be used for synchronizing information about samples with external systems.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nUnknownSampleID - if Investigation ID is not stored in DB." + operationId: Api_GetInvestigation + consumes: [] + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: investigationID + in: path + description: ID of the investigation + required: true + type: integer + format: int32 + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.InvestigateResponse' + /api/samples: + get: + tags: + - Api + summary: "Get list of samples (newer than timestamp). This call can be used for synchronizing information about samples with external systems.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nInvalidTimeStamp - error while decoding timestamp." + operationId: Api_GetSamples + consumes: [] + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: timestamp + in: query + description: if defined, list is limited to samples newer than given timestamp + required: false + type: integer + format: int64 + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.ListSamplesResponse' + /api/investigations: + get: + tags: + - Api + summary: "Get list of investigations (newer than timestamp). This call can be used for synchronizing information about samples with external systems.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing,\r\nInvalidTimeStamp - error while decoding timestamp." + operationId: Api_GetInvestigations + consumes: [] + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: timestamp + in: query + description: >- + if defined, list is limited to investigations newer than given + timestamp + required: false + type: integer + format: int64 + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.ListInvestigationsResponse' + /api/profiles: + get: + tags: + - Api + summary: "Get list of names of profiles, defined in system. This call can be used for selecting profile in investigation.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing," + operationId: Api_GetProfiles + consumes: [] + produces: + - application/json + - text/json + - application/xml + - text/xml + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.ListProfilesResponse' + /api/validators: + get: + tags: + - Api + summary: "Returns list of validators - their id and text description.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these): InternalServerError - problem while preprocessing or unknown problem while processing," + operationId: Api_GetValidatorEnum + consumes: [] + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: language + in: query + description: Optional parameter for defining output language (Czech, German). + required: false + type: string + enum: + - English + - Czech + - Polish + - German + responses: + '200': + description: OK + schema: + type: object + additionalProperties: + type: string + /api/diagnostics: + get: + tags: + - Api + summary: >- + Performs diagnostics test. Note depending on parameters, license might + be consumed + operationId: Api_Diagnostics + consumes: [] + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: checkSelfie + in: query + description: Checks face API is working - THIS CAUSES LICENSE USAGE + required: false + type: boolean + - name: checkCloud + in: query + description: Checks cloud services are working - THIS CAUSES LICENSE USAGE + required: false + type: boolean + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.DiagnosticsResponse' + /api/initSdk: + get: + tags: + - Api + summary: Returns string required for a correct function of mobile and Web SDK. + operationId: Api_InitSdk + consumes: [] + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: token + in: query + description: Challenge token generated by SDK function. + required: false + type: string + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.InitSdkResponse' + /api/face: + post: + tags: + - Api + summary: "Loads face image in the image repository (for comparing faces for validation).\r\nIn most cases, /api/sample with sampleType=Selfie should be used instead.\r\nWhile processing, error could be identified.\r\nIn that case empty response with described ErrorCode and Description is returned (one of these):\r\nInternalServerError - problem while preprocessing or unknown problem while processing or disabled face database." + operationId: Api_UploadFace + consumes: + - multipart/form-data + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: birthNumber + in: query + description: Birth namber, required for image/face coupling. + required: true + type: string + - name: fileName + in: query + description: Name of the input file + required: false + type: string + - name: async + in: query + description: >- + Set this true if you want this request to respond as soon as the + data are uploaded for processing, not waiting for result. + required: false + type: boolean + - name: callbackUrl + in: query + description: Optional, used if api/face called asynchroniously + required: false + type: string + - name: File + in: formData + description: Upload face + required: true + type: file + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.UploadFaceResponse' + /api/verifyCardsRecalled: + post: + tags: + - Api + summary: "Verifies card validity using same means as existing CardRecalled validator\r\nIt takes a List of cards to verify - each with DocumentCode and card number and returns same list with Recalled status. Recalled status can be either True = recalled, False = not recalled, Null = could not determine/not supported document code." + operationId: Api_VerifyCardsRecalled + consumes: + - application/json + - text/json + - application/xml + - text/xml + - application/x-www-form-urlencoded + produces: + - application/json + - text/json + - application/xml + - text/xml + parameters: + - name: request + in: body + description: List of cards to verify - each with DocumentCode and card number + required: true + schema: + $ref: '#/definitions/ZenidWeb.VerifyCardsRecalledRequest' + responses: + '200': + description: OK + schema: + $ref: '#/definitions/ZenidWeb.VerifyCardsRecalledResponse' +definitions: + ZenidWeb.UploadSampleResponse: + description: Response object for UploadSample + type: object + properties: + SampleID: + description: Unique ID of the sample in ZenID system. + type: string + CustomData: + description: Copy of the input parameter CustomData + type: string + UploadSessionID: + format: uuid + description: Copy of the input parameter UploadSessionID + type: string + example: 00000000-0000-0000-0000-000000000000 + SampleType: + description: Real SampleType + enum: + - Unknown + - Selfie + - DocumentPicture + - SelfieVideo + - DocumentVideo + - Archived + type: string + MinedData: + $ref: '#/definitions/ZenidShared.MineAllResult' + description: Structure of data, mined from sample - {ZenidShared.MineAllResult}. + State: + description: State of the request - NotDone/Done/Error + enum: + - NotDone + - Done + - Error + - Operator + - Rejected + type: string + ProjectedImage: + $ref: '#/definitions/ZenidShared.Hash' + description: hash of the source projected image + ParentSampleID: + description: hash of the parent sampleID if this is a subsample + type: string + AnonymizedImage: + $ref: '#/definitions/ZenidShared.Hash' + description: Hash of the censored projected image + ImageUrlFormat: + description: link to the source projected image + type: string + ImagePageCount: + format: int32 + description: >- + Number of pages this document has (in case of PDF or TIFF). This can + be used in history URL /history/image/{hash}?page=1 + type: integer + Subsamples: + description: >- + If subsample processing is enable, this list contains further images + extracted from the primary image, each with extra document image + type: array + items: + $ref: '#/definitions/ZenidWeb.UploadSampleResponse' + ErrorCode: + description: If throght processing some error occurs, ErrorCode property is set. + enum: + - UnknownSampleID + - UnknownUploadSessionID + - EmptyBody + - InternalServerError + - InvalidTimeStamp + - SampleInInvalidState + - InvalidSampleCombination + - AccessDenied + - UnknownPerson + - InvalidInputData + type: string + ErrorText: + description: Error text + type: string + MessageType: + type: string + readOnly: true + ZenidShared.MineAllResult: + type: object + properties: + FirstName: + $ref: '#/definitions/ZenidShared.MinedText' + LastName: + $ref: '#/definitions/ZenidShared.MinedText' + Address: + $ref: '#/definitions/ZenidShared.MinedAddress' + BirthAddress: + $ref: '#/definitions/ZenidShared.MinedText' + BirthLastName: + $ref: '#/definitions/ZenidShared.MinedText' + BirthNumber: + $ref: '#/definitions/ZenidShared.MinedRc' + BirthDate: + $ref: '#/definitions/ZenidShared.MinedDate' + ExpiryDate: + $ref: '#/definitions/ZenidShared.MinedDate' + IssueDate: + $ref: '#/definitions/ZenidShared.MinedDate' + IdcardNumber: + $ref: '#/definitions/ZenidShared.MinedText' + description: identification number for id card - set only on id cards + DrivinglicenseNumber: + $ref: '#/definitions/ZenidShared.MinedText' + description: >- + identification number for driving licence - set only on driving + licences + PassportNumber: + $ref: '#/definitions/ZenidShared.MinedText' + description: identification number for passport - set only on passports + Sex: + $ref: '#/definitions/ZenidShared.MinedSex' + Nationality: + $ref: '#/definitions/ZenidShared.MinedText' + Authority: + $ref: '#/definitions/ZenidShared.MinedText' + description: Authority (state agency) issued this document + MaritalStatus: + $ref: '#/definitions/ZenidShared.MinedMaritalStatus' + Photo: + $ref: '#/definitions/ZenidShared.MinedPhoto' + Mrz: + $ref: '#/definitions/ZenidShared.MinedMrz' + description: Machine readable zone + DocumentCode: + description: >- + Code identificating document (when combining from more samples the + most probable version is set) + enum: + - IDC2 + - DRV + - IDC1 + - PAS + - SK_IDC_2008plus + - SK_DRV_2004_08_09 + - SK_DRV_2013 + - SK_DRV_2015 + - SK_PAS_2008_14 + - SK_DRV_1993 + - PL_IDC_2015 + - DE_IDC_2010 + - DE_IDC_2001 + - HR_IDC_2013_15 + - AT_IDE_2000 + - HU_IDC_2000_01_12 + - HU_IDC_2016 + - AT_IDC_2002_05_10 + - HU_ADD_2012 + - AT_PAS_2006_14 + - AT_DRV_2006 + - AT_DRV_2013 + - CZ_RES_2011_14 + - CZ_RES_2006_T + - CZ_RES_2006_07 + - CZ_GUN_2014 + - HU_PAS_2006_12 + - HU_DRV_2012_13 + - HU_DRV_2012_B + - EU_EHIC_2004_A + - Unknown + - CZ_GUN_2017 + - CZ_RES_2020 + - PL_IDC_2019 + - IT_PAS_2006_10 + - INT_ISIC_2008 + - DE_PAS + - DK_PAS + - ES_PAS + - FI_PAS + - FR_PAS + - GB_PAS + - IS_PAS + - NL_PAS + - RO_PAS + - SE_PAS + - PL_PAS + - PL_DRV_2013 + - CZ_BIRTH + - CZ_VEHICLE_I + - INT_ISIC_2019 + - SI_PAS + - SI_IDC + - SI_DRV + - EU_EHIC_2004_B + - PL_IDC_2001_02_13 + - IT_IDC_2016 + - HR_PAS_2009_15 + - HR_DRV_2013 + - HR_IDC_2003 + - SI_DRV_2009 + - BG_PAS_2010 + - BG_IDC_2010 + - BG_DRV_2010_13 + - HR_IDC_2021 + - AT_IDC_2021 + - DE_PAS_2007 + - DE_DRV_2013_21 + - DE_DRV_1999_01_04_11 + - FR_IDC_2021 + - FR_IDC_1988_94 + - ES_PAS_2003_06 + - ES_IDC_2015 + - ES_IDC_2006 + - IT_IDC_2004 + - RO_IDC_2001_06_09_17_21 + - NL_IDC_2014_17_21 + - BE_PAS_2014_17_19 + - BE_IDC_2013_15 + - BE_IDC_2020_21 + - GR_PAS_2020 + - PT_PAS_2006_09 + - PT_PAS_2017 + - PT_IDC_2007_08_09_15 + - SE_IDC_2012_21 + - FI_IDC_2017_21 + - IE_PAS_2006_13 + - LT_PAS_2008_09_11_19 + - LT_IDC_2009_12 + - LV_PAS_2015 + - LV_PAS_2007 + - LV_IDC_2012 + - LV_IDC_2019 + - EE_PAS_2014 + - EE_PAS_2021 + - EE_IDC_2011 + - EE_IDC_2018_21 + - CY_PAS_2010_20 + - CY_IDC_2000_08 + - CY_IDC_2015_20 + - LU_PAS_2015 + - LU_IDC_2014_21 + - LU_IDC_2008_13 + - MT_PAS_2008 + - MT_IDC_2014 + - PL_PAS_2011 + - PL_DRV_1999 + - LT_IDC_2021 + type: string + DocumentCountry: + description: Country associated with this document type + enum: + - Cz + - Sk + - At + - Hu + - Pl + - De + - Hr + - Ro + - Ru + - Ua + - It + - Dk + - Es + - Fi + - Fr + - Gb + - Is + - Nl + - Se + - Si + - Bg + - Be + - Ee + - Ie + - Cy + - Lt + - Lv + - Lu + - Mt + - Pt + - Gr + type: string + DocumentRole: + description: >- + General role of this document (ID card vs Passport vs Driver license + etc) + enum: + - Idc + - Pas + - Drv + - Res + - Gun + - Hic + - Std + - Car + - Birth + - Add + - Ide + type: string + PageCode: + description: identification of page of document + enum: + - F + - B + type: string + Height: + $ref: '#/definitions/ZenidShared.MinedText' + EyesColor: + $ref: '#/definitions/ZenidShared.MinedText' + CarNumber: + $ref: '#/definitions/ZenidShared.MinedText' + FirstNameOfParents: + $ref: '#/definitions/ZenidShared.MinedText' + ResidencyNumber: + $ref: '#/definitions/ZenidShared.MinedText' + ResidencyNumberPhoto: + $ref: '#/definitions/ZenidShared.MinedText' + ResidencyPermitDescription: + $ref: '#/definitions/ZenidShared.MinedText' + ResidencyPermitCode: + $ref: '#/definitions/ZenidShared.MinedText' + GunlicenseNumber: + $ref: '#/definitions/ZenidShared.MinedText' + Titles: + $ref: '#/definitions/ZenidShared.MinedText' + TitlesAfter: + $ref: '#/definitions/ZenidShared.MinedText' + SpecialRemarks: + $ref: '#/definitions/ZenidShared.MinedText' + MothersName: + $ref: '#/definitions/ZenidShared.MinedText' + HealthInsuranceCardNumber: + $ref: '#/definitions/ZenidShared.MinedText' + InsuranceCompanyCode: + $ref: '#/definitions/ZenidShared.MinedText' + IssuingCountry: + $ref: '#/definitions/ZenidShared.MinedText' + ZenidShared.Hash: + description: Simple MD5 hash wrapper with easy compare/text conversions + type: object + properties: + AsText: + type: string + IsNull: + type: boolean + readOnly: true + ZenidShared.MinedText: + description: Identifies mined text - its value and confidence + type: object + properties: + Text: + type: string + Confidence: + format: int32 + type: integer + ZenidShared.MinedAddress: + type: object + properties: + ID: + type: string + A1: + description: physical first row of address on card + type: string + A2: + description: physical second row of address on card + type: string + A3: + description: physical third row of address on card + type: string + AdministrativeAreaLevel1: + description: main admin. area - in CZ - kraj + type: string + AdministrativeAreaLevel2: + description: >- + secondary admin. area - in CZ - okres or towns behaves also as okres - + like Brno + type: string + Locality: + description: >- + identification of town/city/village (if not already defined up - Brno, + Praha) / OSM: boundary=administrative+ admin_level=8 + type: string + Sublocality: + description: "town-subdivision\r\nCZ - čtvrť/katastrální území (Neighborhood/Cadastral place) / OSM: boundary=administrative+ admin_level=10\r\nSK - čtvrť/katastrální území (Neighborhood/Cadastral place) / OSM: boundary=administrative+ admin_level=10\r\nDE - stadtteil without selfgovernment / OSM: boundary=administrative+ admin_level=10\r\nHU - admin-level 9\r\n \r\ntodo slovak: Valaská - Piesok is in addess, but Piesok is just place=village, no admin_level=10" + type: string + Suburb: + description: "town-subdivision - selfgoverning - probably used only in CZ and maybe DE\r\nCZ - městská část/obvod / OSM: addr:suburb - it can be in multiple cadastral places (parts cadastral place Trnitá is in suburb Brno-střed and Brno-jih)\r\nDE - stadtteil without selfgovernment / OSM: boundary=administrative+ admin_level=9\r\n \r\ntodo not used outside CZ right now, so it is not searched/mined from osm, just ruian" + type: string + Street: + description: in CZ - ulice + type: string + HouseNumber: + description: >- + descriptive house number in town - used in Czechia, Slovakia, Austria + (číslo popisné, číslo súpisné, Konskriptionsnummer) + type: string + StreetNumber: + description: descriptive number of house on the street - in CZ - číslo orientační + type: string + PostalCode: + description: in CZ - poštovní směrovací číslo - PSČ + type: string + GoogleSearchable: + type: string + readOnly: true + Text: + type: string + Confidence: + format: int32 + type: integer + ZenidShared.MinedRc: + description: >- + Object containing mined information about birth-number - checksum, date, + sex... + type: object + properties: + BirthDate: + format: date-time + description: Date of the birth - can be parsed from RC identifier + type: string + Checksum: + format: int32 + type: integer + readOnly: true + Sex: + enum: + - F + - M + type: string + Text: + type: string + Confidence: + format: int32 + type: integer + ZenidShared.MinedDate: + description: object for storing Mined Date - Date, default Format, Test and Confidence. + type: object + properties: + Date: + format: date-time + type: string + Text: + type: string + Confidence: + format: int32 + type: integer + ZenidShared.MinedSex: + description: MinedSex - test of field, its confidence and property Sex (parsed text) + type: object + properties: + Sex: + enum: + - F + - M + type: string + Text: + type: string + Confidence: + format: int32 + type: integer + ZenidShared.MinedMaritalStatus: + description: >- + MinedMaritalStatus - test of field, its confidence and property + MaritalStatus (parsed text) + type: object + properties: + MaritalStatus: + enum: + - Single + - Married + - Divorced + - Widowed + - Partnership + type: string + ImpliedSex: + enum: + - F + - M + type: string + Text: + type: string + Confidence: + format: int32 + type: integer + ZenidShared.MinedPhoto: + description: >- + MinedPhoto - shows image data, and also two face-related values - + estimated age and sex. + type: object + properties: + ImageData: + $ref: '#/definitions/ZenidShared.LazyMatImage' + EstimatedAge: + format: double + type: number + EstimatedSex: + enum: + - F + - M + type: string + HasOccludedMouth: + type: boolean + HasSunGlasses: + type: boolean + HasHeadWear: + type: boolean + Text: + type: string + Confidence: + format: int32 + type: integer + ZenidShared.MinedMrz: + description: Declare mined Text, Confidence, and also structure Mrz. + type: object + properties: + Mrz: + $ref: '#/definitions/ZenidShared.Mrz' + Text: + type: string + Confidence: + format: int32 + type: integer + ZenidShared.LazyMatImage: + type: object + properties: + ImageHash: + $ref: '#/definitions/ZenidShared.Hash' + ZenidShared.Mrz: + type: object + properties: + Type: + enum: + - ID_v2000 + - ID_v2012 + - PAS_v2006 + - Unknown + - AUT_IDC2002 + - AUT_PAS2006 + - SVK_IDC2008 + - SVK_DL2013 + - SVK_PAS2008 + - POL_IDC2015 + - HRV_IDC2003 + - CZE_RES_2011_14 + - HUN_PAS_2006_12 + - HU_IDC_2000_01_12_16 + type: string + Subtype: + enum: + - OP + - R + - D + - S + - Default + - Unknown + type: string + BirthDate: + description: >- + Inner Birth date string of MRZ. Low-level data, ignore it. Use + BirthDate from MineAllResult object. + type: string + BirthDateVerified: + description: >- + Inner flag, if MRZ BirthDate checksum is ok. Low-level check, ignore + it. Use Validators. + type: boolean + DocumentNumber: + description: >- + Inner Document number string of MRZ. Low-level data, ignore it. Use + value from MineAllResult object. + type: string + DocumentNumberVerified: + description: >- + Inner flag, if MRZ DocumentNumber checksum is ok. Low-level check, + ignore it. Use Validators. + type: boolean + ExpiryDate: + description: >- + Inner Expiry date string of MRZ. Low-level data, ignore it. Use value + from MineAllResult object. + type: string + ExpiryDateVerified: + description: >- + Inner flag, if MRZ ExpiryDate checksum is ok. Low-level check, ignore + it. Use Validators. + type: boolean + GivenName: + description: >- + Inner Given name string of MRZ. Low-level data, ignore it. Use value + from MineAllResult object. + type: string + ChecksumVerified: + description: >- + Inner flag, if checksum of MRZ itself is ok. Low-level check, ignore + it. Use Validators. + type: boolean + ChecksumDigit: + format: int32 + description: Inner value of global MRZ checksum. + type: integer + LastName: + description: >- + Inner Last name string of MRZ. Low-level data, ignore it. Use value + from MineAllResult object. + type: string + Nationality: + description: >- + Inner Nationality string of MRZ. Low-level data, ignore it. Use value + from MineAllResult object. + type: string + Sex: + description: >- + Inner Sex string of MRZ. Low-level data, ignore it. Use value from + MineAllResult object. + type: string + BirthNumber: + description: >- + Inner Birthnumber string of MRZ (used on Czech passports). Low-level + data, ignore it. Use value from MineAllResult object. + type: string + BirthNumberChecksum: + format: int32 + description: >- + Inner value of Birthnumber checksum in MRZ (on Czech passports). + Low-level check, ignore it. Use Validators. + type: integer + BirthNumberVerified: + description: >- + Inner flag, if MRZ BirthNumber checksum is ok (used on Czech + passports). Low-level check, ignore it. Use Validators. + type: boolean + BirthdateChecksum: + format: int32 + description: Inner value of MRZ BirthDate checksum. + type: integer + DocumentNumChecksum: + format: int32 + description: Inner value of MRZ DocumentNumber checksum. + type: integer + ExpiryChecksum: + format: int32 + description: Inner value of MRZ ExpiryDate checksum. + type: integer + IssueDate: + description: >- + Prefix of the MRZ (type of the MRZ + subtype (differs, some ID cards + have ID, other I_ or IO) + country issuer. Low-level data, can be + ignored. + type: string + IssueDateParsed: + format: date-time + type: string + readOnly: true + AdditionalData: + description: >- + Output of OptionalSubstructure dont fitting in IssueDate or + BirthNumber + type: string + BirthDateParsed: + format: date-time + description: >- + Inner machine-readable value of BirthDate (in DateTime structure). + Low-level data, use value from MineAllResult object. + type: string + readOnly: true + ExpiryDateParsed: + format: date-time + description: >- + Inner machine-readable value of ExpiryDate (in DateTime structure). + Low-level data, use value from MineAllResult object. + type: string + readOnly: true + MrzLength: + $ref: '#/definitions/System.ValueTuple[System.Int32,System.Int32]' + readOnly: true + MrzDefType: + enum: + - TD1_IDC + - TD2_IDC2000 + - TD3_PAS + - SKDRV + - None + type: string + System.ValueTuple[System.Int32,System.Int32]: + type: object + properties: + Item1: + format: int32 + type: integer + Item2: + format: int32 + type: integer + ZenidWeb.InvestigateResponse: + description: Response object for the investigation nodes. + type: object + properties: + InvestigationID: + format: int32 + description: Unique identification of the investigation (set of samples) + type: integer + CustomData: + description: Copy of the input parameter CustomData + type: string + MinedData: + $ref: '#/definitions/ZenidShared.MineAllResult' + description: Structure of data, mined from sample - {ZenidShared.MineAllResult}. + DocumentsData: + description: >- + If investigation covers multiple documents, each will have their own + entry here + type: array + items: + $ref: '#/definitions/ZenidShared.MineAllResult' + InvestigationUrl: + description: URL of the investigation detail + type: string + ValidatorResults: + description: >- + Result of the all validators - List of + {ZenidWeb.InvestigationValidatorResponse} + type: array + items: + $ref: '#/definitions/ZenidWeb.InvestigationValidatorResponse' + State: + description: State of the request - NotDone/Done/Error + enum: + - NotDone + - Done + - Error + - Operator + - Rejected + type: string + ErrorCode: + description: If throght processing some error occurs, ErrorCode property is set. + enum: + - UnknownSampleID + - UnknownUploadSessionID + - EmptyBody + - InternalServerError + - InvalidTimeStamp + - SampleInInvalidState + - InvalidSampleCombination + - AccessDenied + - UnknownPerson + - InvalidInputData + type: string + ErrorText: + description: Error text + type: string + MessageType: + type: string + readOnly: true + ZenidWeb.InvestigationValidatorResponse: + type: object + properties: + Name: + type: string + Code: + format: int32 + description: Code identification of validator in external system + type: integer + Score: + format: int32 + description: Score of the validator for given input + type: integer + AcceptScore: + format: int32 + description: >- + Accept score - if score is higher than accept score, Validator + response OK is set to true + type: integer + Issues: + description: Description of the issues of validation (why score is lower) + type: array + items: + $ref: '#/definitions/ZenidWeb.InvestigationIssueResponse' + Ok: + type: boolean + ZenidWeb.InvestigationIssueResponse: + type: object + properties: + IssueUrl: + description: Url with detailed visualization of the issue. + type: string + IssueDescription: + description: Description of issue + type: string + DocumentCode: + description: Document code of sample, where issue is present + enum: + - IDC2 + - DRV + - IDC1 + - PAS + - SK_IDC_2008plus + - SK_DRV_2004_08_09 + - SK_DRV_2013 + - SK_DRV_2015 + - SK_PAS_2008_14 + - SK_DRV_1993 + - PL_IDC_2015 + - DE_IDC_2010 + - DE_IDC_2001 + - HR_IDC_2013_15 + - AT_IDE_2000 + - HU_IDC_2000_01_12 + - HU_IDC_2016 + - AT_IDC_2002_05_10 + - HU_ADD_2012 + - AT_PAS_2006_14 + - AT_DRV_2006 + - AT_DRV_2013 + - CZ_RES_2011_14 + - CZ_RES_2006_T + - CZ_RES_2006_07 + - CZ_GUN_2014 + - HU_PAS_2006_12 + - HU_DRV_2012_13 + - HU_DRV_2012_B + - EU_EHIC_2004_A + - Unknown + - CZ_GUN_2017 + - CZ_RES_2020 + - PL_IDC_2019 + - IT_PAS_2006_10 + - INT_ISIC_2008 + - DE_PAS + - DK_PAS + - ES_PAS + - FI_PAS + - FR_PAS + - GB_PAS + - IS_PAS + - NL_PAS + - RO_PAS + - SE_PAS + - PL_PAS + - PL_DRV_2013 + - CZ_BIRTH + - CZ_VEHICLE_I + - INT_ISIC_2019 + - SI_PAS + - SI_IDC + - SI_DRV + - EU_EHIC_2004_B + - PL_IDC_2001_02_13 + - IT_IDC_2016 + - HR_PAS_2009_15 + - HR_DRV_2013 + - HR_IDC_2003 + - SI_DRV_2009 + - BG_PAS_2010 + - BG_IDC_2010 + - BG_DRV_2010_13 + - HR_IDC_2021 + - AT_IDC_2021 + - DE_PAS_2007 + - DE_DRV_2013_21 + - DE_DRV_1999_01_04_11 + - FR_IDC_2021 + - FR_IDC_1988_94 + - ES_PAS_2003_06 + - ES_IDC_2015 + - ES_IDC_2006 + - IT_IDC_2004 + - RO_IDC_2001_06_09_17_21 + - NL_IDC_2014_17_21 + - BE_PAS_2014_17_19 + - BE_IDC_2013_15 + - BE_IDC_2020_21 + - GR_PAS_2020 + - PT_PAS_2006_09 + - PT_PAS_2017 + - PT_IDC_2007_08_09_15 + - SE_IDC_2012_21 + - FI_IDC_2017_21 + - IE_PAS_2006_13 + - LT_PAS_2008_09_11_19 + - LT_IDC_2009_12 + - LV_PAS_2015 + - LV_PAS_2007 + - LV_IDC_2012 + - LV_IDC_2019 + - EE_PAS_2014 + - EE_PAS_2021 + - EE_IDC_2011 + - EE_IDC_2018_21 + - CY_PAS_2010_20 + - CY_IDC_2000_08 + - CY_IDC_2015_20 + - LU_PAS_2015 + - LU_IDC_2014_21 + - LU_IDC_2008_13 + - MT_PAS_2008 + - MT_IDC_2014 + - PL_PAS_2011 + - PL_DRV_1999 + - LT_IDC_2021 + type: string + FieldID: + description: FieldID wher issue is present + enum: + - A1 + - A2 + - A3 + - FirstName + - LastName + - Photo + - BirthDate + - BirthNumber + - Authority + - Mrz1 + - Mrz2 + - Mrz3 + - IdcardNumber + - Sex + - MaritalStatus + - BirthAddress + - BA1 + - BA2 + - IssueDate + - ExpiryDate + - PassportNumber + - DrivinglicenseNumber + - Barcode + - BirthLastName + - SpecialRemarks + - Height + - EyesColor + - Titles + - Authority1 + - Authority2 + - LastName1 + - LastName2TitlesAfter + - DrvCodes + - Signature + - OtherInfo + - MiniHologram + - MiniPhoto + - CarNumber + - LicenseTypes + - FirstNameOfParents + - BirthDateNumber + - DrivinglicenseNumber2 + - RDIFChipAccess + - Pseudonym + - ResidencyPermitDescription + - ResidencyPermitCode + - ResidencyNumber + - AuthorityAndIssueDate + - Nationality + - GunlicenseNumber + - Stamp + - Stamp2 + - SurnameAndName1 + - SurnameAndName2 + - SurnameAndName3 + - MothersSurnameAndName + - TemporaryAddress1 + - TemporaryAddress2 + - AddressStartingDate + - TemporaryAddressStartingDate + - TemporaryAddressEndingDate + - NameInNationalLanguage + - BirthDateAndAddress + - SpecialRemarks2 + - SpecialRemarks3 + - Unknown + - HealthInsuranceCardNumber + - InsuranceCompanyCode + - IssuingCountry + - ResidencyNumberPhoto + - IssueDateAndAuthority + - TitlesAfter + - PlaceOfIssue + - BirthAddressAndDate + - IssueDateAndPlaceOfIssue + - MothersSurname + - MothersName + - FathersSurname + - FathersName + - LastName2 + - A4 + - FirstName2 + - IssueAndExpiryDate + - FiscalNumber + - SocialNumber + - AlternativeName + type: string + SampleID: + description: ID of the identification issue + type: string + PageCode: + description: Identification of the page type for issue + enum: + - F + - B + type: string + SampleType: + description: Type of sample + enum: + - Unknown + - Selfie + - DocumentPicture + - SelfieVideo + - DocumentVideo + - Archived + type: string + ZenidWeb.DeletePersonResponse: + type: object + properties: + DeletedSampleIDs: + type: array + items: + type: string + DeletedFacesFromSampleIDs: + type: array + items: + type: string + ErrorCode: + description: If throght processing some error occurs, ErrorCode property is set. + enum: + - UnknownSampleID + - UnknownUploadSessionID + - EmptyBody + - InternalServerError + - InvalidTimeStamp + - SampleInInvalidState + - InvalidSampleCombination + - AccessDenied + - UnknownPerson + - InvalidInputData + type: string + ErrorText: + description: Error text + type: string + MessageType: + type: string + readOnly: true + ZenidWeb.DeleteSampleResponse: + type: object + properties: + ErrorCode: + description: If throght processing some error occurs, ErrorCode property is set. + enum: + - UnknownSampleID + - UnknownUploadSessionID + - EmptyBody + - InternalServerError + - InvalidTimeStamp + - SampleInInvalidState + - InvalidSampleCombination + - AccessDenied + - UnknownPerson + - InvalidInputData + type: string + ErrorText: + description: Error text + type: string + MessageType: + type: string + readOnly: true + ZenidWeb.ListSamplesResponse: + description: Return value of api/samples + type: object + properties: + Results: + description: >- + List of declarations of samples - ID, CustomData, UploadSessionID, + State + type: array + items: + $ref: '#/definitions/ZenidWeb.ListSamplesResponse.SampleItem' + TimeStamp: + format: int64 + description: Timestamp limit (if defined as input) + type: integer + ErrorCode: + description: If throght processing some error occurs, ErrorCode property is set. + enum: + - UnknownSampleID + - UnknownUploadSessionID + - EmptyBody + - InternalServerError + - InvalidTimeStamp + - SampleInInvalidState + - InvalidSampleCombination + - AccessDenied + - UnknownPerson + - InvalidInputData + type: string + ErrorText: + description: Error text + type: string + MessageType: + type: string + readOnly: true + ZenidWeb.ListSamplesResponse.SampleItem: + type: object + properties: + SampleID: + description: DB ID of given sample. + type: string + ParentSampleID: + description: >- + If the sample is subsample image created from primary one, this is the + ID of primary image + type: string + CustomData: + description: CustomData attribute (copied from Request) + type: string + UploadSessionID: + format: uuid + description: GUID of upload session set. + type: string + example: 00000000-0000-0000-0000-000000000000 + State: + description: State of the investigation + enum: + - NotDone + - Done + - Error + - Operator + - Rejected + type: string + ZenidWeb.ListInvestigationsResponse: + description: Return value of api/investigation + type: object + properties: + Results: + description: List of declarations of samples - ID, CustpmData, State + type: array + items: + $ref: '#/definitions/ZenidWeb.ListInvestigationsResponse.InvestigateItem' + TimeStamp: + format: int64 + description: Timestamp limit (if defined as input) + type: integer + ErrorCode: + description: If throght processing some error occurs, ErrorCode property is set. + enum: + - UnknownSampleID + - UnknownUploadSessionID + - EmptyBody + - InternalServerError + - InvalidTimeStamp + - SampleInInvalidState + - InvalidSampleCombination + - AccessDenied + - UnknownPerson + - InvalidInputData + type: string + ErrorText: + description: Error text + type: string + MessageType: + type: string + readOnly: true + ZenidWeb.ListInvestigationsResponse.InvestigateItem: + description: Short description of investigation (its ID, State and CUstomData) + type: object + properties: + InvestigationID: + format: int32 + description: DB ID of investigation + type: integer + CustomData: + description: CustomData attribute (copied from Request) + type: string + State: + description: State of the investigation + enum: + - NotDone + - Done + - Error + - Operator + - Rejected + type: string + ZenidWeb.ListProfilesResponse: + description: Return value of api/profiles + type: object + properties: + Results: + description: List of names of profiles + type: array + items: + type: string + ErrorCode: + description: If throght processing some error occurs, ErrorCode property is set. + enum: + - UnknownSampleID + - UnknownUploadSessionID + - EmptyBody + - InternalServerError + - InvalidTimeStamp + - SampleInInvalidState + - InvalidSampleCombination + - AccessDenied + - UnknownPerson + - InvalidInputData + type: string + ErrorText: + description: Error text + type: string + MessageType: + type: string + readOnly: true + ZenidWeb.DiagnosticsResponse: + description: Response object for UploadSample + type: object + properties: + IsAllOk: + type: boolean + SelfCheckItems: + type: array + items: + $ref: '#/definitions/ZenidWeb.Controllers.SelfCheck.SelfCheckItem' + LicenseExpiration: + format: date-time + type: string + LicenseRemaining: + $ref: '#/definitions/ZenidShared.LicenseCountables' + SupportedDocuments: + type: array + items: + enum: + - IDC2 + - DRV + - IDC1 + - PAS + - SK_IDC_2008plus + - SK_DRV_2004_08_09 + - SK_DRV_2013 + - SK_DRV_2015 + - SK_PAS_2008_14 + - SK_DRV_1993 + - PL_IDC_2015 + - DE_IDC_2010 + - DE_IDC_2001 + - HR_IDC_2013_15 + - AT_IDE_2000 + - HU_IDC_2000_01_12 + - HU_IDC_2016 + - AT_IDC_2002_05_10 + - HU_ADD_2012 + - AT_PAS_2006_14 + - AT_DRV_2006 + - AT_DRV_2013 + - CZ_RES_2011_14 + - CZ_RES_2006_T + - CZ_RES_2006_07 + - CZ_GUN_2014 + - HU_PAS_2006_12 + - HU_DRV_2012_13 + - HU_DRV_2012_B + - EU_EHIC_2004_A + - Unknown + - CZ_GUN_2017 + - CZ_RES_2020 + - PL_IDC_2019 + - IT_PAS_2006_10 + - INT_ISIC_2008 + - DE_PAS + - DK_PAS + - ES_PAS + - FI_PAS + - FR_PAS + - GB_PAS + - IS_PAS + - NL_PAS + - RO_PAS + - SE_PAS + - PL_PAS + - PL_DRV_2013 + - CZ_BIRTH + - CZ_VEHICLE_I + - INT_ISIC_2019 + - SI_PAS + - SI_IDC + - SI_DRV + - EU_EHIC_2004_B + - PL_IDC_2001_02_13 + - IT_IDC_2016 + - HR_PAS_2009_15 + - HR_DRV_2013 + - HR_IDC_2003 + - SI_DRV_2009 + - BG_PAS_2010 + - BG_IDC_2010 + - BG_DRV_2010_13 + - HR_IDC_2021 + - AT_IDC_2021 + - DE_PAS_2007 + - DE_DRV_2013_21 + - DE_DRV_1999_01_04_11 + - FR_IDC_2021 + - FR_IDC_1988_94 + - ES_PAS_2003_06 + - ES_IDC_2015 + - ES_IDC_2006 + - IT_IDC_2004 + - RO_IDC_2001_06_09_17_21 + - NL_IDC_2014_17_21 + - BE_PAS_2014_17_19 + - BE_IDC_2013_15 + - BE_IDC_2020_21 + - GR_PAS_2020 + - PT_PAS_2006_09 + - PT_PAS_2017 + - PT_IDC_2007_08_09_15 + - SE_IDC_2012_21 + - FI_IDC_2017_21 + - IE_PAS_2006_13 + - LT_PAS_2008_09_11_19 + - LT_IDC_2009_12 + - LV_PAS_2015 + - LV_PAS_2007 + - LV_IDC_2012 + - LV_IDC_2019 + - EE_PAS_2014 + - EE_PAS_2021 + - EE_IDC_2011 + - EE_IDC_2018_21 + - CY_PAS_2010_20 + - CY_IDC_2000_08 + - CY_IDC_2015_20 + - LU_PAS_2015 + - LU_IDC_2014_21 + - LU_IDC_2008_13 + - MT_PAS_2008 + - MT_IDC_2014 + - PL_PAS_2011 + - PL_DRV_1999 + - LT_IDC_2021 + type: string + ErrorCode: + description: If throght processing some error occurs, ErrorCode property is set. + enum: + - UnknownSampleID + - UnknownUploadSessionID + - EmptyBody + - InternalServerError + - InvalidTimeStamp + - SampleInInvalidState + - InvalidSampleCombination + - AccessDenied + - UnknownPerson + - InvalidInputData + type: string + ErrorText: + description: Error text + type: string + MessageType: + type: string + readOnly: true + ZenidWeb.Controllers.SelfCheck.SelfCheckItem: + type: object + properties: + Name: + type: string + Status: + type: boolean + Comment: + type: string + ZenidShared.LicenseCountables: + type: object + properties: + PageCount: + format: int32 + description: Note this is actually "document count" + type: integer + SelfieCount: + format: int32 + type: integer + FraudCount: + format: int32 + type: integer + ZenidWeb.InitSdkResponse: + type: object + properties: + Response: + type: string + ErrorCode: + description: If throght processing some error occurs, ErrorCode property is set. + enum: + - UnknownSampleID + - UnknownUploadSessionID + - EmptyBody + - InternalServerError + - InvalidTimeStamp + - SampleInInvalidState + - InvalidSampleCombination + - AccessDenied + - UnknownPerson + - InvalidInputData + type: string + ErrorText: + description: Error text + type: string + MessageType: + type: string + readOnly: true + ZenidWeb.UploadFaceResponse: + description: Return object for /api/face + type: object + properties: + UploadFaceResult: + description: Possibly result of the upload face photo + enum: + - Ok + - FaceNotDetected + - ImageExistsWithDifferentCustomerData + type: string + OriginalImageHash: + description: hash of the original image + type: string + PersistedFace: + format: uuid + description: GUID - link of the face image in the Oxford API repository + type: string + example: 00000000-0000-0000-0000-000000000000 + ErrorCode: + description: If throght processing some error occurs, ErrorCode property is set. + enum: + - UnknownSampleID + - UnknownUploadSessionID + - EmptyBody + - InternalServerError + - InvalidTimeStamp + - SampleInInvalidState + - InvalidSampleCombination + - AccessDenied + - UnknownPerson + - InvalidInputData + type: string + ErrorText: + description: Error text + type: string + MessageType: + type: string + readOnly: true + ZenidWeb.VerifyCardsRecalledRequest: + type: object + properties: + CardsToVerify: + type: array + items: + $ref: '#/definitions/ZenidWeb.VerifyCardsRecalledRequest.CardInfo' + ZenidWeb.VerifyCardsRecalledRequest.CardInfo: + type: object + properties: + DocumentCode: + enum: + - IDC2 + - DRV + - IDC1 + - PAS + - SK_IDC_2008plus + - SK_DRV_2004_08_09 + - SK_DRV_2013 + - SK_DRV_2015 + - SK_PAS_2008_14 + - SK_DRV_1993 + - PL_IDC_2015 + - DE_IDC_2010 + - DE_IDC_2001 + - HR_IDC_2013_15 + - AT_IDE_2000 + - HU_IDC_2000_01_12 + - HU_IDC_2016 + - AT_IDC_2002_05_10 + - HU_ADD_2012 + - AT_PAS_2006_14 + - AT_DRV_2006 + - AT_DRV_2013 + - CZ_RES_2011_14 + - CZ_RES_2006_T + - CZ_RES_2006_07 + - CZ_GUN_2014 + - HU_PAS_2006_12 + - HU_DRV_2012_13 + - HU_DRV_2012_B + - EU_EHIC_2004_A + - Unknown + - CZ_GUN_2017 + - CZ_RES_2020 + - PL_IDC_2019 + - IT_PAS_2006_10 + - INT_ISIC_2008 + - DE_PAS + - DK_PAS + - ES_PAS + - FI_PAS + - FR_PAS + - GB_PAS + - IS_PAS + - NL_PAS + - RO_PAS + - SE_PAS + - PL_PAS + - PL_DRV_2013 + - CZ_BIRTH + - CZ_VEHICLE_I + - INT_ISIC_2019 + - SI_PAS + - SI_IDC + - SI_DRV + - EU_EHIC_2004_B + - PL_IDC_2001_02_13 + - IT_IDC_2016 + - HR_PAS_2009_15 + - HR_DRV_2013 + - HR_IDC_2003 + - SI_DRV_2009 + - BG_PAS_2010 + - BG_IDC_2010 + - BG_DRV_2010_13 + - HR_IDC_2021 + - AT_IDC_2021 + - DE_PAS_2007 + - DE_DRV_2013_21 + - DE_DRV_1999_01_04_11 + - FR_IDC_2021 + - FR_IDC_1988_94 + - ES_PAS_2003_06 + - ES_IDC_2015 + - ES_IDC_2006 + - IT_IDC_2004 + - RO_IDC_2001_06_09_17_21 + - NL_IDC_2014_17_21 + - BE_PAS_2014_17_19 + - BE_IDC_2013_15 + - BE_IDC_2020_21 + - GR_PAS_2020 + - PT_PAS_2006_09 + - PT_PAS_2017 + - PT_IDC_2007_08_09_15 + - SE_IDC_2012_21 + - FI_IDC_2017_21 + - IE_PAS_2006_13 + - LT_PAS_2008_09_11_19 + - LT_IDC_2009_12 + - LV_PAS_2015 + - LV_PAS_2007 + - LV_IDC_2012 + - LV_IDC_2019 + - EE_PAS_2014 + - EE_PAS_2021 + - EE_IDC_2011 + - EE_IDC_2018_21 + - CY_PAS_2010_20 + - CY_IDC_2000_08 + - CY_IDC_2015_20 + - LU_PAS_2015 + - LU_IDC_2014_21 + - LU_IDC_2008_13 + - MT_PAS_2008 + - MT_IDC_2014 + - PL_PAS_2011 + - PL_DRV_1999 + - LT_IDC_2021 + type: string + CardNumber: + type: string + ZenidWeb.VerifyCardsRecalledResponse: + type: object + properties: + VerifiedCards: + type: array + items: + $ref: '#/definitions/ZenidWeb.VerifyCardsRecalledResponse.VerifiedCard' + ErrorCode: + description: If throght processing some error occurs, ErrorCode property is set. + enum: + - UnknownSampleID + - UnknownUploadSessionID + - EmptyBody + - InternalServerError + - InvalidTimeStamp + - SampleInInvalidState + - InvalidSampleCombination + - AccessDenied + - UnknownPerson + - InvalidInputData + type: string + ErrorText: + description: Error text + type: string + MessageType: + type: string + readOnly: true + ZenidWeb.VerifyCardsRecalledResponse.VerifiedCard: + type: object + properties: + Recalled: + type: boolean + DocumentCode: + enum: + - IDC2 + - DRV + - IDC1 + - PAS + - SK_IDC_2008plus + - SK_DRV_2004_08_09 + - SK_DRV_2013 + - SK_DRV_2015 + - SK_PAS_2008_14 + - SK_DRV_1993 + - PL_IDC_2015 + - DE_IDC_2010 + - DE_IDC_2001 + - HR_IDC_2013_15 + - AT_IDE_2000 + - HU_IDC_2000_01_12 + - HU_IDC_2016 + - AT_IDC_2002_05_10 + - HU_ADD_2012 + - AT_PAS_2006_14 + - AT_DRV_2006 + - AT_DRV_2013 + - CZ_RES_2011_14 + - CZ_RES_2006_T + - CZ_RES_2006_07 + - CZ_GUN_2014 + - HU_PAS_2006_12 + - HU_DRV_2012_13 + - HU_DRV_2012_B + - EU_EHIC_2004_A + - Unknown + - CZ_GUN_2017 + - CZ_RES_2020 + - PL_IDC_2019 + - IT_PAS_2006_10 + - INT_ISIC_2008 + - DE_PAS + - DK_PAS + - ES_PAS + - FI_PAS + - FR_PAS + - GB_PAS + - IS_PAS + - NL_PAS + - RO_PAS + - SE_PAS + - PL_PAS + - PL_DRV_2013 + - CZ_BIRTH + - CZ_VEHICLE_I + - INT_ISIC_2019 + - SI_PAS + - SI_IDC + - SI_DRV + - EU_EHIC_2004_B + - PL_IDC_2001_02_13 + - IT_IDC_2016 + - HR_PAS_2009_15 + - HR_DRV_2013 + - HR_IDC_2003 + - SI_DRV_2009 + - BG_PAS_2010 + - BG_IDC_2010 + - BG_DRV_2010_13 + - HR_IDC_2021 + - AT_IDC_2021 + - DE_PAS_2007 + - DE_DRV_2013_21 + - DE_DRV_1999_01_04_11 + - FR_IDC_2021 + - FR_IDC_1988_94 + - ES_PAS_2003_06 + - ES_IDC_2015 + - ES_IDC_2006 + - IT_IDC_2004 + - RO_IDC_2001_06_09_17_21 + - NL_IDC_2014_17_21 + - BE_PAS_2014_17_19 + - BE_IDC_2013_15 + - BE_IDC_2020_21 + - GR_PAS_2020 + - PT_PAS_2006_09 + - PT_PAS_2017 + - PT_IDC_2007_08_09_15 + - SE_IDC_2012_21 + - FI_IDC_2017_21 + - IE_PAS_2006_13 + - LT_PAS_2008_09_11_19 + - LT_IDC_2009_12 + - LV_PAS_2015 + - LV_PAS_2007 + - LV_IDC_2012 + - LV_IDC_2019 + - EE_PAS_2014 + - EE_PAS_2021 + - EE_IDC_2011 + - EE_IDC_2018_21 + - CY_PAS_2010_20 + - CY_IDC_2000_08 + - CY_IDC_2015_20 + - LU_PAS_2015 + - LU_IDC_2014_21 + - LU_IDC_2008_13 + - MT_PAS_2008 + - MT_IDC_2014 + - PL_PAS_2011 + - PL_DRV_1999 + - LT_IDC_2021 + type: string + CardNumber: + type: string diff --git a/enrollment-server/src/main/resources/application-async.properties b/enrollment-server/src/main/resources/application-async.properties new file mode 100644 index 000000000..a64a3ee51 --- /dev/null +++ b/enrollment-server/src/main/resources/application-async.properties @@ -0,0 +1 @@ +enrollment-server.document-verification.zenid.asyncProcessingEnabled=true \ No newline at end of file diff --git a/enrollment-server/src/main/resources/application-test.properties b/enrollment-server/src/main/resources/application-test.properties new file mode 100644 index 000000000..b71e2f0a0 --- /dev/null +++ b/enrollment-server/src/main/resources/application-test.properties @@ -0,0 +1,19 @@ +# +# PowerAuth Enrollment Server +# Copyright (C) 2022 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 . +# +enrollment-server.document-verification.checkInProgressDocumentSubmits.cron=* * * * * * +enrollment-server.document-verification.checkDocumentSubmitVerifications.cron=* * * * * * diff --git a/enrollment-server/src/main/resources/application.properties b/enrollment-server/src/main/resources/application.properties index fc8f93a94..67b93e7be 100644 --- a/enrollment-server/src/main/resources/application.properties +++ b/enrollment-server/src/main/resources/application.properties @@ -73,4 +73,71 @@ powerauth.service.security.clientSecret= # Enrollment Server Configuration enrollment-server.mtoken.enabled=true -enrollment-server.activation-spawn.enabled=false \ No newline at end of file +enrollment-server.activation-spawn.enabled=false + +# Onboarding Process Configuration +enrollment-server.onboarding-process.enabled=false +enrollment-server.onboarding-process.otp.length=8 +enrollment-server.onboarding-process.otp.expiration=PT30S +enrollment-server.onboarding-process.otp.max-failed-attempts=5 +enrollment-server.onboarding-process.otp.resend-period=PT30S +enrollment-server.onboarding-process.activation.expiration.seconds=300 +enrollment-server.onboarding-process.verification.expiration.seconds=86400 +enrollment-server.onboarding-process.max-processes-per-day=5 + +# Identity Verification Configuration +enrollment-server.identity-verification.enabled=false +enrollment-server.identity-verification.data-retention.hours=1 +enrollment-server.identity-verification.expiration.seconds=300 +enrollment-server.identity-verification.otp.enabled=true + +# Provider Configuration +#enrollment-server.document-verification.provider=zenid +enrollment-server.document-verification.provider=mock +enrollment-server.document-verification.cleanupEnabled=false +enrollment-server.document-verification.checkInProgressDocumentSubmits.cron=0/5 * * * * * +enrollment-server.document-verification.verificationOnSubmitEnabled=true +enrollment-server.document-verification.checkDocumentSubmitVerifications.cron=0/5 * * * * * + +enrollment-server.presence-check.enabled=true +#enrollment-server.presence-check.provider=iproov +enrollment-server.presence-check.provider=mock +enrollment-server.presence-check.cleanupEnabled=false +# Enables/disabled verification of the presence check selfie photo with the documents +enrollment-server.presence-check.verifySelfieWithDocumentsEnabled=false + +# iProov configuration +enrollment-server.presence-check.iproov.apiKey=${IPROOV_API_KEY} +enrollment-server.presence-check.iproov.apiSecret=${IPROOV_API_SECRET} +enrollment-server.presence-check.iproov.assuranceType=${IPROOV_ASSURANCE_TYPE:genuine_presence} +enrollment-server.presence-check.iproov.ensureUserIdValueEnabled=${IPROOV_ENSURE_USER_ID_VALUE_ENABLED:false} +enrollment-server.presence-check.iproov.riskProfile=${IPROOV_RISK_PROFILE:} +enrollment-server.presence-check.iproov.serviceBaseUrl=${IPROOV_SERVICE_BASE_URL} +enrollment-server.presence-check.iproov.serviceHostname=${IPROOV_SERVICE_HOSTNAME} +enrollment-server.presence-check.iproov.serviceUserAgent=Wultra/EnrollmentServer + +# iProov REST client configuration +enrollment-server.presence-check.iproov.restClientConfig.acceptInvalidSslCertificate=false +enrollment-server.presence-check.iproov.restClientConfig.proxyEnabled=false +enrollment-server.presence-check.iproov.restClientConfig.proxyHost= +enrollment-server.presence-check.iproov.restClientConfig.proxyPort=0 +enrollment-server.presence-check.iproov.restClientConfig.proxyUsername= +enrollment-server.presence-check.iproov.restClientConfig.proxyPassword= + +spring.security.oauth2.client.provider.app.token-uri=http://localhost:6060/oauth/token + +# ZenID configuration +enrollment-server.document-verification.zenid.additionalDocSubmitValidationsEnabled=${ZENID_ADDITIONAL_DOC_SUBMIT_VALIDATIONS_ENABLED:true} +enrollment-server.document-verification.zenid.asyncProcessingEnabled=${ZENID_ASYNC_PROCESSING_ENABLED:false} +enrollment-server.document-verification.zenid.documentCountry=Cz +enrollment-server.document-verification.zenid.ntlmDomain=${ZENID_NTLM_DOMAIN:} +enrollment-server.document-verification.zenid.ntlmUsername=${ZENID_NTLM_USERNAME} +enrollment-server.document-verification.zenid.ntlmPassword=${ZENID_NTLM_PASSWORD} +enrollment-server.document-verification.zenid.serviceBaseUrl=${ZENID_SERVICE_BASE_URL} + +# Maximum request and file size +spring.servlet.multipart.max-request-size=10MB +spring.servlet.multipart.max-file-size=50MB + +# Incoming request debug logging +#logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=DEBUG \ No newline at end of file diff --git a/enrollment-server/src/main/resources/images/specimen_photo.jpg b/enrollment-server/src/main/resources/images/specimen_photo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..81e3f87b6c33becb3e8f97590e032df79f5b4679 GIT binary patch literal 21816 zcmeFYXIN9+w>=s}Ktx1PK|zQWUZsgNr6ejMqGA+Oq((%fL_nkkNKp|`5TYO;H7X?{ zB_h2>Y81rKODKUvdP{&5NV|D|_x#WKazEVXe!XWUPqLD|cA0DMJ?9u>&Mh1kP6GB^ zy=;9MASMO?ToJth!YRNLOStzV0Kmoua0&nb>;P;RlLUy1dSZZWq7Q}t>Wfwo`{#fD zkMRa<|IZiz03`Yb0K`9v{m;mc+x~lL$4Bx1(-)Kacc5@e`iNIh&=V6~UB5sbw}<`@ zJapXseRbh(PjvNkPU`~9!0;z-?l6y_!w)<>z5UFOuGSHb9`=4{e$?L3=CsWd3lA^v zE8$N)?80x{b`OWSpL=)|3_5HEH-Y;;@%0FDI}G=I>=$SPH$VEHjhl%2|NgCe^zeU% z1i{RY-m|%R*uwv*$6+I#(>kY*iq?Mm@R5n_rOW@duIQKf(f>Ng(9lqwP<+( z?LqgycJXibzmFAk_~XK==b7 zze9wUI>g0}0=CJEiOY)#TLC~30+SH?Px}`~{oBO0iEo#Xl-eP^Q${qPVh>=On7H`1 z?cx#=+ePS13@KU;*e)-ja76Ed(KY02=dZ(h& zfrH8?)HVJ&dCI`h$oT9z6N^ihm#wZ?U$wh^=dS%d2S@jZ9*;b|ynO#Vd0T4 zqoQM8y^c*xdY_z<`XMd-b53qvKJH7w*Y9QJ6_r)hHMPwxt!?eZpT9bK`}zk4hlWSU zQ&igD>6zKN`31(>`UaE5=4@{LYnKQ({@-SaKK~!>k{8*vZToid?Na~RCAKZ}U&H0M zOB~UYRJd?U$}M2;(KF9??7R5xQ)!d*F@0P5{s&Kcb}Ak>m^#7u*R=mw_W#bX7yq9u z`)|Yk$F5O;g$QN;+qQ|`;@h@~PL;T*ZI|5sZVWUE(4{FE1_+00RV_S~m4BCY`hjr_n{f%=o8x zM|;incYQxVs7p#Olo*i?I9)f1F1?E)Pe6GnG!4u5S=u-avHiy{R|vRb+IL0>DBQ#V z9Y@yQRMw-`g#gvBm8F(CPjc8;bV73Y-IK7G*+qw}!Ann|ME}51271x@vN`bXwDy#{(Ps>Pp}{hVzqpvC7vO z`eu8vKW=00415v-?i7vUdk=&2U@cT9HzDAN?jCDh?kC7bi4mE)?zg#I9s;>Zt?eJw zCF#K&E%80>{`{1N*z}xs)*VxDgKGTy$|U}19T(H3dAp&$a^xN!!w*TrtQ&S&l@J4j z0H89tiXZrD=t{6t-G`8j_8Q=dFCJ8E(cAJ*;DSq8vg~+`o-r@nPY7@vM}{*C9y8CG zW$Zxp>KhVQLC}T`oLSsRm`2bxuw{?YQX8R>NLxPv6U>{pB-WHKJ?HWxaDBr+ni#HXmSTbxk)uoxn|$;t~wU zzu%;+_x)Ymz+KTZ-9Iy6&?UI*x4zgwXfJ)lfW~ZWIyPHZD0J^VXvTCk_uNc(S3~ z_my?~#n>|0^vNcc-Z$pvRxgdO1gztA1&wF+#~BF$H6#KwoPgM#LilIfm%C9!UbtX3 z9pe?swE&UWZ^Ja(fNR`~sMQq&;c8ycN|}eXZ0XKSUZYdDa*oUAtV?i7CyCQO$d0%QR=X&6#`Pz zZuEnhKiE825~q}bb!M24Io?UmB@EBA_jn?(P)A+`BRK)bfKkh{_Y5}n@RO&wHofO_ zeqlfj(LQ0g;l2^98<`^n++*CySa`bsT7aN2Y&7ti5a9fJnlO2XL0qb0vGe{a(_cP31O>ud3LmMaGPQu&xqsmpuQ0{-J{i{yYCFi zcxyEC$*x;0a|whUH)uhzQJH~>X;kHioxlp@ksur6s|wF9I$vW?I12%Lg#dZ8?2QNu zlc14VsDjx)erA?Zv1alCC;LlEK)+Np_sF2*!}oB?7#(uEyaW=ADZM%J;3u5R&-p#- zC(48XSE-LlB3l|LhBV4q0r&>m0!vOQcYQEcvvCR39N zZjJ~mHt?5ovM_iuA*qP3#%h5l#+j>&mL{(TbAf?>$Msp6{G577SRhqa#jc_+L507um~vgU8xwRmtYG52!_KwF?K%(e9MJMeEw=8w3$wV5&n)rt&%yHBMxif- zfbV!BS|09h58Vy#9VD+q`n3f^o_hC3hlVqUmV|();FBfVzKEzOz!Ppmtx?D5UVqJ4=&h--9g26=!1$k=Of0jPUW*5x>#WB?Q<{2VhDxKc~z>DwT}& z$A0;^t9bk)qo@{1t|!0Q+GR}tx$HVf9voe|bxmnR!2ZxyDg?L$_6fxAxFsyO`Tqr( zQJ4|A^UT5r5vC5bO7C&eUhdnU1K}nLi<4`(MvJ#ygn$SDq9RW}W8d$09z`v!i#{!5 zk=M&owluHg$mRgPwuP$_$fLjed8r=~L#_DD zfgRi+d@qD-+Fps0>**T!vYQn4LvRENM74ts_8ZFBhrX2dHxN-G4{5+6_zoG@&qaG~+Eji=pyepfcu;|ZkS%pTWTQ&1#TECP!y-;*} zDF(;zDUjyUDoNQ{NwQ;OflK@cNaVTq_3aH6G(`+R@&%5lE#TLXrofP zc~^46o1q-k;)sSn>wz5WBVlC|s$=3y%R`*ZMeOY{G*@F^iax4jS8v-PrCs4-e<;M^ zxOlgz9My2Eg^HEq$5~jVoj*->&ZK>kp2qM3r-gvfg!f`}<5uuCS^de(HvsjOUj58q zJtW4H&zP~U>dUnM1uiQoKHg{&*Bp0%E1Bbo*b$Jq30+t-U);!GJ@5V4OL#P8(p5n% z+dQyqZy#Y2d}JP)*(lXF)Qpwat#~!?&Y)&P<%HDdgF?Wd*!&Dy4a7!tdk2z(Vg7sI z_V$?OKSkYp`H3FpD3TCh9=OP@IVS}80aukb?P=hs#|Fjz+*?Poom3rrWx5PM{oLa^ zk48GF%A#@+H|flmWF+qO220^ndH=7~B)Kg-sRBWvLZn>b#2;H#F+twj_ueC^7yA6H zFSq5>cPvp_P1(f-x2?>tT6G8kvSylv@Qlg!;!j?Kdz+|`-Hynr%PAV<<099c?Q{9G zdCdMT3$Bu&&)8L~0lJe@^0VzYbdF1ge6Zm!Wrqy4fXh^g(aN(IvQ6t4+C^x$#3OZ* z;DCwhPNWXxB|*(lds|n94BS3I?${oyBthXJA)xbypkh7(@?>_#1vB5-oZi1}LjU>q zg+(v$(iT#)n^v8sts{vwuz1LE+58)B-%i=ycw~fA*W##?m&(;18P`3Scx*wb6klwh z$!)kXCi$1cd{Ae$_{d)ko^`!dHL|<|0T` zeCOKhW&DBR%QpQSVEyvE16$s^^H7vgl}gwfHFJ}oV(&x4#R+yKI$fYb;Ld;ThtlH> zu6C&vCQ1gLnko5p{8n&(41Y@qSiE~{;4*wLL%>Hf3kRYp^x5CHpEL>q?}WXAeo&vr z2zpSUa{t*IFvXi^T9K|j+P5;wWw1xcrS2s&+ zlv!l{q31Q;=bFcEsr_`hWqC$4^4Jx$Ed2gKLzim;^B34Ou#P3tjn);QwfwNEI_=U; zA)qML4AV|9(F%fzqBW-%{R(viq9HoDH*vY>sVid@bW+RdYbWY`3chy{%5|{!e-A;| ze;*2T#maX5R`ysaj)>wKZ<&b;!fp7g!C%a_e<#E5{&BJ#Yhure?1ADN;MjK3>(dNbL3rF@>lu#2@3C1Pn$T1C?B?n+ zyKn_RQul-G8b#mw1G_A}t1>Oj{snPN1d!HSh}r7BPE(I6rm(wnzw|ZD`UHMDF_Xk) z?>eWw`U~HSeV#h#nA&V}s>Rxu^~=+@l*lU|9|F~^UPxn*mPbmSe;(zCTc-wXm4#L9 zlp2fVY0i+MK|7RD#<&}q_B}#?3aX7EM|(r<=dXBglkek;k!Jri%k^%g>Elb+bzdQ$j$@WRKqp zGwj(~E9&M06DvN2jjXjNQB;E0@fKlziFz1r^#R@&@eZ_?Hq>eew0&N&^z2$5cF8vU zQKJom5L0ZRdhuAm&ZGhxRmLB1*k2LJ(_Sp&1hOM;r<3AVnf%M0Q@(32tCWNQe-?=y z(5E&qf^eWGFaL>g|5B&#;uTlgpPAK&h4=tiz({qV3eU8E1HD(3v@`h)?)KdUrEWD2 zK8tsZTENq#Nz_)ZAFp^GYW8rK3VL(KiVYr_cO`Q5CgQI(TPjZx0x(w64IbbaM<|)w z{D{_m*VUwKfY(znQsj46H}yB9mU3q)JeD>D^N*u zYUe|JwwA7ERQnVyksw*^L=Qv z)PWkPnhBYo-zbr4cFcqZUO{ZE)kAV@j8>;kx%=+XnYxpw>!d6pmOXU0dOl{X=@(AJT9ZEw#>Rb_ju-Jluitd6l& zLN>L*mF-*>-M-qUG<+Mg5;4@|DpyWm47JI^Xlfr3o!3;Z%~VK5J!sg$RxRB7jjz*P zQe2MRKJs1Bf@L#y2r)r$6re!{yqiOfW`b^$ys8+To!$lD;Y0`Ne;{jkGFpw(v2M-L z3oY>eS{&?~N3q`a{8yqb)185D#zc~}8xk^b(kjc4FI}u<+{|0Ah+2*1kRYsy6& zOX>Y5uV#RE+i0G#a=hIfNG4by`baQu1Gp>%R17{l z3*G_0fE$d(PMlkCc&*;pMsG|wj9J!i-7ex9W~e(vjz)_p5R5eTc|3Zp!OqO0WQ7+; zW5OPxhD=ki-EX=rM+&J(Ki%E%fTCj1({beD6ng)>ad?g#Gg~ zNGo)Ey>Yt$FsMRI32%yH5DibfQ9IUc=B_mC4vR3waJKwD{i8LFI6>uNcOuSP2XR;p z$?L!KA@7ruRxvqfC46`!we~2_ieVIYcxS$U)dk{Kb|z*uI76^mB1Of;VXs$n?PHg& zBn!B$)oz>w7=$)hccMLY3Lmcw*}CKp9G+Pd{Owu~Cd#l!1zVu}XGRlalA9nIpfJ9%Hr zwQvtCeRlV)@5W*uxj_JZO`v1CioBdII&>KQU?18cmCNBq^2vYe9rEF!c6~bUY*(fn zs+?ctKE5(h7&%yvCE7dYjXjB%)UDj?WO?_xeY>Vn7|F)ebOm$uvlC+inN=+&ty`}X z*2pUou`(`wo(BRKCUA?cxT!+^)fJTpr-BsEIR1lz#SQ#Rfh4>P!q9lBL%w!Jb$`eU zy)}$AU#hpUvd@2oR_nAV5i7Poe|7Be4J3TkK{YuzKjcg@%d<- z)rRZ#bG`WVH}y;_`}l0q;+=9j!6T*vxF}+Vn`WMZY8swqW0ZX$GwmRw@EaET7{_mX^69D2%eS`w6L-$p10Cdfr2 z4hC&G;YL5zZ{B2Ld9X#y9v^~7`DRBt&x-#Mq}fTuzDd`J_Eoox_XpkFEsom%y6;L7 z4OC4Ay+j;&I6cN4`t7R6=t^x_71NE{@TST66z}`cG)`Fc95Uy_OlhPx8z(!4>T*ov z5$=tGfZ$59UdJfrvOiUAV10!!1-pm1F|oI{Ut+j#ay0aex?==>C9*$V2DDVgd2a@x zoBQkBYhUxqUl@3HF!3)l&b(;@p91!PMh~i;VaQTifBOC{?=ujoVhRIq-PA>)cOK)H zOoE@$Rp4Fc*h~?k8>Ky7Y>fGPnwcq5CP=rScM-@zKfzvv_IEzE5ztiC8Yjg<*3JID z*C&Xm;-Z&>Kklw-f$lNrPoxR~O?$0_(oHEGv^7BCc!LmtZ4r>TqI=eY?mlIft@46m zKS0y>QPR*?IP;gAjboI+SWcXSIG!;*E57QLy2I*zhiz5AFA^ri&rPyb7gvfwf5>Cf zZ@+&N9G3LjmVm`sdDW@l@_ZH?%>t^@fZB@__yaWa+o(Ia=lyaF4rcmz2UrP;L-=Ql z%&(n)Q_`Cm?jF0bq-}p6xVp~#DEHpXgI?o5d?ofK{-WfLE&DVQeeCPc)Qo-C*zGq| z78ju*K5xtTbN#gbA!+G)*s2jZ_eayZ0+RJ2t7r)y^<-bH7}MA&Q5P^*>|{t_!&Vi z0VR&zUsXcakDLOuh!9^mFdV=OV`#)~2mwmGv)C)OL{@S|$ztIhZe8O%P2=E-l^rN6Xy{vy7FYUF$AqdYfcC!q>H);?8=>7VC&M>b;b@wzF` zi&tN5*-&0>sOmy{N~mFXs>VOQa&sC|Yh=0_CeRa0NaS|v3^N9;_5hOOz ziWdk)a>Q*1%q|~gNWNCBEhcTvJ$zE>&k5xf`rLXFT{O$`OcanBkAdY7$S<4+3kQK0 z`*kXbb;qC2nHMshE)3o8V`@V$!!_Emo=xlEA#@#793I&+p_&bIxa(a$Qj!tmuZ-`Q z$WfI=o+R>~ZL{QV)~HXPIGZ$cfMn4XI3YV^VDogX&$j0g^1Gv-#AaM*iOHi%TFTPr3}BVj zZmE=fi)NN;#LhXhh4t!$J*#qJ*E1T$rVd~ZHrZf`Z9^@PnlAI~C2n_1FVe^d_DdM@>Sa)INVt1%s~ zk3i09p~v+*^s7eC;>CJ-D%{X{@_=Z9%AvbI_3PR{gc!DZ3?Rjh9gf(^2X%Y&)dTaf zJLkf|$CQv734o{yzT2O)k;XHgt{k^?)46nF`Ex{ zHhvNR`u)Ve)DU3!6exG~@`f4~hVR_X)baiL=q%!k1A)YW0 zKfqzqHE4cG5r+ni!V7)$orHjb=f}?&U~lQo5U!f3P_MD~RTK$W zQVqi|*&8=K&0l4gwFL@JFfMwhIHlXh^e4d9S84Xk$EKj$ZGxvBrdu;Lp&Mb^M{VXF z3Fyh_7{*7r8+;u4vHxzwww`6b5^&!m(e+}#^HB8`8{d@`DA-v*10%3AIeB|fhxh1E;L4|i}-A9)Nr}@{PefB@YQDj=te#Iu1$IZcIrck9AgaH z&_!9Wk&MQ_AiywWV7qeES8vDTZB7NXZ;04GjV*Mk<^5kr$Fjh*%Q1mdRpy?=T>MK0}8@ znYrUsOsQoKo$L`n2rwRX4mAM$ZuPQWRZGObc|i9QUB$IwN|jTfu+;9|G&14RF@}8{ zLZT>jP)nm{336{L@GSV*S^V`{^e)~dI=O4g#U^-r&AH6BnCdrNOWk8@ALV9SXfu)i zQv?9~sd0Q>f=qFxov~Kj9K_kakmF8OFZ8Cb-{^NAfEw95-ODZ7ZGP5V^Bup|p9zft z$@TH{kp6FVN)ypBLO`5|+U-Y?8c+KAPZ`IVfdhg(vJP_}YfiFyY74!qiJ&%)Xs=2NdUA#@bdtdAptckE++w2b4jxoAnVQ?>L+@HD>F?8n_&USWL<41hp63vw!HQfnjUHVXYGt)4Y{R98`W89U*?ZGLW zZ6Aez5`PB%6-b#wosk31ky|LpcZU2kERy_3x4jzU)&c#ye{A3h%WkhmFC*r{ZY@NF z_tmY0{7JzH2L-(|87F7=wjoFi3RNv*5-7f)rry6p8a8CEIZ*){Px&AsWE_#`E@8`}&goz>&tp0wO%<6@{D?A%|#Q?U|KR%rjk8sngON2CvO*hsoLhr+IRJF9p zRT-lKX$8pz+-H%%j&eh2GMvdLimq+rf1SN0qH4HRY+*1yloLh$!jzL(5FDusiXHH) zeARwYY)O?A}y7Hv!`DK^`XlS4(`u%=wCrKe=49#-7MgK0EzKAtLIhx5l6m?*mo z{4x{}uG^AtDywTV2x@=*c-LhZ^t<2s0UiDIz$8jK&yxQkXm+q%2sk*G9%K^{T)@G! zgv9L%_`3pp+_J%?)M2t4G`IBVgy_}!YurG`1zQ7hrkY|OQ|0Fgct@Hgbvg1lb8YCQ zU~clJ)JP!byZ>(MU`?mQ&hDh2TlP)aXBkp$U^1A>N}a??SxL0DH4w-o^7&Ym{h||} zsiv_BpEV1B<_!uYq4InApVY?3pPCH4M_lEc^o3Cy^xHrO18_beDW=DgyyTCrj6chH zW*qpOl3lJ0j0&YFf^;3=CB!QgwY|#^>-tyR-l5uKe!UJ48-c#xJzSD=ueQD)A&rBJ zoX3@<7ILx>z=JP1BC>BkMGtu;sSqa55A_07KoX0NLvQf)E?>O2Ei7ETrzPQr(e9tT z|B#J8HDKU!ajW<2>axcR@+jZx-CL!($mg7!kAl&=QBBk}3a9=VBc;a;qfDJqK@@sp>-PTguQ}C$xeT7?9@zd6g`nU4%o0Y^_H_ zQNe5FEq~^BcYG4it=oPnhPQWp%6I6QK*0$%p)t-cECia|a}irh?e_fmQ7#4TrK=_w z2k%B`^K)pjp3S!TT^T8ZY`#Bk0-uf2=KSQqT9TURQM(+2Uvag6X;)5?_%m)VtOf~> z&_2MJxA4BH8=n-&mVu$7wD05Sqt7Fa^LOBzbS2?-whUBR(PB#IZ`(CeM@?zgwS5^} zE`$+5Gf-B0Tsz6Yyn3u1ylS$rPYAHfb<(2%`N40I6pn1zP^(-#>NMv>u~ptYNF{o& ze<9lAy6rTNQYi!=-Uj}S2*Tf_&qNh)#BaTjRY!jxb3_+~*cPF11;xNu;N6BoK&)fG z+UbUF-T40H#!Hpty3v8ses22gU*wPJdQ+|=B9PvmS^x`=$}hl?y{e!di8D0*8I9C| zDutbV*hblu#HjtRnkM$ViR{Gt-Ji5xgyv1y91=U!Ykba~p{ zyI3?t;6EYeFg@zLg^G17U>RY z@Gs@$4?<4H0y*5&)hg~X&!(S^Kf=+Z*_<-&F;IQrQabYHtkes0StOE?)M7Wmk*#;f zEvUfwj#|IIr+&W0k&eNYH|?4A@EL6=U+yC%|-DU@RM@Iaq(IhHY;%9 zOL0Ntnp<9+O&XMQi6>p?a_rS~bm!05JDcbT{-hNnf{CY%vYKML1~*o>c?$vciB1i8 z%5SyojR%N?uT~_o>4K5TP>jJ#i26D@?OPxh@5n;Og)iq4#44WSoa-Z(v19+H03a`e z;`F)atpd!n2FYfFMyKm+u=-u1+`Y+~RGyDdVjWMaKh#o>dxuSS|FsCj^^t38fC0f~ z5P@{w6q_NpY=xAIHoVj876Hq$J+4Vmj;}=I28^NnRa2IhT zuSw#S*Rk8D>MnOnO5tuKQ`O`eA(dG`Yskj2f!C8my^gwX-|=D|qxuhG2Fghl_uA)( z=F(lL`|vWB=U}}q!~PcMHGSaTxbq(Q^SQH z)0&ZWQ-aQZVRj)%3757jfZF)AH|Urf_H<-6LDzBaQD5&M@sE20U(P^21d)TA11nav zT}un%f_D+YTQiC7W~E5El#Dh60d|t-Bcj0G7HGUolIT8r9 zz2=`gqI5dhLStgIeHCB)G&J;^_}iQ+{~PZWQ6g2b={!O&2Sx#`2G!Nw{g9OPr!D~* zhM$aVt~G^2ST7prqq5(0K5@SgRZyP^1l>{mof@FD?!Ifs-ItxMVtB zJV7}#$OMfFjD$lMBVvj`l-sRcopdw1xtV@-w>#Q33&xW5H{9_fe(>jaddR5TQ*!`K zEqQ6G;MD6sR>`k3bMD@HEvie31wVFqFb3c4a_Ao+z^1PGO+c*RE=b1*3*+Y*?5_je zFGZKj55)?Sz>gaqY@B%M2D&%;G@+>h(ty?msi)gqqX8NeO0$Xt?n%i!A3afB)%V8d zC%*M-*PoZaG_>`jPWt)UKl9IrcW$At(0%c*8V@aG1LMG6m6i3i(K=1qT(M!KCM{md z)=mvu;5l8^us%AWyCMYCE%y>GqCJHGPMS#C-KWo+cgu0!mXi-$4G;{W9lw*BDBC$R zlu1#Xl4^k}6l3--+)a%!ZzUi>7@oza)pP;Hpgkmx{SerZa zc?pNl4%@9PZx9~`;a7~Oba1+{A|)2>Y9*_lgLAM>`lTaMfv~m_+4(gE8M=Xia}A1e z#(b}F=1d(71_Tj9&LCk4eJ5nt@RE|2n~5jLR%*$;)_Eb@(cz6KPEIjpF2$+*?-{J! zFZ`3nep~mpKqZiBjEhTZz+|}mdEhw?o@U%E_Y8HQq91hv#!7!`d@JQJKV-R)*D*gnDHmt$*&q7!>7i3-816l^e3wmN8jaY;XNn;#3GW-<#;Q?l)RgbVek( z*CN#%irRH9|06#7N(yfnS)g|SFc2Yon?QMhs_%1L85eLp7cZp!^tq|aj(-~d=KJoK z_BQydKt5DdzoM`_(a{;aunKCJGXR@_{bV5mZ7nPr=RdmpFX@p;`s2Og+@JSk#WSQ{ zSx9*w<;qN#)k5qRU|WeI9FokLk=2~~7=1@Yp7rvr0r_>_^ROYGe?SDI#rE>3E{j-iwY&@C8Ceaxi zRDy*sN~KjhN3qsScF(PENs0D{JTX@}qmxoE@HB>N)>nfZ1z&B!VEb4Nv*oF|oH=YG zLLgr$q8L}>TF;#Sj%Yq1aZ&tFw-jLahmtg^hRmr5`JM8~V*(wVu!kSOudWz}SoLc! zFSqqme99W$YzySJaf_L%m|IX04v3vN%ZmsraS}VQZTe|FqOdz)I0Qdm#_FS27JOaU z+Fg_HQvO2MWCEI`r@m6sk`DSMSGMW$eyVj`KB=d9IXnxaHN*XBs!>+nnZyG2RQ& zxqt5^%DC+ThFO#m2&b{|qz`|&q(9oiwcY#&0{Q*Zah z>MLqf{oGsPpY_#6&d`Rv5U@R&kyTR61#)0EA@;rsGpmp!v%i*Xr8z>gX-H8)pHX|C z;NaFs{%@=B`4yK>E0#~U5GjLb1|QR0X=vrMTmJ+SIG!zcsvz;DIpY-2S7gmYOq&CRgUjn;9&=AAeG+LYgO^t zb@2$9FR0<-;t6zX%C&nrLV!2<>N;h=ptsaZsWRO%x+=hECAaM)Y491?)(rgRy-Nyr zEc}KPv+%96QzN3yt!jlw2`6Z%unLV?P?)bHm4)7%WqBV1rf5P1D%&XMM_65#gSp@d zL0?s(nGitP_6+P?X$S#FZ%S2K1_rw!r?1=~6?<>8DeKEyL*V>T7g%l8sQhpn ze~d>+y}F@?GcQiy;y-L9l#6vbH>=M+G3;1;W*9?S<7-w`bx+rFGwr1q%D3|8BDR38 z%tO;_{Yz*tfqpZiII#UC-0FH2tT3Sa-4A>xL|j0kgI~25JP`ss@3=fm@Q~q;F^QG1 z4G8Nkr&Lv>Dd`MZR)-yRO_T)P6C|&NM1FN+u=HjeZaiPQ|w*Nw##Mu{WNPwjNt z+?He{&dH~A{vCO9&5>ENsKI9rG4W;f;&`YNTygPc+R!&8iA;uh}bonkX&&p zueyY|O!yIT?oV;vgdfPB9Y~ypq9tAnLKGCjejQKWv==du==64`Ct0G_#rLL(NPWpe zCftjBHoIO-I~aT9T4kphHR=H5a4!%%7?McSj}=u!2LR#ZCu zbxB8(W)_=h+060S$g$ZbW6~07T7Rk%uN%d4;zve&_%zFA12qxjVTaZy8UkC5SyhdP z`Vnq@K4JbLDKZ6h(j8`PPdWWRE4$wjTylW^v zv-m@mJ4jrzV65Kbe?WW7%22g4`^JVLUcQF=HBKEhzZLz&ewcC3?rRV=7~Gu$se3_N zZB^gOv@dp;-+ElB7Cb5q>?;=O)vHyac!!cwkRMY zhEkj^rLY^F1H&}uTAqBA)22g2>OqW&O;tv|7i*8)7R*-#ZHR|AR`zZ$1QmGul{6ek z{wXTk9@vO$`__&%1#^?Bj>Jeaw-QnHCe0ei+tDpWuqlQkM}JpG|1>VV{04o?Wjjcz zhZ(om;PW4=)Ya(MH^5gnldfbTc1=PNdV(L6o0B5FWiJ)C*)<8F2CB&228 zRtWgnzwt`vm|&^&80KfBi(yQY$XgD(pkepOC-C*RUV=aFUxMDphVU}C*f}?`9heRQ z@J7EF*~`(brwH90z3y`IdyQs!DKRfFV8QlcottlDcYuo^%epjU#yg*PGmDsIgmedk$U^9qmx)~;W@6uoOgBkcs~#!LfFbm6>s zSOaHXi~M`9g?()CDBoN&mOd0ot!t2blv+ly6cp(l$F>vfz@GS#+5wFUhA!rz(R3}j z41rsxCSQS+Mk^cGm-kN#6nP3mpyRMTUa`@{i+!V^Wb7N`5+rORk`>Ac^rQ*_2P|kF zFRql!$zYbcB+o@V!U?mbQibS>!Fs$40~k}1LYdNiu4@@zL|D{_o6%+-!_%M$4)ET$ z9)m2SUO=hk+4ii*IdXuF`zE(ep?>!k-#}t=c{;%=F1CFa``m>9t9On4D-o9S0wz+! z|4HFCEs+#__NGcUfAKd@CD&@7nn&l`mQNb#E4V)V_LZX|DDdRc@26ms`8Sh-W26G< z!l(Zh?O-pb;fU;_6JSbrH8wIaLx9ESqN zcGZAzctd+HGD3 zuKVWJ5N)EE9u8FD1Pb=}sxN4|KyA1LpT}*+lW;>OWm%44@7z;S9C)L@^3He1Z&$5=G#1fPS zN@Oc7HKirm&4jOKBNt~V`%%BK(gMJ+nX6I1pW(q=ZqI0q7R5QZKolokUk6k&pyc6CXa7Ew~&t zNN8dED9yJ8NnMThXU=cNu6N0_rR=J^v17wrvkask)ySd=0b={a1|1yF`c45r)%nFq zqTE{XMO6LJnMT8B=r3<|2P>JnN@5<>r%Cv50BBt5V-dH!&lgRbXN#VbAgz}uqW2Re zu&c2}*okFVLn<(?HW<6beKoomc^+sfDh$Sr`tH5o;^KQu)e6^JrE9nsr!$il67IKI zNDUL59v=N!mE`(m=V(k?t$2b zgx~VB1iJ0&=tAct?&Slz#>>^CXn7cLp(iW?%C3v$?cwO8Q)26Hp#zItKGmeifvDF# zg9sG&aRS+lJJbcONNsg-A_B|fd{FA^`Pohl#b&SPa}3d6V^6Kisz521O8#Zm$; zlnmd;b91VuT&+IBt)@sZ!G;Xu)`RKP;CWn=?y-3hzfxv3xYGu|Sz=>&2lqMd5#e?W z{PwoJ3{}mqa+$w$ECxZ6a7{a>J$waj-A<9HGQr&EvJa81;rTqg0zUr^^M0GA=#? z_oU{7lIhrHtm1^TG|zxM-joUf1;y3WKrCnTkm`ogwHZSph7=|K(irbkL_SJx#h`O>c&hIK_nE{l0tC`b z03$-VS9oV4TkqQUaS})VR#lAaz6+JsLOPxNwKdeGX27pUsaoc#JkRSa_%ZIDZM%!^ z8^_*`&Ye_Gq6^GTslc|bJK6@z(x9GGmPgi0a3am~s@3s`ps$SpPO0cVj6v^u&;P9Q zI&=DCoxh$n`|eNt2h=g12O|~3gX~~vk11{;>o!X*w|lt9izMK$XQjphZ)6~wi^DT# zy9M%`^%CAA&ZZC`ja()S8&t2L{=7qX+R3dGtF8)C%%pQ1$#a!e@0yNEE>359F2)M1 zM1VQ2a}@o`2&7RzKwHn7{dGEE^8uj(GqT=78FiH}TYqmYh^w19I6IWh><+Yhe}8`b zz)-g2_n1q@CPj6}p>^5Cj^W!)W}X|fvfg}MWa3c+jml)7pH->GO`of{9X%dsE&CHx z{O)0nVQ~;l(=$a8TBf9N{MP~m-yro#OY79)^|4-oF>ElzY31ngExcn*AlGJoFvTg` zyG%s1MT{p3Slpw=9{L%rLPgZgOH(GUV4mkm%a=w?Y&&2P_o&z^O zGgDl@F`!+}gXy0Z0>a`EWyK4k2ejUOTDNYfQn}}z8IIIk2FCibROB|g@EiL#&8ehU zkSUvhdJE~4yAj1@m!94*2W%8E^qvP4{6xqyRDO`FUV(i^>My1?4_)uOG3$L=2}E$* zg52PI@k^T+JRJ>fynVlFNEJpD0(?51wDntTIoKbkEeb{#(Tg4dztI;>?y{TlvY^_* z^!Q1yfm+;o^v)UEwEQDjN1iU@j)>j!Y^#zo=D_vt6WDh@@jVzB1h}kcSqpKEI;XB6 z5|Ixto;`7M8SMxQgXbI9sxFaK*JVUW5#}s*mUZGqMbe;BObg1q;NBNNwIBZVZ zTAZIgL(<^3W_5AW=OG#HDYL%#;qejY_y{7NJ5T6$luHpM*O2y@-ii(MAs275cJ{Y* z2n$o;I~vJGf|A#xh^?6Yt}r#<@mAY%ETaCxE1pgGB@vJBYoT8(bsAl;SBwd|RsUyu z@xp}#Z|;pxA*eClPDXNjAysz2o8CQ2F_DE_{jj~*WUJJyik8k-T{FaP@9y(1dD!N|7vIHzp10S|!pVXXw$*-ry{qbCG)B~lrCd$my!!>K! ze)@7Gkh^ezm&^b!A7p&Bs*k=l?@03-pADe(i4HmDm6-<(=>sf-za29}kEe}T{gsWt zyy96Hgbu~_T}DqHt=Dy6Brg+r@K!@8JGx`$Ys8A?k|uT^!a4Q!6+-Zny@KJ{E&Is% zaMxt2>FSb;a!@>>A?OokW?;iUmWR-;w`4{1uHo3$hfyw?T`P?WKDC2&{L+u7eqxud zC~exEotEM@+T18}DIe69V)?%^_^^5a{q!_C??Qo>#?^1@Is>Zc`X76pq^BTp>!QTZ z;{}t{5qo|{3iL1RI_TXjy4kdF4DD^4>mKaA^d~z=phuB;Tv9HJQbpUBU&7qrj@*Kd z$Spw-!Pss0x(rH9y-!;DZ&5LN8shaC6n)-4 zlpLoSeW9(Y?6o*wMc`MD>*%qcE+83wYu3C+t+lK9HlH!C7u0pzSGD^@iUi;D%N%}G z0rB6)S!5O*GS}hG-Umtu(+HR5dCUNF^ z_x$Vm{P^oFzl?q$!(xy`(dp0TzEluj`^Vqkt#Z{XmLAf_r~Dl8HiH+0#*KX_mqPS^ zng0OqlUf7EdgZ^0Z2ULi%P}UM_PxJoP7XaXJ6FVzYf^kY)IQsJG5b&a@+XhVzTx;2 z@h{<~i*aXpac}*zsb_cge0YjI-n#|{e+p`w?rDiWobYeM&lBEF=4cj@+{^y}F6898 zf4u!`>b*bV&V%sR!}4od^~d&w{qOpyNyuNIJwFQdzuDsJSMi60ueFQ+0J8W?!}I?D zt7{y~cX9s!T=)Fnm2@8x?)(SvuS;(gd_dFfB-Uh~?Al&|B&~5Imj3|r)X3Q?efrmG zrg77$&UiK7gc)Zy%tUZU{~smi{{Vqwuj5`# zr~H2LN5-#=Ut@w}sK7tvrMzB2{KhNv9sECJ{{RisS9G^)jWhl}a-6^WrxbH8#=joL z{{Yzp>0TN5rQsb;(S)`(dbNeLQMVFFC#d)Luj!W8O4aWzqq~L}qDfRbrsAoa~Pt%~VyvUje(l`C&2j)-# z?dw%anw%7yvClb4PD&{M*8XsOb@2k~7G{R*65UUc*XzZ2Pm4Tl;#<34w&_>T9mo9f zL@GZaUuS$p@LsiNd$7H#4@Sj!uZc9D7d3~I;N2~5FF)s~Ku_n4)|F`;m`rMiw7K(y z`g|mR!bjuiR%?&>@mu|$PyYa) zW-QZx-$$CW;7^0L{{RraA&nY)VKt4B`&%{v1b=b;SJ?4f9ZvnfhIto`EwAA4o^@+; z3I5OB(;vS702=H60A;TQL8{s(h?#dyCrJp;>sNnjO_1qd*)d5v$#MRu2^4-@SJQv8 z{{VwVjqtw0+^k?+{{X6NoSN+RhaMuQCg*o=;F&J8qb8nwsoSi50sJfJZ}=tG#Mt~J za~6*y@c#gKILH;}BGUVRn+e+9qk~&Nvc8*XqImE77(o)D{{WVt;lDn$%}ujPb*Aag z=k=xFO;7trPV*4+>)ySxWrI*{$frKF`T6@bd`@2&=pWh>N##rb06f9K{HyML2UwC% z^`9+t9JgmH@e1nxJr?fSR(WpX{{UAiu~Yf=seB)6sZXP;_^wpBx?YN$V!O{0Y4=*@ zgZ31?`#s0^*a7<29i#X|TbfI|-F8)p*Y5crEtdPkXa!f);_E?>$*Mc4NQc)yF9JvQFz=H;=sqxT<; zeKq1eKTYuTKiX2T`%m}(0E_eMUz+~_9eyQC9lhq29N0eGP(NyXGy6e!;V(|>cQXA> zYx0}NI!fE?Z?0Pg#J_xx;`;j6*ghli2CJ;z?7od-9+$IeR$v+%&N72lT-F{UHHl29R_d#;{KN2v#hcF=_-5zC8g87n7cu_;tGF_4X}{j? ze;WFy;jf9lA!~8R;c4Nv*Q}TCT}(o;`myG^E^^MNvp&TAoxUFEJ`GpeAmBDvEjdQbQS}Wk1I8WJDFNZeYwqJvMUuyHAe-YS6 z5<9jO``7i9uvy$Fo(oMtqm9a3DQq5@=dZ1Q8lSPh#EVZ6e%C$(@NK)T&zs_l4fhVa zvG|u}h~%jLKmNUUVCciy?nl=bw3+|c&%gfwf>_(?*Z%C4) z7Qf;@Kf}Hhm0|w?o^PqzZTz=lxKD;Zu;<0Eg`N)9H5-o!qUu_;&z9KKqHird6z|}B z3jT$)5BUiS^3N52$h!Xk_4!^6Tr?9tqbZ{qPNy;a$b44+0D^n|*aI zzjr#NpFf7``vc+E>?8X^=sp?Kv|so|WD5`aylQrzC;PuQABBF?_>2BZ4ZV8)gZ}^? zwpE8BFs(NCKMvdWA^oJHw%sp=mp|;@*^ld7*M~o0@7iZd)_=8hJqhi2ZX*|OGLnpkIOj+(n!Df zs)OOD-mjp%FIkN9Eo0&4see6;cJK1R{*x$`w+DmJ`k$?7cyGaeE3wneI?TJ|Zmgj) zex|)&{$79X8CtLYs>LR8+Hr(KsNe)?v?xL7ix#b{{ZUis+}}>Zlt@P%vP=Y9{$&tZX}1o?5sKwsoDZR z@#^{aN&f(XbpF;>EjwRm_Y#|twqn|yn|X`q*>9MAYx;qcwQ^H+tNN`z#M8vhAB2Ak zKj57owCdg^mx+8U5Q#qMOUn`$=rhHBg#0%BjD9p+*&hh_Kfo5ZGavbBsOnIt{{YBL zYxNVtfASWup}%MU0QnrNvX&a3V}`9+cRw;bHTxQTQ@+|fMPg(7FZ%xgDr<4K{<+}s z_*carwMXpN`$zm{@p;jFAK-tocy~oVXoww3&181;KQ(_!8gKl?YvqIg03jV`{1K7jzT|#E{=?s}pT_S3e17n!#UB~?fBP!KM%Sz_UN|)fW@#7D?S6iSzp%LP sydH?D_G^v+AL|eJ*XCElj+Vdm^

PowerAuth Enrollment Server

- Version version, built on built. + Version version, built on + built.

2020, © Wultra s.r.o. diff --git a/enrollment-server/src/test/java/com/wultra/app/docverify/AbstractDocumentVerificationProviderTest.java b/enrollment-server/src/test/java/com/wultra/app/docverify/AbstractDocumentVerificationProviderTest.java new file mode 100644 index 000000000..8faecd3d6 --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/docverify/AbstractDocumentVerificationProviderTest.java @@ -0,0 +1,55 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.docverify; + +import com.wultra.app.enrollmentserver.model.integration.DocumentSubmitResult; +import com.wultra.app.enrollmentserver.model.integration.DocumentsSubmitResult; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.model.integration.SubmittedDocument; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +public class AbstractDocumentVerificationProviderTest { + + public void assertSubmittedDocuments(OwnerId ownerId, List documents, DocumentsSubmitResult result) throws Exception { + assertEquals(documents.size(), result.getResults().size(), "Different size of submitted documents than expected"); + assertNotNull(result.getExtractedPhotoId(), "Missing extracted photoId"); + + List submittedDocsIds = result.getResults().stream() + .map(DocumentSubmitResult::getDocumentId) + .collect(Collectors.toList()); + assertEquals(documents.size(), submittedDocsIds.size(), "Different size of unique submitted documents than expected"); + documents.forEach(document -> { + assertTrue(submittedDocsIds.contains(document.getDocumentId())); + }); + + result.getResults().forEach(submitResult -> { + assertNull(submitResult.getErrorDetail()); + assertNull(submitResult.getRejectReason()); + + assertNotNull(submitResult.getUploadId()); + }); + } + +} diff --git a/enrollment-server/src/test/java/com/wultra/app/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java b/enrollment-server/src/test/java/com/wultra/app/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java new file mode 100644 index 000000000..78e9a1e09 --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java @@ -0,0 +1,167 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.docverify.mock.provider; + +import com.google.common.collect.ImmutableList; +import com.wultra.app.docverify.AbstractDocumentVerificationProviderTest; +import com.wultra.app.enrollmentserver.EnrollmentServerTestApplication; +import com.wultra.app.enrollmentserver.database.entity.DocumentResultEntity; +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentVerificationStatus; +import com.wultra.app.enrollmentserver.model.integration.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@SpringBootTest(classes = EnrollmentServerTestApplication.class) +@ActiveProfiles("mock") +@ComponentScan(basePackages = {"com.wultra.app.docverify.mock"}) +@EnableConfigurationProperties +public class WultraMockDocumentVerificationProviderTest extends AbstractDocumentVerificationProviderTest { + + private WultraMockDocumentVerificationProvider provider; + + private OwnerId ownerId; + + @BeforeEach + public void init() { + ownerId = createOwnerId(); + } + + @Autowired + public void setProvider(WultraMockDocumentVerificationProvider provider) { + this.provider = provider; + } + + @Test + public void checkDocumentUploadTest() throws Exception { + SubmittedDocument document = createSubmittedDocument(); + DocumentsSubmitResult submitResult = provider.submitDocuments(ownerId, List.of(document)); + + DocumentVerificationEntity docVerification = new DocumentVerificationEntity(); + docVerification.setFilename("filename"); + docVerification.setType(document.getType()); + docVerification.setUploadId(submitResult.getResults().get(0).getUploadId()); + + DocumentsSubmitResult result = provider.checkDocumentUpload(ownerId, docVerification); + + assertEquals(1, result.getResults().size()); + assertEquals(docVerification.getUploadId(), result.getResults().get(0).getUploadId()); + } + + @Test + public void submitDocumentsTest() throws Exception { + SubmittedDocument document = createSubmittedDocument(); + List documents = ImmutableList.of(document); + + DocumentsSubmitResult result = provider.submitDocuments(ownerId, documents); + + assertSubmittedDocuments(ownerId, documents, result); + } + + @Test + public void verifyDocumentsTest() throws Exception { + List uploadIds = ImmutableList.of("doc_1", "doc_2"); + + DocumentsVerificationResult result = provider.verifyDocuments(ownerId, uploadIds); + assertEquals(DocumentVerificationStatus.IN_PROGRESS, result.getStatus()); + assertNotNull(result.getVerificationId()); + } + + @Test + public void getVerificationResultTest() throws Exception { + List uploadIds = ImmutableList.of("doc_1", "doc_2"); + + DocumentsVerificationResult result = provider.verifyDocuments(ownerId, uploadIds); + + // Check status of an existing verification + DocumentsVerificationResult verificationResults = provider.getVerificationResult(ownerId, result.getVerificationId()); + assertTrue(verificationResults.isAccepted()); + assertEquals(DocumentVerificationStatus.ACCEPTED, verificationResults.getStatus()); + + List documentResults = verificationResults.getResults(); + assertEquals(uploadIds.size(), documentResults.size()); + documentResults.forEach(documentResult -> { + assertTrue(uploadIds.contains(documentResult.getUploadId())); + assertNotNull(documentResult.getExtractedData()); + assertNotNull(documentResult.getVerificationResult()); + }); + + // Check status of a not existing verification + DocumentsVerificationResult verificationResultNotExisting = provider.getVerificationResult(ownerId, "notExisting"); + assertEquals(DocumentVerificationStatus.FAILED, verificationResultNotExisting.getStatus()); + assertNotNull(verificationResultNotExisting.getErrorDetail()); + } + + @Test + public void getPhotoTest() throws Exception { + Image photo = provider.getPhoto("photoId"); + + assertNotNull(photo.getData()); + assertNotNull(photo.getFilename()); + } + + @Test + public void cleanupDocumentsTest() throws Exception { + List uploadIds = ImmutableList.of("doc_1", "doc_2"); + + provider.cleanupDocuments(ownerId, uploadIds); + } + + @Test + public void parseRejectionReasonsTest() throws Exception { + DocumentResultEntity docResultRejected = new DocumentResultEntity(); + docResultRejected.setVerificationResult("{\"reason\":\"rejected\"}"); + assertEquals(List.of("Rejection reason"), provider.parseRejectionReasons(docResultRejected)); + + DocumentResultEntity docResultNotRejected = new DocumentResultEntity(); + docResultNotRejected.setVerificationResult("{\"reason\":\"ok\"}"); + assertEquals(Collections.emptyList(), provider.parseRejectionReasons(docResultNotRejected)); + } + + private OwnerId createOwnerId() { + OwnerId ownerId = new OwnerId(); + ownerId.setActivationId("activation-id"); + ownerId.setUserId("user-id"); + return ownerId; + } + + private SubmittedDocument createSubmittedDocument() { + SubmittedDocument document = new SubmittedDocument(); + document.setDocumentId("documentId"); + document.setType(DocumentType.ID_CARD); + document.setSide(CardSide.FRONT); + + return document; + } + +} diff --git a/enrollment-server/src/test/java/com/wultra/app/docverify/zenid/provider/ZenidDocumentVerificationProviderTest.java b/enrollment-server/src/test/java/com/wultra/app/docverify/zenid/provider/ZenidDocumentVerificationProviderTest.java new file mode 100644 index 000000000..68fddfe9b --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/docverify/zenid/provider/ZenidDocumentVerificationProviderTest.java @@ -0,0 +1,237 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.docverify.zenid.provider; + +import com.google.common.collect.ImmutableList; +import com.wultra.app.docverify.AbstractDocumentVerificationProviderTest; +import com.wultra.app.enrollmentserver.EnrollmentServerTestApplication; +import com.wultra.app.enrollmentserver.database.DocumentVerificationRepository; +import com.wultra.app.enrollmentserver.database.entity.DocumentResultEntity; +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.test.TestUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.test.context.ActiveProfiles; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@SpringBootTest(classes = EnrollmentServerTestApplication.class) +@ActiveProfiles("external-service") +@ComponentScan(basePackages = {"com.wultra.app.docverify.zenid"}) +@EnableConfigurationProperties +@Tag("external-service") +public class ZenidDocumentVerificationProviderTest extends AbstractDocumentVerificationProviderTest { + + private static final Logger logger = LoggerFactory.getLogger(ZenidDocumentVerificationProviderTest.class); + + private static final String DOC_ID_CARD_BACK = "idCardBack"; + + private static final String DOC_ID_CARD_FRONT = "idCardFront"; + + private List uploadIds; + + private OwnerId ownerId; + + @MockBean + private DocumentVerificationRepository documentVerificationRepository; + + @Autowired + private ZenidDocumentVerificationProvider provider; + + @BeforeEach + public void init() { + ownerId = createOwnerId(); + uploadIds = new ArrayList<>(); + } + + @AfterEach + public void teardown() { + try { + cleanupDocuments(ownerId); + } catch (Exception e) { + logger.warn("Unable to cleanup documents during teardown", e); + } + } + + @Test + public void checkDocumentUploadTest() throws Exception { + SubmittedDocument document = createIdCardFrontDocument(); + List documents = List.of(document); + + DocumentsSubmitResult docsSubmitResult = submitDocuments(ownerId, documents); + DocumentSubmitResult docSubmitResult = docsSubmitResult.getResults().get(0); + DocumentVerificationEntity docVerification = new DocumentVerificationEntity(); + docVerification.setType(document.getType()); + docVerification.setUploadId(docSubmitResult.getUploadId()); + DocumentsSubmitResult result = provider.checkDocumentUpload(ownerId, docVerification); + + assertEquals(1, result.getResults().size()); + assertEquals(docVerification.getUploadId(), result.getResults().get(0).getUploadId()); + } + + @Test + public void submitDocumentsTest() throws Exception { + List documents = createSubmittedDocuments(); + + DocumentsSubmitResult result = submitDocuments(ownerId, documents); + + assertSubmittedDocuments(ownerId, documents, result); + } + + @Test + public void verifyDocumentsTest() throws Exception { + List documents = createSubmittedDocuments(); + + DocumentsSubmitResult submitResult = provider.submitDocuments(ownerId, documents); + + List uploadIds = submitResult.getResults().stream() + .map(DocumentSubmitResult::getUploadId) + .collect(Collectors.toList()); + + DocumentsVerificationResult verificationResult = provider.verifyDocuments(ownerId, uploadIds); + + assertNotNull(verificationResult.getVerificationId()); + assertEquals(uploadIds.size(), verificationResult.getResults().size()); + } + + @Test + public void getVerificationResultTest() throws Exception { + List documents = createSubmittedDocuments(); + + DocumentsSubmitResult submitResult = provider.submitDocuments(ownerId, documents); + + List uploadIds = submitResult.getResults().stream() + .map(DocumentSubmitResult::getUploadId) + .collect(Collectors.toList()); + + DocumentsVerificationResult verifyDocumentsResult = provider.verifyDocuments(ownerId, uploadIds); + Mockito.when(documentVerificationRepository.findAllUploadIds(verifyDocumentsResult.getVerificationId())) + .thenReturn(uploadIds); + + DocumentsVerificationResult verificationResult = provider.getVerificationResult(ownerId, verifyDocumentsResult.getVerificationId()); + + assertEquals(verifyDocumentsResult.getVerificationId(), verificationResult.getVerificationId()); + assertEquals(uploadIds.size(), verificationResult.getResults().size()); + } + + @Test + public void getPhotoTest() throws Exception { + List documents = createSubmittedDocuments(); + + DocumentsSubmitResult result = provider.submitDocuments(ownerId, documents); + + Image photo = provider.getPhoto(result.getExtractedPhotoId()); + + assertNotNull(photo.getData()); + assertNotNull(photo.getFilename()); + } + + @Test + public void cleanupDocumentsTest() throws Exception { + List documents = createSubmittedDocuments(); + + submitDocuments(ownerId, documents); + + cleanupDocuments(ownerId); + } + + @Test + public void parseRejectionReasonsTest() throws Exception { + DocumentResultEntity docResult = new DocumentResultEntity(); + docResult.setVerificationResult("[{\"Ok\": false, \"Issues\":[{\"IssueDescription\": \"Rejection reason\"}]}]"); + List rejectionReasons = provider.parseRejectionReasons(docResult); + assertEquals(List.of("Rejection reason"), rejectionReasons); + } + + private void cleanupDocuments(OwnerId ownerId) throws Exception { + if (uploadIds.size() > 0) { + provider.cleanupDocuments(ownerId, uploadIds); + } + } + + private DocumentsSubmitResult submitDocuments(OwnerId ownerId, List documents) throws Exception { + DocumentsSubmitResult result = provider.submitDocuments(ownerId, documents); + + List uploadIdsFromSubmit = result.getResults().stream() + .map(DocumentSubmitResult::getUploadId) + .collect(Collectors.toList()); + uploadIds.addAll(uploadIdsFromSubmit); + + return result; + } + + private List createSubmittedDocuments() throws Exception { + return ImmutableList.of( + createIdCardFrontDocument(), + createIdCardBackDocument() + ); + } + + private SubmittedDocument createIdCardFrontDocument() throws IOException { + SubmittedDocument idCardFront = new SubmittedDocument(); + idCardFront.setDocumentId(DOC_ID_CARD_FRONT); + Image idCardFrontPhoto = TestUtil.loadPhoto("/images/specimen_id_front.jpg"); + idCardFront.setPhoto(idCardFrontPhoto); + idCardFront.setSide(CardSide.FRONT); + idCardFront.setType(DocumentType.ID_CARD); + + return idCardFront; + } + + private SubmittedDocument createIdCardBackDocument() throws IOException { + SubmittedDocument idCardBack = new SubmittedDocument(); + idCardBack.setDocumentId(DOC_ID_CARD_BACK); + Image idCardBackPhoto = TestUtil.loadPhoto("/images/specimen_id_back.jpg"); + idCardBack.setPhoto(idCardBackPhoto); + idCardBack.setSide(CardSide.BACK); + idCardBack.setType(DocumentType.ID_CARD); + + return idCardBack; + } + + private OwnerId createOwnerId() { + OwnerId ownerId = new OwnerId(); + ownerId.setActivationId("integration-test-" + UUID.randomUUID()); + ownerId.setUserId("integration-test-user-id"); + return ownerId; + } + +} diff --git a/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/EnrollmentServerTestApplication.java b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/EnrollmentServerTestApplication.java new file mode 100644 index 000000000..9edc6d812 --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/EnrollmentServerTestApplication.java @@ -0,0 +1,28 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.enrollmentserver; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@SpringBootApplication +public class EnrollmentServerTestApplication { + +} diff --git a/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/database/entity/OnboardingOtpEntityTest.java b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/database/entity/OnboardingOtpEntityTest.java new file mode 100644 index 000000000..03acd33d7 --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/database/entity/OnboardingOtpEntityTest.java @@ -0,0 +1,51 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.enrollmentserver.database.entity; + +import org.junit.jupiter.api.Test; + +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +class OnboardingOtpEntityTest { + + @Test + void hasExpiredTest() { + final long TIME_MS = System.currentTimeMillis(); + + OnboardingOtpEntity beforeExpiration = new OnboardingOtpEntity(); + beforeExpiration.setTimestampCreated(new Date(TIME_MS)); + beforeExpiration.setTimestampExpiration(new Date(TIME_MS + 1)); + assertFalse(beforeExpiration.hasExpired(), "Not yet expired OTP"); + + OnboardingOtpEntity sharplyBeforeExpiration = new OnboardingOtpEntity(); + sharplyBeforeExpiration.setTimestampCreated(new Date(TIME_MS)); + sharplyBeforeExpiration.setTimestampExpiration(new Date(TIME_MS)); + assertFalse(sharplyBeforeExpiration.hasExpired(), "Sharply not expired OTP"); + + OnboardingOtpEntity afterExpiration = new OnboardingOtpEntity(); + afterExpiration.setTimestampCreated(new Date(TIME_MS)); + afterExpiration.setTimestampExpiration(new Date(TIME_MS - 1)); + assertTrue(afterExpiration.hasExpired(), "Already expired OTP"); + } + +} diff --git a/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/PresenceCheckServiceTest.java b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/PresenceCheckServiceTest.java new file mode 100644 index 000000000..0840ec94b --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/PresenceCheckServiceTest.java @@ -0,0 +1,80 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.enrollmentserver.impl.service; + +import com.wultra.app.enrollmentserver.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 org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.List; + +import static org.mockito.Mockito.*; + +/** + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +class PresenceCheckServiceTest { + + @Mock + IdentityVerificationService identityVerificationService; + + @InjectMocks + PresenceCheckService service; + + @BeforeEach + public void init() { + MockitoAnnotations.openMocks(this); + } + + @Test + void selectPhotoForPresenceCheckTest() throws Exception { + OwnerId ownerId = new OwnerId(); + + // Two documents with person photo in reversed order of preference + DocumentVerificationEntity docPhotoDrivingLicense = new DocumentVerificationEntity(); + docPhotoDrivingLicense.setPhotoId("drivingLicensePhotoId"); + docPhotoDrivingLicense.setType(DocumentType.DRIVING_LICENSE); + + DocumentVerificationEntity docPhotoIdCard = new DocumentVerificationEntity(); + docPhotoIdCard.setPhotoId("idCardPhotoId"); + docPhotoIdCard.setType(DocumentType.ID_CARD); + + List documentsReversedOrder = List.of(docPhotoDrivingLicense, docPhotoIdCard); + + service.selectPhotoForPresenceCheck(ownerId, documentsReversedOrder); + when(identityVerificationService.getPhotoById(docPhotoIdCard.getPhotoId())).thenReturn(new Image()); + verify(identityVerificationService, times(1)).getPhotoById(docPhotoIdCard.getPhotoId()); + + // Unknown document with a person photo + DocumentVerificationEntity docPhotoUnknown = new DocumentVerificationEntity(); + docPhotoUnknown.setPhotoId("unknownPhotoId"); + docPhotoUnknown.setType(DocumentType.UNKNOWN); + + List documentUnknown = List.of(docPhotoUnknown); + + service.selectPhotoForPresenceCheck(ownerId, documentUnknown); + when(identityVerificationService.getPhotoById(docPhotoUnknown.getPhotoId())).thenReturn(new Image()); + verify(identityVerificationService, times(1)).getPhotoById(docPhotoUnknown.getPhotoId()); + } + +} diff --git a/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentProcessingServiceTest.java b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentProcessingServiceTest.java new file mode 100644 index 000000000..37e6a7d3e --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/document/DocumentProcessingServiceTest.java @@ -0,0 +1,74 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.enrollmentserver.impl.service.document; + +import com.wultra.app.enrollmentserver.database.DocumentVerificationRepository; +import com.wultra.app.enrollmentserver.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +/** + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +class DocumentProcessingServiceTest { + + @InjectMocks + DocumentProcessingService service; + + @Mock + DocumentVerificationRepository documentVerificationRepository; + + @BeforeEach + public void init() { + MockitoAnnotations.openMocks(this); + } + + @Test + void pairTwoSidedDocumentsTest() { + DocumentVerificationEntity docIdCardFront = new DocumentVerificationEntity(); + docIdCardFront.setId("1"); + docIdCardFront.setType(DocumentType.ID_CARD); + docIdCardFront.setSide(CardSide.FRONT); + + DocumentVerificationEntity docIdCardBack = new DocumentVerificationEntity(); + docIdCardBack.setId("2"); + docIdCardBack.setType(DocumentType.ID_CARD); + docIdCardBack.setSide(CardSide.BACK); + + List documents = List.of(docIdCardFront, docIdCardBack); + + service.pairTwoSidedDocuments(documents); + when(documentVerificationRepository.setOtherDocumentSide("1", "2")).thenReturn(1); + verify(documentVerificationRepository, times(1)).setOtherDocumentSide("1", "2"); + when(documentVerificationRepository.setOtherDocumentSide("2", "1")).thenReturn(1); + verify(documentVerificationRepository, times(1)).setOtherDocumentSide("2", "1"); + assertEquals(docIdCardBack.getId(), docIdCardFront.getOtherSideId()); + assertEquals(docIdCardFront.getId(), docIdCardBack.getOtherSideId()); + } + +} diff --git a/enrollment-server/src/test/java/com/wultra/app/presencecheck/iproov/provider/IProovPresenceCheckProviderTest.java b/enrollment-server/src/test/java/com/wultra/app/presencecheck/iproov/provider/IProovPresenceCheckProviderTest.java new file mode 100644 index 000000000..42b398eba --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/presencecheck/iproov/provider/IProovPresenceCheckProviderTest.java @@ -0,0 +1,132 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.presencecheck.iproov.provider; + +import com.wultra.app.enrollmentserver.EnrollmentServerTestApplication; +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; +import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import com.wultra.app.presencecheck.iproov.IProovConst; +import com.wultra.app.test.TestUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.test.context.ActiveProfiles; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@SpringBootTest(classes = EnrollmentServerTestApplication.class) +@ActiveProfiles("external-service") +@ComponentScan(basePackages = {"com.wultra.app.presencecheck.iproov"}) +@EnableConfigurationProperties +@Tag("external-service") +public class IProovPresenceCheckProviderTest { + + private IProovPresenceCheckProvider provider; + + private OwnerId ownerId; + + @BeforeEach + public void init() { + ownerId = createOwnerId(); + } + + @Autowired + public void setProvider(IProovPresenceCheckProvider provider) { + this.provider = provider; + } + + @Test + public void initPresenceCheckTest() throws Exception { + initPresenceCheck(ownerId); + } + + // FIXME temporary testing of repeated initiatilization (not implemented deletion of previous iProov enrollment) + @Test + public void repeatInitPresenceCheckTest() throws Exception { + initPresenceCheck(ownerId); + initPresenceCheck(ownerId); + } + + @Test + public void startPresenceCheckTest() throws Exception { + initPresenceCheck(ownerId); + + SessionInfo sessionInfo = provider.startPresenceCheck(ownerId); + + assertNotNull(sessionInfo); + assertNotNull(sessionInfo.getSessionAttributes()); + assertNotNull(sessionInfo.getSessionAttributes().get(IProovConst.VERIFICATION_TOKEN)); + } + + @Test + public void getResultTest() throws Exception { + initPresenceCheck(ownerId); + + SessionInfo sessionInfo = provider.startPresenceCheck(ownerId); + + PresenceCheckResult result = provider.getResult(ownerId, sessionInfo); + + assertEquals(PresenceCheckStatus.IN_PROGRESS, result.getStatus()); + } + + @Test + public void repeatPresenceCheckStartTest() throws Exception { + initPresenceCheck(ownerId); + + SessionInfo sessionInfo1 = provider.startPresenceCheck(ownerId); + assertNotNull( + sessionInfo1.getSessionAttributes().get(IProovConst.VERIFICATION_TOKEN), + "Missing presence check verification token in session 1" + ); + + SessionInfo sessionInfo2 = provider.startPresenceCheck(ownerId); + assertNotNull( + sessionInfo2.getSessionAttributes().get(IProovConst.VERIFICATION_TOKEN), + "Missing presence check verification token in session 2" + ); + assertNotEquals( + sessionInfo1.getSessionAttributes().get(IProovConst.VERIFICATION_TOKEN), + sessionInfo2.getSessionAttributes().get(IProovConst.VERIFICATION_TOKEN), + "Same presence check verification tokens between session 1 and session 2"); + } + + private OwnerId createOwnerId() { + OwnerId ownerId = new OwnerId(); + ownerId.setActivationId("integration-test-" + UUID.randomUUID()); + ownerId.setUserId("integration-test-user-id" + UUID.randomUUID()); + return ownerId; + } + + private void initPresenceCheck(OwnerId ownerId) throws Exception { + Image photo = TestUtil.loadPhoto("/images/specimen_photo.jpg"); + provider.initPresenceCheck(ownerId, photo); + } + +} diff --git a/enrollment-server/src/test/java/com/wultra/app/presencecheck/iproov/service/IProovRestApiServiceTest.java b/enrollment-server/src/test/java/com/wultra/app/presencecheck/iproov/service/IProovRestApiServiceTest.java new file mode 100644 index 000000000..5a6362eb0 --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/presencecheck/iproov/service/IProovRestApiServiceTest.java @@ -0,0 +1,45 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2022 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.presencecheck.iproov.service; + +import com.wultra.app.enrollmentserver.EnrollmentServerTestApplication; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(classes = EnrollmentServerTestApplication.class) +/** + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +class IProovRestApiServiceTest { + + @Test + public void ensureValidUserIdValueTest() throws Exception { + assertThrows(IllegalArgumentException.class, () -> { + IProovRestApiService.ensureValidUserIdValue("invalidChars,[="); + }); + + String userIdTooLong = RandomStringUtils.randomAlphabetic(IProovRestApiService.USER_ID_MAX_LENGTH + 1); + String userIdEnsured = IProovRestApiService.ensureValidUserIdValue(userIdTooLong); + assertEquals(IProovRestApiService.USER_ID_MAX_LENGTH, userIdEnsured.length()); + } + +} diff --git a/enrollment-server/src/test/java/com/wultra/app/presencecheck/mock/provider/WultraMockPresenceCheckProviderTest.java b/enrollment-server/src/test/java/com/wultra/app/presencecheck/mock/provider/WultraMockPresenceCheckProviderTest.java new file mode 100644 index 000000000..3ebd8e4ba --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/presencecheck/mock/provider/WultraMockPresenceCheckProviderTest.java @@ -0,0 +1,107 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.presencecheck.mock.provider; + +import com.wultra.app.enrollmentserver.EnrollmentServerTestApplication; +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; +import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import com.wultra.app.presencecheck.mock.MockConst; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@SpringBootTest(classes = EnrollmentServerTestApplication.class) +@ActiveProfiles("mock") +@ComponentScan(basePackages = {"com.wultra.app.presencecheck.mock"}) +@EnableConfigurationProperties +public class WultraMockPresenceCheckProviderTest { + + private WultraMockPresenceCheckProvider provider; + + private OwnerId ownerId; + + @BeforeEach + public void init() { + ownerId = createOwnerId(); + } + + @Autowired + public void setProvider(WultraMockPresenceCheckProvider provider) { + this.provider = provider; + } + + @Test + public void initPresenceCheckTest() throws Exception { + initPresenceCheck(); + } + + @Test + public void startPresenceCheckTest() throws Exception { + initPresenceCheck(); + + SessionInfo sessionInfo = provider.startPresenceCheck(ownerId); + + assertNotNull(sessionInfo); + assertNotNull(sessionInfo.getSessionAttributes()); + assertNotNull(sessionInfo.getSessionAttributes().get(MockConst.VERIFICATION_TOKEN)); + } + + @Test + public void getResultTest() throws Exception { + SessionInfo sessionInfo = new SessionInfo(); + sessionInfo.getSessionAttributes().put(MockConst.VERIFICATION_TOKEN, "token"); + + PresenceCheckResult result = provider.getResult(ownerId, sessionInfo); + + assertEquals(PresenceCheckStatus.ACCEPTED, result.getStatus()); + assertNotNull(result.getPhoto()); + } + + @Test + public void cleanupIdentityDataTest() throws Exception { + provider.cleanupIdentityData(ownerId); + } + + private OwnerId createOwnerId() { + OwnerId ownerId = new OwnerId(); + ownerId.setActivationId("activation-id"); + ownerId.setUserId("user-id"); + return ownerId; + } + + private void initPresenceCheck() throws Exception { + Image photo = new Image(); + photo.setData(new byte[]{}); + photo.setFilename("id_photo.jpg"); + provider.initPresenceCheck(ownerId, photo); + } + +} diff --git a/enrollment-server/src/test/java/com/wultra/app/test/TestUtil.java b/enrollment-server/src/test/java/com/wultra/app/test/TestUtil.java new file mode 100644 index 000000000..e7f130284 --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/test/TestUtil.java @@ -0,0 +1,53 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2021 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.test; + +import com.wultra.app.enrollmentserver.model.integration.Image; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +/** + * Test utilities + * + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +public class TestUtil { + + /** + * Loads photo from a file + * @param path Path to the file with the photo + * @return Image with the photo data + * @throws IOException when an error occurred + */ + public static Image loadPhoto(String path) throws IOException { + File file = new File(path); + + Image photo = new Image(); + photo.setFilename(file.getName()); + try (InputStream stream = TestUtil.class.getResourceAsStream(path)) { + if (stream == null) { + throw new IllegalStateException("Unable to get a stream for: " + path); + } + photo.setData(stream.readAllBytes()); + } + return photo; + } + +} diff --git a/enrollment-server/src/test/resources/application-external-service.properties b/enrollment-server/src/test/resources/application-external-service.properties new file mode 100644 index 000000000..1e20f9894 --- /dev/null +++ b/enrollment-server/src/test/resources/application-external-service.properties @@ -0,0 +1,7 @@ +enrollment-server.document-verification.provider=zenid + +enrollment-server.document-verification.zenid.asyncProcessingEnabled=false + +enrollment-server.presence-check.provider=iproov + +logging.level.root=INFO diff --git a/enrollment-server/src/test/resources/application-mock.properties b/enrollment-server/src/test/resources/application-mock.properties new file mode 100644 index 000000000..5534a8c5d --- /dev/null +++ b/enrollment-server/src/test/resources/application-mock.properties @@ -0,0 +1,8 @@ +# Spring Datasource +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1; +spring.datasource.username=sa +spring.datasource.password=password +spring.datasource.driver-class-name=org.h2.Driver + +enrollment-server.document-verification.provider=mock +enrollment-server.presence-check.provider=mock diff --git a/enrollment-server/src/test/resources/images/specimen_id_back.jpg b/enrollment-server/src/test/resources/images/specimen_id_back.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e226ce82ed9ebcf8d871d3e6afad596a51353407 GIT binary patch literal 47068 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 literal 0 HcmV?d00001 diff --git a/enrollment-server/src/test/resources/images/specimen_id_front.jpg b/enrollment-server/src/test/resources/images/specimen_id_front.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d00e167b81c290541497d941e90e57444684978b GIT binary patch literal 54278 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 sydH?D_G^v+AL|eJ*XCElj+Vdm^ +} + +export default MyApp diff --git a/enrollment-server/tools/iproov-verify-nextjs/pages/index.js b/enrollment-server/tools/iproov-verify-nextjs/pages/index.js new file mode 100644 index 000000000..59f042559 --- /dev/null +++ b/enrollment-server/tools/iproov-verify-nextjs/pages/index.js @@ -0,0 +1,67 @@ +import Head from 'next/head' +import Image from "next/image"; +import Link from "next/link"; +import Script from "next/script"; +import {useState} from 'react'; + +// Import the library in browser +export default function Home() { + const [token, setToken] = useState("") + + const handleChange = event => { + setToken(event.target.value); + } + + return ( +

+ + Wultra Enrollment Server - iProov demo + + + +