diff --git a/Dockerfile b/Dockerfile index ca781b412..4f51ea7d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ibm-semeru-runtimes:open-17.0.8_7-jre +FROM ibm-semeru-runtimes:open-17.0.9_9-jre LABEL maintainer="petr@wultra.com" # Prepare environment variables @@ -8,7 +8,7 @@ ENV JAVA_HOME=/opt/java/openjdk \ PKG_RELEASE=1~jammy \ TOMCAT_HOME=/usr/local/tomcat \ TOMCAT_MAJOR=10 \ - TOMCAT_VERSION=10.1.13 \ + TOMCAT_VERSION=10.1.17 \ TZ=UTC ENV PATH=$PATH:$LB_HOME:$TOMCAT_HOME/bin @@ -20,7 +20,7 @@ RUN apt-get -y update \ # Install tomcat RUN curl -jkSL -o /tmp/apache-tomcat.tar.gz http://archive.apache.org/dist/tomcat/tomcat-${TOMCAT_MAJOR}/v${TOMCAT_VERSION}/bin/apache-tomcat-${TOMCAT_VERSION}.tar.gz \ - && [ "406c0c367ac6ad95bb724ecc3a3c340ad7ded8c62287d657811eeec496eaaca1f5add52d2f46111da1426ae67090c543f6deccfeb5fdf4bdae32f9b39e773265 /tmp/apache-tomcat.tar.gz" = "$(sha512sum /tmp/apache-tomcat.tar.gz)" ] \ + && [ "ff9670f9cd49a604e47edfbcfb5855fe59342048c3278ea8736276b51327adf2d076973f3ad1b8aa7870ef26c28cf7111527be810b445c9927f2a457795f5cb6 /tmp/apache-tomcat.tar.gz" = "$(sha512sum /tmp/apache-tomcat.tar.gz)" ] \ && gunzip /tmp/apache-tomcat.tar.gz \ && tar -C /opt -xf /tmp/apache-tomcat.tar \ && ln -s /opt/apache-tomcat-$TOMCAT_VERSION $TOMCAT_HOME diff --git a/docs-private/Developer-How-To-Start.md b/docs-private/Developer-How-To-Start.md index b59929d3b..1ca53619d 100644 --- a/docs-private/Developer-How-To-Start.md +++ b/docs-private/Developer-How-To-Start.md @@ -6,7 +6,6 @@ ### Standalone Run -- Enable maven profile `standalone` - Use IntelliJ Idea run configuration at `../.run/EnrollmentServerApplication.run.xml` - Open [http://localhost:8081/enrollment-server/actuator/health](http://localhost:8081/enrollment-server/actuator/health) and you should get `{"status":"UP"}` @@ -37,20 +36,20 @@ mvn clean package ### Build the docker image ```shell -docker build . -t enrollment-server:1.5.0 +docker build . -t enrollment-server:1.6.0 ``` ### Prepare environment variables -* Copy `deploy/env.list.tmp` to `./env.list` and edit the values to use it via `docker run --env-file env.list IMAGE` -* Or set environment variables via `docker run -e ENROLLMENT_SERVER_DATASOURCE_USERNAME='powerauth' IMAGE` +* Copy `deploy/env.list.tmp` to `./env.list` and edit the values to use it via `docker run --env-file env.list enrollment-server:1.6.0` +* Or set environment variables via `docker run -e ENROLLMENT_SERVER_DATASOURCE_USERNAME='powerauth' enrollment-server:1.6.0` ### Run the docker image ```shell -docker run -p 80:8080 -e ENROLLMENT_SERVER_DATASOURCE_URL='jdbc:postgresql://host.docker.internal:5432/powerauth' -e ENROLLMENT_SERVER_DATASOURCE_USERNAME='powerauth' -e ENROLLMENT_SERVER_DATASOURCE_PASSWORD='' enrollment-server:1.5.0 +docker run -p 80:8080 -e ENROLLMENT_SERVER_DATASOURCE_URL='jdbc:postgresql://host.docker.internal:5432/powerauth' -e ENROLLMENT_SERVER_DATASOURCE_USERNAME='powerauth' -e ENROLLMENT_SERVER_DATASOURCE_PASSWORD='' enrollment-server:1.6.0 ``` @@ -59,7 +58,6 @@ docker run -p 80:8080 -e ENROLLMENT_SERVER_DATASOURCE_URL='jdbc:postgresql://hos ### Standalone Run -- Enable maven profile `standalone` - Use IntelliJ Idea run configuration at `../.run/EnrollmentServerOnboardingApplication.run.xml` - Open [http://localhost:8083/enrollment-server-onboarding/actuator/health](http://localhost:8083/enrollment-server-onboarding/actuator/health) and you should get `{"status":"UP"}` diff --git a/docs/Configuration-Properties.md b/docs/Configuration-Properties.md index c0d5a0187..46718a263 100644 --- a/docs/Configuration-Properties.md +++ b/docs/Configuration-Properties.md @@ -10,7 +10,6 @@ The Enrollment Server uses the following public configuration properties: | `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 | @@ -61,3 +60,9 @@ Sample setting of logging pattern: ```properties logging.pattern.console=%clr(%d{${LOG_DATEFORMAT_PATTERN:yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) [%X{X-Correlation-ID}] %clr(%5p) %clr(${PID: }){magenta} %clr(---){faint}%clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx} ``` + + +## Monitoring and Observability + +The WAR file includes the `micrometer-registry-prometheus` dependency. +Discuss its configuration with the [Spring Boot documentation](https://docs.spring.io/spring-boot/docs/3.1.x/reference/html/actuator.html#actuator.metrics). diff --git a/docs/Migration-Instructions.md b/docs/Migration-Instructions.md index 074891779..0bf91d4af 100644 --- a/docs/Migration-Instructions.md +++ b/docs/Migration-Instructions.md @@ -2,5 +2,6 @@ This page contains PowerAuth Enrollment Server migration instructions. +- [PowerAuth Enrollment Server 1.6.0](./PowerAuth-Enrollment-Server-1.6.0.md) - [PowerAuth Enrollment Server 1.5.0](./PowerAuth-Enrollment-Server-1.5.0.md) - [PowerAuth Enrollment Server 1.4.0](./PowerAuth-Enrollment-Server-1.4.0.md) diff --git a/docs/Mobile-Token-API.md b/docs/Mobile-Token-API.md index a7ddf261e..d155655e3 100644 --- a/docs/Mobile-Token-API.md +++ b/docs/Mobile-Token-API.md @@ -17,6 +17,8 @@ Following endpoints are published in Enrollment Server RESTful API: ### Operations API - `POST` [/api/auth/token/app/operation/list](#get-pending-operations) - List pending Mobile Token operations +- `POST` [/api/auth/token/app/operation/detail](#get-operation-detail) - Get detail of a Mobile Token operation +- `POST` [/api/auth/token/app/operation/detail/claim](#claim-operation) - Claim a Mobile Token operation for a user - `POST` [/api/auth/token/app/operation/history](#get-history-of-operations) - Get history of Mobile Token operations - `POST` [/api/auth/token/app/operation/authorize](#confirm-operation) - Confirm a Mobile Token operation - `POST` [/api/auth/token/app/operation/cancel](#reject-operation) - Reject a Mobile Token operation @@ -75,15 +77,16 @@ Mobile token API provides access to operations. List of error codes in Mobile Token API: -| Code | Description | HTTP Status Code | -|---|---|---| -| `INVALID_REQUEST` | Invalid request sent - missing request object in request | 400 | -| `INVALID_ACTIVATION` | Activation is not valid (it is different from configured activation). Return this error in case the activation does not exist, or in case the activation is not allowed to perform the action (for example, user did not allow operation approvals on such device). | 400 | -| `POWERAUTH_AUTH_FAIL` | PowerAuth authentication failed | 401 | -| `OPERATION_ALREADY_FINISHED` | Operation is already finished | 400 | -| `OPERATION_ALREADY_FAILED` | Operation is already failed | 400 | -| `OPERATION_ALREADY_CANCELED` | Operation is already canceled | 400 | -| `OPERATION_EXPIRED` | Operation is expired | 400 | +| Code | Description | HTTP Status Code | +|------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------| +| `INVALID_REQUEST` | Invalid request sent - missing request object in request | 400 | +| `INVALID_ACTIVATION` | Activation is not valid (it is different from configured activation). Return this error in case the activation does not exist, or in case the activation is not allowed to perform the action (for example, user did not allow operation approvals on such device). | 400 | +| `POWERAUTH_AUTH_FAIL` | PowerAuth authentication failed | 401 | +| `OPERATION_ALREADY_FINISHED` | Operation is already finished | 400 | +| `OPERATION_ALREADY_FAILED` | Operation is already failed | 400 | +| `OPERATION_ALREADY_CANCELED` | Operation is already canceled | 400 | +| `OPERATION_EXPIRED` | Operation is expired | 400 | +| `OPERATION_FAILED` | PowerAuth server operation approval fails. | 401 | ## Localization @@ -225,6 +228,282 @@ Get the list with all operations that are pending confirmation. ``` + +### Get Operation Detail + +Get an operation detail. + + + + + + + + + + + + +
MethodPOST
Resource URI/api/auth/token/app/operation/detail
+ + +#### Request + +- Headers: + - `Content-Type: application/json` + - `Accept-Language: en-US` + - `X-PowerAuth-Authorization: ...` + +```json +{ + "requestObject": { + "id": "7e0ba60f-bf22-4ff5-b999-2733784e5eaa" + } +} +``` + +#### Response 200 + +```json +{ + "status": "OK", + "responseObject": { + "id": "7e0ba60f-bf22-4ff5-b999-2733784e5eaa", + "name": "authorize_payment", + "data": "A1*A100CZK*Q238400856/0300**D20190629*NUtility Bill Payment - 05/2019", + "status": "PENDING", + "operationCreated": "2018-07-02T14:43:13+0000", + "operationExpires": "2018-07-02T14:48:17+0000", + "allowedSignatureType": { + "type": "2FA", + "variants": [ + "possession_knowledge", + "possession_biometry" + ] + }, + "formData": { + "title": "Confirm Payment", + "message": "Hello,\nplease confirm following payment:", + "attributes": [ + { + "type": "ALERT", + "alertType": "WARNING", + "id": "operation.warning", + "label": "Balance alert", + "title": "Insufficient Balance", + "message": "You have only $1.00 on your account with number 238400856/0300." + }, + { + "type": "HEADING", + "id": "operation.heading", + "label": "Utility Payment" + }, + { + "type": "AMOUNT", + "id": "operation.amount", + "label": "Amount", + "amount": 2199.40, + "currency": "CZK", + "amountFormatted": "2199,40", + "currencyFormatted": "Kč" + }, + { + "type": "AMOUNT_CONVERSION", + "id": "operation.conversion", + "label": "Conversion Rate", + "dynamic": false, + "sourceAmount": 100.00, + "sourceCurrency": "USD", + "sourceAmountFormatted": "100.00", + "sourceCurrencyFormatted": "$", + "sourceValueFormatted": "$100.00", + "targetAmount": 2199.40, + "targetCurrency": "CZK", + "targetAmountFormatted": "2199,40", + "targetCurrencyFormatted": "Kč", + "targetValueFormatted": "2199,40 Kč" + }, + { + "type": "KEY_VALUE", + "id": "operation.account", + "label": "To Account", + "value": "238400856/0300" + }, + { + "type": "KEY_VALUE", + "id": "operation.dueDate", + "label": "Due Date", + "value": "Jun 29, 2019" + }, + { + "type": "NOTE", + "id": "operation.note", + "label": "Note", + "note": "Utility Bill Payment - 05/2019" + }, + { + "type": "IMAGE", + "id": "operation.image", + "label": "Payment Check Preview", + "thumbnailUrl": "https://example.com/thumbnail.png", + "originalUrl": "https://example.com/image.png" + }, + { + "type": "PARTY_INFO", + "id": "operation.partyInfo", + "label": "Application", + "partyInfo": { + "logoUrl": "https://itesco.cz/img/logo/logo.svg", + "name": "Tesco", + "description": "Find out more about Tesco...", + "websiteUrl": "https://itesco.cz/hello" + } + } + ] + } + } +} +``` + + + +### Claim Operation + +Claim an operation for a user. + + + + + + + + + + + + +
MethodPOST
Resource URI/api/auth/token/app/operation/detail/claim
+ + +#### Request + +- Headers: + - `Content-Type: application/json` + - `Accept-Language: en-US` + - `X-PowerAuth-Authorization: ...` + +```json +{ + "requestObject": { + "id": "7e0ba60f-bf22-4ff5-b999-2733784e5eaa" + } +} +``` + +#### Response 200 + +```json +{ + "status": "OK", + "responseObject": { + "id": "7e0ba60f-bf22-4ff5-b999-2733784e5eaa", + "name": "authorize_payment", + "data": "A1*A100CZK*Q238400856/0300**D20190629*NUtility Bill Payment - 05/2019", + "status": "PENDING", + "operationCreated": "2018-07-02T14:43:13+0000", + "operationExpires": "2018-07-02T14:48:17+0000", + "allowedSignatureType": { + "type": "2FA", + "variants": [ + "possession_knowledge", + "possession_biometry" + ] + }, + "formData": { + "title": "Confirm Payment", + "message": "Hello,\nplease confirm following payment:", + "attributes": [ + { + "type": "ALERT", + "alertType": "WARNING", + "id": "operation.warning", + "label": "Balance alert", + "title": "Insufficient Balance", + "message": "You have only $1.00 on your account with number 238400856/0300." + }, + { + "type": "HEADING", + "id": "operation.heading", + "label": "Utility Payment" + }, + { + "type": "AMOUNT", + "id": "operation.amount", + "label": "Amount", + "amount": 2199.40, + "currency": "CZK", + "amountFormatted": "2199,40", + "currencyFormatted": "Kč" + }, + { + "type": "AMOUNT_CONVERSION", + "id": "operation.conversion", + "label": "Conversion Rate", + "dynamic": false, + "sourceAmount": 100.00, + "sourceCurrency": "USD", + "sourceAmountFormatted": "100.00", + "sourceCurrencyFormatted": "$", + "sourceValueFormatted": "$100.00", + "targetAmount": 2199.40, + "targetCurrency": "CZK", + "targetAmountFormatted": "2199,40", + "targetCurrencyFormatted": "Kč", + "targetValueFormatted": "2199,40 Kč" + }, + { + "type": "KEY_VALUE", + "id": "operation.account", + "label": "To Account", + "value": "238400856/0300" + }, + { + "type": "KEY_VALUE", + "id": "operation.dueDate", + "label": "Due Date", + "value": "Jun 29, 2019" + }, + { + "type": "NOTE", + "id": "operation.note", + "label": "Note", + "note": "Utility Bill Payment - 05/2019" + }, + { + "type": "IMAGE", + "id": "operation.image", + "label": "Payment Check Preview", + "thumbnailUrl": "https://example.com/thumbnail.png", + "originalUrl": "https://example.com/image.png" + }, + { + "type": "PARTY_INFO", + "id": "operation.partyInfo", + "label": "Application", + "partyInfo": { + "logoUrl": "https://itesco.cz/img/logo/logo.svg", + "name": "Tesco", + "description": "Find out more about Tesco...", + "websiteUrl": "https://itesco.cz/hello" + } + } + ] + } + } +} +``` + + ### Get History of Operations diff --git a/docs/PowerAuth-Enrollment-Server-1.6.0.md b/docs/PowerAuth-Enrollment-Server-1.6.0.md new file mode 100644 index 000000000..66ea56171 --- /dev/null +++ b/docs/PowerAuth-Enrollment-Server-1.6.0.md @@ -0,0 +1,5 @@ +# Migration from 1.5.x to 1.6.x + +This guide contains instructions for migration from PowerAuth Enrollment Server version `1.5.x` to version `1.6.0`. + +No migration steps nor database changes are required. diff --git a/docs/onboarding/Configuration-Properties.md b/docs/onboarding/Configuration-Properties.md index a9c3e1dd5..799d45a15 100644 --- a/docs/onboarding/Configuration-Properties.md +++ b/docs/onboarding/Configuration-Properties.md @@ -10,7 +10,6 @@ The Onboarding Server uses the following public configuration properties: | `spring.datasource.username` | `powerauth` | Database JDBC username | | `spring.datasource.password` | `_empty_` | Database JDBC password | | `spring.datasource.driver-class-name` | `org.postgresql.Driver` | Datasource JDBC class name | -| `spring.jpa.database-platform` | `org.hibernate.dialect.PostgreSQLDialect` | Database dialect | | `spring.jpa.hibernate.ddl-auto` | `none` | Configuration of automatic database schema creation | | `spring.jpa.properties.hibernate.connection.characterEncoding` | `utf8` | Character encoding | | `spring.jpa.properties.hibernate.connection.useUnicode` | `true` | Character encoding - Unicode support | @@ -75,7 +74,7 @@ The Onboarding Server uses the following public configuration properties: | Property | Default | Note | |---|---|---| -| `enrollment-server-onboarding.document-verification.provider` | `mock` | Document verification provider (`mock`, `zenid`). | +| `enrollment-server-onboarding.document-verification.provider` | `mock` | Document verification provider (`mock`, `zenid`, `innovatrics`). | | `enrollment-server-onboarding.document-verification.cleanupEnabled` | `false` | Whether document cleanup is enabled for the provider. | | `enrollment-server-onboarding.document-verification.checkInProgressDocumentSubmits` | `0/5 * * * * *` | Cron scheduler for checking status of submitted documents. | | `enrollment-server-onboarding.document-verification.checkDocumentsVerifications.cron` | `0/5 * * * * *` | Cron scheduler for checking pending document verifications. | @@ -88,7 +87,7 @@ The Onboarding Server uses the following public configuration properties: | Property | Default | Note | |--------------------------------------------------------------------------------|---------|----------------------------------------------------------------------------------------| | `enrollment-server-onboarding.presence-check.enabled` | `true` | Whether presence check provider is enabled. | -| `enrollment-server-onboarding.presence-check.provider` | `mock` | Presence check provider (`mock`, `iproov`). | +| `enrollment-server-onboarding.presence-check.provider` | `mock` | Presence check provider (`mock`, `iproov`, `innovatrics`). | | `enrollment-server-onboarding.presence-check.cleanupEnabled` | `false` | Whether cleanup of presence check data is enabled. | | `enrollment-server-onboarding.presence-check.verifySelfieWithDocumentsEnabled` | `false` | Whether verification of the presence check selfie photo with the documents is enabled. | | `enrollment-server-onboarding.presence-check.max-failed-attempts` | `5` | Maximum failed attempts for presence check and OTP verification. | @@ -128,12 +127,32 @@ The Onboarding Server uses the following public configuration properties: | `enrollment-server-onboarding.presence-check.iproov.oAuthClientUsername` | | OAuth client username to iProov REST service. | | `enrollment-server-onboarding.presence-check.iproov.oAuthClientPassword` | | OAuth client password to iProov REST service. | | `enrollment-server-onboarding.presence-check.iproov.restClientConfig.acceptInvalidSslCertificate` | `false` | Whether invalid SSL certificate is accepted when calling Zen ID REST service. | -| `enrollment-server-onboarding.document-verification.zenid.restClientConfig.maxInMemorySize` | `10485760` | Maximum in memory size of HTTP requests when calling iProov REST service. | -| `enrollment-server-onboarding.presence-check.iproov.zenid.restClientConfig.proxyEnabled` | `false` | Whether proxy server is enabled when calling iProov REST service. | -| `enrollment-server-onboarding.presence-check.iproov.zenid.restClientConfig.proxyHost` | | Proxy host to be used when calling iProov REST service. | -| `enrollment-server-onboarding.presence-check.iproov.zenid.restClientConfig.proxyPort` | 0 | Proxy port to be used when calling iProov REST service. | -| `enrollment-server-onboarding.presence-check.iproov.zenid.restClientConfig.proxyUsername` | | Proxy username to be used when calling iProov REST service. | -| `enrollment-server-onboarding.presence-check.iproov.zenid.restClientConfig.proxyPassword` | | Proxy password to be used when calling iProov REST service. | +| `enrollment-server-onboarding.presence-check.iproov.restClientConfig.maxInMemorySize` | `10485760` | Maximum in memory size of HTTP requests when calling iProov REST service. | +| `enrollment-server-onboarding.presence-check.iproov.restClientConfig.proxyEnabled` | `false` | Whether proxy server is enabled when calling iProov REST service. | +| `enrollment-server-onboarding.presence-check.iproov.restClientConfig.proxyHost` | | Proxy host to be used when calling iProov REST service. | +| `enrollment-server-onboarding.presence-check.iproov.restClientConfig.proxyPort` | 0 | Proxy port to be used when calling iProov REST service. | +| `enrollment-server-onboarding.presence-check.iproov.restClientConfig.proxyUsername` | | Proxy username to be used when calling iProov REST service. | +| `enrollment-server-onboarding.presence-check.iproov.restClientConfig.proxyPassword` | | Proxy password to be used when calling iProov REST service. | + + +## Innovatrics Configuration + +| Property | Default | Note | +|---------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `enrollment-server-onboarding.provider.innovatrics.serviceBaseUrl` | | Base REST service URL for Innovatrics. | +| `enrollment-server-onboarding.provider.innovatrics.serviceToken` | | Authentication token for Innovatrics. | +| `enrollment-server-onboarding.provider.innovatrics.serviceUserAgent` | `Wultra/OnboardingServer` | User agent to use when making HTTP calls to Innovatrics REST service. | +| `enrollment-server-onboarding.provider.innovatrics.presenceCheckConfiguration.score` | 0.875 | Presence check minimal score threshold. | +| `enrollment-server-onboarding.provider.innovatrics.documentVerificationConfiguration.documentCountries` | `CZE` | List of expected countries of issue of identification documents as three-letter country codes, i.e. ISO 3166-1 alpha-3. If empty, all countries of issue known to Innovatrics are considered during classification, which may have negative impact on performance. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.acceptInvalidSslCertificate` | `false` | Whether invalid SSL certificate is accepted when calling Zen ID REST service. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.maxInMemorySize` | `10485760` | Maximum in memory size of HTTP requests when calling Innovatrics REST service. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyEnabled` | `false` | Whether proxy server is enabled when calling Innovatrics REST service. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyHost` | | Proxy host to be used when calling Innovatrics REST service. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyPort` | 0 | Proxy port to be used when calling Innovatrics REST service. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyUsername` | | Proxy username to be used when calling Innovatrics REST service. | +| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyPassword` | | Proxy password to be used when calling Innovatrics REST service. | + +See [Innovatrics documentation](https://developers.innovatrics.com/digital-onboarding/docs/functionalities/face/active-liveness-check/#magnifeye-liveness) for details how the score affects false acceptances (FAR) and false rejections (FRR). ## Correlation HTTP Header Configuration @@ -148,3 +167,9 @@ Sample setting of logging pattern: ```properties logging.pattern.console=%clr(%d{${LOG_DATEFORMAT_PATTERN:yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) [%X{X-Correlation-ID}] %clr(%5p) %clr(${PID: }){magenta} %clr(---){faint}%clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx} ``` + + +## Monitoring and Observability + +The WAR file includes the `micrometer-registry-prometheus` dependency. +Discuss its configuration with the [Spring Boot documentation](https://docs.spring.io/spring-boot/docs/3.1.x/reference/html/actuator.html#actuator.metrics). diff --git a/docs/onboarding/Configuration-Verification-Providers.md b/docs/onboarding/Configuration-Verification-Providers.md index ea4f83544..ed358b8bd 100644 --- a/docs/onboarding/Configuration-Verification-Providers.md +++ b/docs/onboarding/Configuration-Verification-Providers.md @@ -6,6 +6,7 @@ This document describes configuration of providers for personal identity documen The document verification process is currently supported for following providers: - [ZenID](https://zenid.trask.cz/) - use value `zenid` in configuration +- [Innovatrics](https://www.innovatrics.com/) - use value `innovatrics` in configuration - Mock - useful for simple testing and local runs - use value `mock` in configuration ### ZenID @@ -35,6 +36,7 @@ When calling `document-verification/init-sdk` following implementation fields ar The document verification process is currently supported for following providers: - [iProov](https://www.iproov.com/) - use value `iproov` in configuration +- [Innovatrics](https://www.innovatrics.com/) - use value `innovatrics` in configuration - Mock - useful for simple testing and local runs - use value `mock` in configuration #### Configuration diff --git a/docs/onboarding/Deploying-Wildfly.md b/docs/onboarding/Deploying-Wildfly.md index 3e7bce397..b2653251e 100644 --- a/docs/onboarding/Deploying-Wildfly.md +++ b/docs/onboarding/Deploying-Wildfly.md @@ -15,7 +15,7 @@ Enrollment Server contains the following configuration in `jboss-deployment-stru - + diff --git a/docs/onboarding/Migration-Instructions.md b/docs/onboarding/Migration-Instructions.md index 21b191aa9..9c502ee33 100644 --- a/docs/onboarding/Migration-Instructions.md +++ b/docs/onboarding/Migration-Instructions.md @@ -2,4 +2,5 @@ This page contains PowerAuth Enrollment Onboarding Server migration instructions. +- [PowerAuth Enrollment Onboarding Server 1.6.0](./PowerAuth-Enrollment-Onboarding-Server-1.6.0.md) - [PowerAuth Enrollment Onboarding Server 1.5.0](./PowerAuth-Enrollment-Onboarding-Server-1.5.0.md) diff --git a/docs/onboarding/PowerAuth-Enrollment-Onboarding-Server-1.6.0.md b/docs/onboarding/PowerAuth-Enrollment-Onboarding-Server-1.6.0.md new file mode 100644 index 000000000..1613bb122 --- /dev/null +++ b/docs/onboarding/PowerAuth-Enrollment-Onboarding-Server-1.6.0.md @@ -0,0 +1,5 @@ +# Migration from 1.5.x to 1.6.x + +This guide contains instructions for migration from PowerAuth Enrollment Onboarding Server version `1.5.x` to version `1.6.0`. + +No migration steps nor database changes are required. diff --git a/enrollment-server-api-model/pom.xml b/enrollment-server-api-model/pom.xml index 96073dbc9..d4fde669a 100644 --- a/enrollment-server-api-model/pom.xml +++ b/enrollment-server-api-model/pom.xml @@ -30,7 +30,7 @@ com.wultra.security enrollment-server-parent - 1.5.0 + 1.6.0 diff --git a/enrollment-server-onboarding-adapter-mock/pom.xml b/enrollment-server-onboarding-adapter-mock/pom.xml index 3ba8def77..e3216d78d 100644 --- a/enrollment-server-onboarding-adapter-mock/pom.xml +++ b/enrollment-server-onboarding-adapter-mock/pom.xml @@ -24,7 +24,7 @@ com.wultra.security enrollment-server-parent - 1.5.0 + 1.6.0 enrollment-server-onboarding-adapter-mock diff --git a/enrollment-server-onboarding-api-model/pom.xml b/enrollment-server-onboarding-api-model/pom.xml index 51eb931a8..aea8d0ba1 100644 --- a/enrollment-server-onboarding-api-model/pom.xml +++ b/enrollment-server-onboarding-api-model/pom.xml @@ -7,7 +7,7 @@ com.wultra.security enrollment-server-parent - 1.5.0 + 1.6.0 enrollment-server-onboarding-api-model diff --git a/enrollment-server-onboarding-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/onboarding/response/error/ActivationOtpErrorResponse.java b/enrollment-server-onboarding-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/onboarding/response/error/ActivationOtpErrorResponse.java index 43e1dc5aa..11c8d181c 100644 --- a/enrollment-server-onboarding-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/onboarding/response/error/ActivationOtpErrorResponse.java +++ b/enrollment-server-onboarding-api-model/src/main/java/com/wultra/app/enrollmentserver/api/model/onboarding/response/error/ActivationOtpErrorResponse.java @@ -19,21 +19,23 @@ import io.getlime.core.rest.model.base.response.ErrorResponse; import jakarta.validation.constraints.NotBlank; +import lombok.EqualsAndHashCode; /** * Response class used when OTP code verification fails during activation (soft fail). * * @author Roman Strobl, roman.strobl@wultra.com */ +@EqualsAndHashCode(callSuper = true) public class ActivationOtpErrorResponse extends ErrorResponse { - private Integer remainingAttempts; + private final Integer remainingAttempts; /** * Default constructor. */ public ActivationOtpErrorResponse() { - super(); + remainingAttempts = null; } /** @@ -55,12 +57,4 @@ public Integer getRemainingAttempts() { return remainingAttempts; } - /** - * Set remaining attempts for OTP verification during activation. - * @param remainingAttempts Remaining attempts for OTP verification during activation. - */ - public void setRemainingAttempts(Integer remainingAttempts) { - this.remainingAttempts = remainingAttempts; - } - } diff --git a/enrollment-server-onboarding-api/pom.xml b/enrollment-server-onboarding-api/pom.xml new file mode 100644 index 000000000..82203d276 --- /dev/null +++ b/enrollment-server-onboarding-api/pom.xml @@ -0,0 +1,40 @@ + + + + + 4.0.0 + + + com.wultra.security + enrollment-server-parent + 1.6.0 + + + com.wultra.security + enrollment-server-onboarding-api + + + + com.wultra.security + enrollment-server-onboarding-common + + + \ No newline at end of file diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/errorhandling/DocumentVerificationException.java b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/errorhandling/DocumentVerificationException.java similarity index 95% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/errorhandling/DocumentVerificationException.java rename to enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/errorhandling/DocumentVerificationException.java index 8d8043e51..b24d8825d 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/errorhandling/DocumentVerificationException.java +++ b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/errorhandling/DocumentVerificationException.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.onboardingserver.errorhandling; +package com.wultra.app.onboardingserver.api.errorhandling; import java.io.Serial; diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/errorhandling/PresenceCheckException.java b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/errorhandling/PresenceCheckException.java similarity index 95% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/errorhandling/PresenceCheckException.java rename to enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/errorhandling/PresenceCheckException.java index 9837c8bc0..90c319ecc 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/errorhandling/PresenceCheckException.java +++ b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/errorhandling/PresenceCheckException.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.onboardingserver.errorhandling; +package com.wultra.app.onboardingserver.api.errorhandling; import java.io.Serial; diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/provider/DocumentVerificationProvider.java b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/DocumentVerificationProvider.java similarity index 89% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/provider/DocumentVerificationProvider.java rename to enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/DocumentVerificationProvider.java index 0c2e88558..dfbfb5e41 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/provider/DocumentVerificationProvider.java +++ b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/DocumentVerificationProvider.java @@ -15,13 +15,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.onboardingserver.provider; +package com.wultra.app.onboardingserver.api.provider; import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity; import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; +import com.wultra.core.annotations.PublicSpi; import java.util.List; import java.util.Map; @@ -31,6 +32,7 @@ * * @author Roman Strobl, roman.strobl@wultra.com */ +@PublicSpi public interface DocumentVerificationProvider { /** @@ -56,6 +58,15 @@ public interface DocumentVerificationProvider { */ DocumentsSubmitResult submitDocuments(OwnerId id, List documents) throws RemoteCommunicationException, DocumentVerificationException; + /** + * A feature flag whether the selfie result gained by {@link PresenceCheckProvider} should be stored by {@link #submitDocuments(OwnerId, List)}. + *

+ * Some implementation may require this cross-sending between providers by the Onboarding server, some providers may handle it internally. + * + * @return {@code true} if cross-sending between providers should be handled by Onboarding server, {@code false} otherwise. + */ + boolean shouldStoreSelfie(); + /** * Analyze previously submitted documents, detect frauds, return binary result * diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/provider/PresenceCheckProvider.java b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/PresenceCheckProvider.java similarity index 72% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/provider/PresenceCheckProvider.java rename to enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/PresenceCheckProvider.java index 252e8cad6..2a4ace4ac 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/provider/PresenceCheckProvider.java +++ b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/PresenceCheckProvider.java @@ -15,20 +15,22 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.onboardingserver.provider; +package com.wultra.app.onboardingserver.api.provider; 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.onboardingserver.api.errorhandling.PresenceCheckException; import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; -import com.wultra.app.onboardingserver.errorhandling.PresenceCheckException; +import com.wultra.core.annotations.PublicSpi; /** * Provider which allows customization of the presence check. * * @author Roman Strobl, roman.strobl@wultra.com */ +@PublicSpi public interface PresenceCheckProvider { /** @@ -41,6 +43,15 @@ public interface PresenceCheckProvider { */ void initPresenceCheck(OwnerId id, Image photo) throws PresenceCheckException, RemoteCommunicationException; + /** + * Configuration flag setting where the provider implementation expects the trusted photo of the user. + *

+ * Some implementation may require specific source to be called by Onboarding server, some providers may handle it internally. + * + * @return Source where the trusted photo is expected. + */ + TrustedPhotoSource trustedPhotoSource(); + /** * Starts the presence check process. The process has to be initialized before this call. * @@ -65,9 +76,24 @@ public interface PresenceCheckProvider { * Cleans up all presence check data related to the identity. * * @param id Owner identification. + * @param sessionInfo Session info with presence check relevant data. * @throws PresenceCheckException In case of business logic error. * @throws RemoteCommunicationException In case of remote communication error. */ - void cleanupIdentityData(OwnerId id) throws PresenceCheckException, RemoteCommunicationException; + void cleanupIdentityData(OwnerId id, SessionInfo sessionInfo) throws PresenceCheckException, RemoteCommunicationException; + + /** + * Return type for {@link #trustedPhotoSource()}. + */ + enum TrustedPhotoSource { + /** + * If the trusted photo should be passed in {@link #initPresenceCheck} + */ + IMAGE, + /** + * If the trusted photo is passed via reference in {@link SessionInfo} + */ + REFERENCE + } } diff --git a/enrollment-server-onboarding-common/pom.xml b/enrollment-server-onboarding-common/pom.xml index 85d4054bf..7c54ec298 100644 --- a/enrollment-server-onboarding-common/pom.xml +++ b/enrollment-server-onboarding-common/pom.xml @@ -24,7 +24,7 @@ com.wultra.security enrollment-server-parent - 1.5.0 + 1.6.0 enrollment-server-onboarding-common @@ -61,6 +61,12 @@ spring-boot-starter-test test + + + com.h2database + h2 + test + diff --git a/enrollment-server-onboarding-common/src/main/java/com/wultra/app/onboardingserver/common/database/DocumentResultRepository.java b/enrollment-server-onboarding-common/src/main/java/com/wultra/app/onboardingserver/common/database/DocumentResultRepository.java index cb0eab250..fdcc339ba 100644 --- a/enrollment-server-onboarding-common/src/main/java/com/wultra/app/onboardingserver/common/database/DocumentResultRepository.java +++ b/enrollment-server-onboarding-common/src/main/java/com/wultra/app/onboardingserver/common/database/DocumentResultRepository.java @@ -40,18 +40,20 @@ public interface DocumentResultRepository extends CrudRepository streamAllInProgressDocumentSubmits(); + Stream streamAllInProgressDocumentSubmits(String providerName); /** * @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.providerName = :providerName " + " AND doc.documentVerification.verificationId IS NOT NULL" + " ORDER BY doc.timestampCreated ASC") - Stream streamAllInProgressDocumentSubmitVerifications(); + Stream streamAllInProgressDocumentSubmitVerifications(String providerName); /** * @return All document results for the specified document verification and processing phase diff --git a/enrollment-server-onboarding-common/src/main/java/com/wultra/app/onboardingserver/common/errorhandling/IdentityVerificationException.java b/enrollment-server-onboarding-common/src/main/java/com/wultra/app/onboardingserver/common/errorhandling/IdentityVerificationException.java index 401f15436..f403b7bcc 100644 --- a/enrollment-server-onboarding-common/src/main/java/com/wultra/app/onboardingserver/common/errorhandling/IdentityVerificationException.java +++ b/enrollment-server-onboarding-common/src/main/java/com/wultra/app/onboardingserver/common/errorhandling/IdentityVerificationException.java @@ -36,4 +36,8 @@ public IdentityVerificationException(String message) { super(message); } + public IdentityVerificationException(String message, Throwable cause) { + super(message, cause); + } + } \ No newline at end of file diff --git a/enrollment-server-onboarding-common/src/test/java/com/wultra/app/onboardingserver/common/EnrollmentServerOnboardingCommonTestApplication.java b/enrollment-server-onboarding-common/src/test/java/com/wultra/app/onboardingserver/common/EnrollmentServerOnboardingCommonTestApplication.java new file mode 100644 index 000000000..437b51434 --- /dev/null +++ b/enrollment-server-onboarding-common/src/test/java/com/wultra/app/onboardingserver/common/EnrollmentServerOnboardingCommonTestApplication.java @@ -0,0 +1,24 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.common; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class EnrollmentServerOnboardingCommonTestApplication { +} diff --git a/enrollment-server-onboarding-common/src/test/java/com/wultra/app/onboardingserver/common/database/DocumentResultRepositoryTest.java b/enrollment-server-onboarding-common/src/test/java/com/wultra/app/onboardingserver/common/database/DocumentResultRepositoryTest.java new file mode 100644 index 000000000..c95706d24 --- /dev/null +++ b/enrollment-server-onboarding-common/src/test/java/com/wultra/app/onboardingserver/common/database/DocumentResultRepositoryTest.java @@ -0,0 +1,65 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.common.database; + + +import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity; +import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test for {@link DocumentResultRepository}. + * + * @author Jan Pesek, jan.pesek@wultra.com + */ +@DataJpaTest +@ActiveProfiles("test") +@Transactional +class DocumentResultRepositoryTest { + + @Autowired + private DocumentResultRepository tested; + + @Test + @Sql + void testStreamAllInProgressDocumentSubmits() { + assertThat(tested.streamAllInProgressDocumentSubmits("mock")) + .extracting(DocumentResultEntity::getDocumentVerification) + .extracting(DocumentVerificationEntity::getProviderName) + .containsOnly("mock") + .hasSize(1); + } + + @Test + @Sql + void testStreamAllInProgressDocumentSubmitVerifications() { + assertThat(tested.streamAllInProgressDocumentSubmitVerifications("mock")) + .extracting(DocumentResultEntity::getDocumentVerification) + .extracting(DocumentVerificationEntity::getProviderName) + .containsOnly("mock") + .hasSize(1); + } + +} diff --git a/enrollment-server-onboarding-common/src/test/resources/application-test.properties b/enrollment-server-onboarding-common/src/test/resources/application-test.properties new file mode 100644 index 000000000..308c2b3ac --- /dev/null +++ b/enrollment-server-onboarding-common/src/test/resources/application-test.properties @@ -0,0 +1,5 @@ +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.username=sa +spring.datasource.password=password +spring.datasource.driver-class-name=org.h2.Driver +spring.jpa.hibernate.ddl-auto=create diff --git a/enrollment-server-onboarding-common/src/test/resources/com/wultra/app/onboardingserver/common/database/DocumentResultRepositoryTest.testStreamAllInProgressDocumentSubmitVerifications.sql b/enrollment-server-onboarding-common/src/test/resources/com/wultra/app/onboardingserver/common/database/DocumentResultRepositoryTest.testStreamAllInProgressDocumentSubmitVerifications.sql new file mode 100644 index 000000000..a6affd76d --- /dev/null +++ b/enrollment-server-onboarding-common/src/test/resources/com/wultra/app/onboardingserver/common/database/DocumentResultRepositoryTest.testStreamAllInProgressDocumentSubmitVerifications.sql @@ -0,0 +1,12 @@ +-- Documents that have been already submitted and data were extracted (multiple providers). +INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created, timestamp_last_updated) VALUES +('v3', 'a3', 'u3', 'p3', 'IN_PROGRESS', 'DOCUMENT_UPLOAD', now(), now()), +('v4', 'a4', 'u4', 'p4', 'IN_PROGRESS', 'DOCUMENT_UPLOAD', now(), now()); + +INSERT INTO es_document_verification(id, provider_name, activation_id, identity_verification_id, verification_id, type, status, filename, used_for_verification, timestamp_created, timestamp_last_updated) VALUES + ('d3', 'foreign', 'a3', 'v3', 'verification1', 'ID_CARD', 'UPLOAD_IN_PROGRESS', 'f3', true, now(), now()), + ('d4', 'mock', 'a4', 'v4', 'verification2', 'ID_CARD', 'UPLOAD_IN_PROGRESS', 'f4', true, now(), now()); + +INSERT INTO es_document_result(id, document_verification_id, phase, extracted_data, timestamp_created) VALUES + (3, 'd3', 'UPLOAD', '{extracted_data}', now()), + (4, 'd4', 'UPLOAD', '{extracted_data}', now()); diff --git a/enrollment-server-onboarding-common/src/test/resources/com/wultra/app/onboardingserver/common/database/DocumentResultRepositoryTest.testStreamAllInProgressDocumentSubmits.sql b/enrollment-server-onboarding-common/src/test/resources/com/wultra/app/onboardingserver/common/database/DocumentResultRepositoryTest.testStreamAllInProgressDocumentSubmits.sql new file mode 100644 index 000000000..e32e438eb --- /dev/null +++ b/enrollment-server-onboarding-common/src/test/resources/com/wultra/app/onboardingserver/common/database/DocumentResultRepositoryTest.testStreamAllInProgressDocumentSubmits.sql @@ -0,0 +1,12 @@ +-- Documents that have not been submitted yet (multiple providers). +INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created, timestamp_last_updated) VALUES + ('v1', 'a1', 'u1', 'p1', 'IN_PROGRESS', 'DOCUMENT_UPLOAD', now(), now()), + ('v2', 'a2', 'u2', 'p2', 'IN_PROGRESS', 'DOCUMENT_UPLOAD', now(), now()); + +INSERT INTO es_document_verification(id, provider_name, activation_id, identity_verification_id, type, status, filename, used_for_verification, timestamp_created, timestamp_last_updated) VALUES + ('d1', 'foreign', 'a1', 'v1', 'ID_CARD', 'UPLOAD_IN_PROGRESS', 'f1', true, now(), now()), + ('d2', 'mock', 'a2', 'v2', 'ID_CARD', 'UPLOAD_IN_PROGRESS', 'f2', true, now(), now()); + +INSERT INTO es_document_result(id, document_verification_id, phase, timestamp_created) VALUES + (1, 'd1', 'UPLOAD', now()), + (2, 'd2', 'UPLOAD', now()); diff --git a/enrollment-server-onboarding-domain-model/pom.xml b/enrollment-server-onboarding-domain-model/pom.xml index a02cd9f4d..cc0fb0b21 100644 --- a/enrollment-server-onboarding-domain-model/pom.xml +++ b/enrollment-server-onboarding-domain-model/pom.xml @@ -30,7 +30,7 @@ com.wultra.security enrollment-server-parent - 1.5.0 + 1.6.0 diff --git a/enrollment-server-onboarding-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java b/enrollment-server-onboarding-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java index 1dfec196f..89aed8802 100644 --- a/enrollment-server-onboarding-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java +++ b/enrollment-server-onboarding-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java @@ -30,6 +30,11 @@ @Data public class SessionInfo { + public static final String ATTRIBUTE_TIMESTAMP_LAST_USED = "timestampLastUsed"; + public static final String ATTRIBUTE_IMAGE_UPLOADED = "imageUploaded"; + public static final String ATTRIBUTE_PRIMARY_DOCUMENT_REFERENCE = "primaryDocumentReference"; + public static final String ATTRIBUTE_OTHER_DOCUMENTS_REFERENCES = "otherDocumentsReferences"; + private Map sessionAttributes = new LinkedHashMap<>(); } diff --git a/enrollment-server-onboarding-provider-innovatrics/pom.xml b/enrollment-server-onboarding-provider-innovatrics/pom.xml new file mode 100644 index 000000000..1fd6417a4 --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/pom.xml @@ -0,0 +1,93 @@ + + + + + 4.0.0 + + + com.wultra.security + enrollment-server-parent + 1.6.0 + + + com.wultra.security + enrollment-server-onboarding-provider-innovatrics + + + + com.wultra.security + enrollment-server-onboarding-api + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.squareup.okhttp3 + mockwebserver + test + + + + com.h2database + h2 + test + + + + + + + org.openapitools + openapi-generator-maven-plugin + ${openapi-generator-maven-plugin.version} + + + openapi-definitions-innovatrics + + generate + + + false + ${basedir}/src/main/resources/api/api-innovatrics.json + java + false + false + true + false + false + + native + true + com.wultra.app.onboardingserver.provider.innovatrics.model.api + true + + + + + + + + + \ No newline at end of file diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsApiService.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsApiService.java new file mode 100644 index 000000000..bc3caebde --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsApiService.java @@ -0,0 +1,468 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CustomerInspectResponse; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.EvaluateCustomerLivenessRequest; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.EvaluateCustomerLivenessResponse; +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.*; +import com.wultra.core.rest.client.base.RestClient; +import com.wultra.core.rest.client.base.RestClientException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.util.List; +import java.util.Optional; + +/** + * Implementation of the REST service toInnovatrics. + *

+ * It is not possible to combine Innovatrics with other providers such as iProov or ZenID. + * Both providers, document verifier and presence check, must be configured to {@code innovatrics}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + * @author Jan Pesek, jan.pesek@wultra.com + */ +@ConditionalOnExpression(""" + '${enrollment-server-onboarding.presence-check.provider}' == 'innovatrics' and '${enrollment-server-onboarding.document-verification.provider}' == 'innovatrics' + """) +@Service +@Slf4j +class InnovatricsApiService { + + private static final MultiValueMap EMPTY_QUERY_PARAMS = new LinkedMultiValueMap<>(); + + /** + * REST client for Innovatrics calls. + */ + private final RestClient restClient; + + /** + * Configuration properties. + */ + private final InnovatricsConfigProps configProps; + + /** + * Service constructor. + * + * @param restClient REST template for Innovatrics calls. + */ + @Autowired + public InnovatricsApiService(@Qualifier("restClientInnovatrics") final RestClient restClient, + InnovatricsConfigProps configProps) { + this.restClient = restClient; + this.configProps = configProps; + } + + public EvaluateCustomerLivenessResponse evaluateLiveness(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final EvaluateCustomerLivenessRequest request = new EvaluateCustomerLivenessRequest(); + request.setType(EvaluateCustomerLivenessRequest.TypeEnum.MAGNIFEYE_LIVENESS); + + final String apiPath = "/api/v1/customers/%s/liveness/evaluation".formatted(customerId); + + try { + logger.info("Calling liveness/evaluation, {}", ownerId); + logger.debug("Calling {}, {}", apiPath, request); + final ResponseEntity response = restClient.post(apiPath, request, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for liveness/evaluation, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException( + String.format("Failed REST call to evaluate liveness for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when evaluating liveness for customerId=" + customerId, e); + } + } + + public CustomerInspectResponse inspectCustomer(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/inspect".formatted(customerId); + + try { + logger.info("Calling /inspect, {}", ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.post(apiPath, null, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for /inspect, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException( + String.format("Failed REST call to evaluate inspect for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when evaluating inspect for customerId=" + customerId, e); + } + } + + public void deleteLiveness(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/liveness".formatted(customerId); + + try { + logger.info("Deleting liveness, {}", ownerId); + logger.debug("Deleting {}", apiPath); + final ResponseEntity response = restClient.delete(apiPath, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for liveness delete, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + } catch (RestClientException e) { + throw new RemoteCommunicationException( + String.format("Failed REST call to delete liveness for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when deleting liveness for customerId=" + customerId, e); + } + } + + public void deleteSelfie(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/selfie".formatted(customerId); + + try { + logger.info("Deleting selfie, {}", ownerId); + logger.debug("Deleting {}", apiPath); + final ResponseEntity response = restClient.delete(apiPath, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for selfie delete, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + } catch (RestClientException e) { + throw new RemoteCommunicationException( + String.format("Failed REST call to delete selfie for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when deleting selfie for customerId=" + customerId, e); + } + } + + public void createLiveness(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/liveness".formatted(customerId); + + try { + logger.info("Calling liveness creation, {}", ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.put(apiPath, null, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for liveness creation, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + } catch (RestClientException e) { + throw new RemoteCommunicationException( + String.format("Failed REST call to liveness creation for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when creating liveness for customerId=" + customerId, e); + } + } + + public CreateCustomerLivenessRecordResponse createLivenessRecord(final String customerId, final byte[] requestData, final OwnerId ownerId) throws RemoteCommunicationException{ + final String apiPath = "/api/v1/customers/%s/liveness/records".formatted(customerId); + + final HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM); + + try { + logger.info("Calling liveness record creation, {}", ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.post(apiPath, requestData, EMPTY_QUERY_PARAMS, httpHeaders, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for liveness record creation, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException( + String.format("Failed REST call to liveness record creation for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when creating liveness record for customerId=" + customerId, e); + } + } + + public CreateSelfieResponse createSelfie(final String customerId, final String livenessSelfieLink, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/selfie".formatted(customerId); + + final CreateSelfieRequest request = new CreateSelfieRequest().selfieOrigin(new LivenessSelfieOrigin().link(livenessSelfieLink)); + + try { + logger.info("Calling selfie creation, {}", ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.put(apiPath, request, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for selfie creation, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException( + String.format("Failed REST call to selfie creation for customerId=%s, statusCode=%s, responseBody='%s'", customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when creating selfie for customerId=" + customerId, e); + } + } + + /** + * Create a new customer resource. + * @param ownerId owner identification. + * @return optional of CreateCustomerResponse with a customerId. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public CreateCustomerResponse createCustomer(final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers"; + + try { + logger.info("Creating customer, {}", ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.post(apiPath, null, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for creating customer, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed when creating a new customer resource, statusCode=%s, responseBody='%s'".formatted(e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when creating a new customer resource", e); + } + } + + /** + * Create a new document resource assigned to a customer. This resource is used for documents only, not for selfies. + * @param customerId id of the customer to assign the resource to. + * @param documentType type of document that will be uploaded later. + * @param ownerId owner identification. + * @return optional of CreateDocumentResponse. Does not contain important details. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public CreateDocumentResponse createDocument(final String customerId, final DocumentType documentType, final OwnerId ownerId) throws RemoteCommunicationException, DocumentVerificationException { + final String apiPath = "/api/v1/customers/%s/document".formatted(customerId); + + final DocumentClassificationAdvice classificationAdvice = new DocumentClassificationAdvice(); + classificationAdvice.setTypes(List.of(convertType(documentType))); + classificationAdvice.setCountries(configProps.getDocumentVerificationConfiguration().getDocumentCountries()); + final DocumentAdvice advice = new DocumentAdvice(); + advice.setClassification(classificationAdvice); + final CreateDocumentRequest request = new CreateDocumentRequest(); + request.setAdvice(advice); + + try { + logger.info("Creating new document of type {} for customer {}, {}", documentType, customerId, ownerId); + logger.debug("Calling {}, {}", apiPath, request); + final ResponseEntity response = restClient.put(apiPath, request, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for creating document, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed when creating a new document resource for customerId=%s, statusCode=%s, responseBody='%s'".formatted(customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when creating a new document resource for customerId=%s".formatted(customerId), e); + } + } + + /** + * Provide photo of a document page. A document resource must be already assigned to the customer. + * @param customerId id of the customer to whom the document should be provided. + * @param side specifies side of the document. + * @param imageBytes image of the page encoded in base64. + * @param ownerId owner identification. + * @return optional of CreateDocumentPageResponse with details extracted from the page. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public CreateDocumentPageResponse provideDocumentPage(final String customerId, final CardSide side, final byte[] imageBytes, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/document/pages".formatted(customerId); + + final DocumentPageClassificationAdvice classificationAdvice = new DocumentPageClassificationAdvice(); + classificationAdvice.setPageTypes(List.of(convertSide(side))); + final DocumentPageAdvice advice = new DocumentPageAdvice(); + advice.setClassification(classificationAdvice); + + final Image image = new Image(); + image.setData(imageBytes); + + final CreateDocumentPageRequest request = new CreateDocumentPageRequest(); + request.setAdvice(advice); + request.setImage(image); + + try { + logger.info("Providing {} side document page for customer {}, {}", convertSide(side), customerId, ownerId); + logger.debug("Calling {}, {}", apiPath, request); + final ResponseEntity response = restClient.put(apiPath, request, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for providing document page, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed when providing a document page for customerId=%s, statusCode=%s, responseBody='%s'".formatted(customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when providing a document page for customerId=%s".formatted(customerId), e); + } + } + + /** + * Get details gathered about the customer. + * @param customerId id of the customer. + * @param ownerId owner identification. + * @return optional of GetCustomerResponse with details about the customer. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public GetCustomerResponse getCustomer(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s".formatted(customerId); + + try { + logger.info("Getting details about customer {}, {}", customerId, ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.get(apiPath, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for getting details about customer, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed when getting details of customerId=%s, statusCode=%s, responseBody='%s'".formatted(customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when getting details of customerId=%s".formatted(customerId), e); + } + } + + /** + * Get document portrait of the customer. + * @param customerId id of the customer. + * @param ownerId owner identification. + * @return successful Response contains a base64 in the JPG format. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public Optional getDocumentPortrait(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/document/portrait".formatted(customerId); + + try { + logger.info("Getting document portrait of customer {}, {}", customerId, ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.get(apiPath, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for getting document portrait, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return Optional.ofNullable(response.getBody()); + } catch (RestClientException e) { + if (HttpStatus.NOT_FOUND == e.getStatusCode()) { + // API returns 404 in case of missing portrait photo. + logger.debug("Missing portrait photo for customer {}, {}", customerId, ownerId); + return Optional.empty(); + } + throw new RemoteCommunicationException("REST API call failed when getting customer portrait, statusCode=%s, responseBody='%s'".formatted(e.getStatusCode(), e.getResponse()), e); + } + } + + /** + * Inspect consistency of data of the submitted document provided for a customer. + * @param customerId id of the customer whose document to inspect. + * @param ownerId owner identification. + * @return optional of DocumentInspectResponse with details about consistency of the document. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public DocumentInspectResponse inspectDocument(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/document/inspect".formatted(customerId); + + try { + logger.info("Getting document inspect of customer {}, {}", customerId, ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.post(apiPath, null, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for getting document inspect, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + return response.getBody(); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed while getting document inspection for customerId=%s, statusCode=%s, responseBody='%s'".formatted(customerId, e.getStatusCode(), e.getResponse()), e); + } catch (Exception e) { + throw new RemoteCommunicationException("Unexpected error when getting document inspection for customerId=%s".formatted(customerId), e); + } + } + + /** + * Delete customer. + * @param customerId id of the customer. + * @param ownerId owner identification. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public void deleteCustomer(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s".formatted(customerId); + + try { + logger.info("Deleting customer {}, {}", customerId, ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.delete(apiPath, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for deleting customer, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed when deleting customer, statusCode=%s, responseBody='%s'".formatted(e.getStatusCode(), e.getResponse()), e); + } + } + + /** + * Delete customer's document. + * @param customerId id of the customer. + * @param ownerId owner identification. + * @throws RemoteCommunicationException in case of 4xx or 5xx response status code. + */ + public void deleteDocument(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + final String apiPath = "/api/v1/customers/%s/document".formatted(customerId); + + try { + logger.info("Deleting document of customer {}, {}", customerId, ownerId); + logger.debug("Calling {}", apiPath); + final ResponseEntity response = restClient.delete(apiPath, new ParameterizedTypeReference<>() {}); + logger.info("Got {} for deleting customer's document, {}", response.getStatusCode(), ownerId); + logger.debug("{} response status code: {}", apiPath, response.getStatusCode()); + logger.trace("{} response: {}", apiPath, response); + } catch (RestClientException e) { + throw new RemoteCommunicationException("REST API call failed when deleting customer's document, statusCode=%s, responseBody='%s'".formatted(e.getStatusCode(), e.getResponse()), e); + } + } + + /** + * Converts internal DocumentType enum to string value used by Innovatrics. + * @param type represents type of document. + * @return document type as a string value. + */ + private static String convertType(DocumentType type) throws DocumentVerificationException { + return switch (type) { + case ID_CARD -> "identity-card"; + case PASSPORT -> "passport"; + case DRIVING_LICENSE -> "drivers-licence"; + default -> throw new DocumentVerificationException("Unsupported documentType " + type); + }; + } + + /** + * Converts internal CardSide enum to string value used by Innovatrics. + * @param side represents side of a card. + * @return side of a card as a string value. + */ + private static String convertSide(CardSide side) { + return switch (side) { + case FRONT -> "front"; + case BACK -> "back"; + }; + } + +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfig.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfig.java new file mode 100644 index 000000000..6781e1e87 --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfig.java @@ -0,0 +1,68 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.wultra.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 lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; + +/** + * Innovatrics configuration. + *

+ * It is not possible to combine Innovatrics with other providers such as iProov or ZenID. + * Both providers, document verifier and presence check, must be configured to {@code innovatrics}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@ConditionalOnExpression(""" + '${enrollment-server-onboarding.presence-check.provider}' == 'innovatrics' and '${enrollment-server-onboarding.document-verification.provider}' == 'innovatrics' + """) +@ComponentScan(basePackages = {"com.wultra.app.onboardingserver.provider.innovatrics"}) +@Configuration +@Slf4j +class InnovatricsConfig { + + /** + * Prepares REST client specific to Innovatrics. + * + * @param configProps Configuration properties + * @return REST client for Innovatrics service API calls. + */ + @Bean("restClientInnovatrics") + public RestClient restClientInnovatrics(final InnovatricsConfigProps configProps) throws RestClientException { + final String serviceBaseUrl = configProps.getServiceBaseUrl(); + logger.info("Registering restClientInnovatrics: {}", serviceBaseUrl); + + final HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.USER_AGENT, configProps.getServiceUserAgent()); + headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + configProps.getServiceToken()); + + final RestClientConfiguration restClientConfiguration = configProps.getRestClientConfig(); + restClientConfiguration.setBaseUrl(serviceBaseUrl); + restClientConfiguration.setDefaultHttpHeaders(headers); + return new DefaultRestClient(restClientConfiguration); + } + +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfigProps.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfigProps.java new file mode 100644 index 000000000..da7adb429 --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfigProps.java @@ -0,0 +1,86 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.wultra.core.rest.client.base.RestClientConfiguration; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.List; +import java.util.Set; + +/** + * Innovatrics configuration properties. + *

+ * It is not possible to combine Innovatrics with other providers such as iProov or ZenID. + * Both providers, document verifier and presence check, must be configured to {@code innovatrics}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@ConditionalOnExpression(""" + '${enrollment-server-onboarding.presence-check.provider}' == 'innovatrics' and '${enrollment-server-onboarding.document-verification.provider}' == 'innovatrics' + """) +@Configuration +@ConfigurationProperties(prefix = "enrollment-server-onboarding.provider.innovatrics") +@Getter @Setter +class InnovatricsConfigProps { + + /** + * Service base URL. + */ + private String serviceBaseUrl; + + /** + * Authentication for Innovatrics. + */ + private String serviceToken; + + /** + * Identification of the application calling the REST services passed as the User-Agent header. + */ + private String serviceUserAgent; + + /** + * REST client configuration. + */ + private RestClientConfiguration restClientConfig; + + private PresenceCheckConfiguration presenceCheckConfiguration; + + private DocumentVerificationConfiguration documentVerificationConfiguration; + + @Getter @Setter + public static class PresenceCheckConfiguration { + /** + * Presence check minimal score threshold. + */ + private double score = 0.875; + } + + @Getter @Setter + public static class DocumentVerificationConfiguration { + /** + * Identifies expected document countries of issue in ISO 3166-1 alpha-3 format. + */ + private List documentCountries; + } + +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProvider.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProvider.java new file mode 100644 index 000000000..5f660561e --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProvider.java @@ -0,0 +1,408 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Strings; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentVerificationStatus; +import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.enrollmentserver.model.integration.Image; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.provider.DocumentVerificationProvider; +import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity; +import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; +import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.*; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Implementation of the {@link DocumentVerificationProvider} with Innovatrics. + * + * @author Jan Pesek, jan.pesek@wultra.com + */ +@ConditionalOnExpression(""" + '${enrollment-server-onboarding.presence-check.provider}' == 'innovatrics' and '${enrollment-server-onboarding.document-verification.provider}' == 'innovatrics' + """) +@Component +@AllArgsConstructor +@Slf4j +public class InnovatricsDocumentVerificationProvider implements DocumentVerificationProvider { + + private final InnovatricsApiService innovatricsApiService; + private final ObjectMapper objectMapper; + private final InnovatricsConfigProps configuration; + + @Override + public DocumentsSubmitResult checkDocumentUpload(OwnerId id, DocumentVerificationEntity document) throws RemoteCommunicationException, DocumentVerificationException { + logger.warn("Unexpected state of document {}, {}", document, id); + throw new UnsupportedOperationException("Method checkDocumentUpload is not supported by Innovatrics provider."); + } + + @Override + public DocumentsSubmitResult submitDocuments(OwnerId id, List documents) throws RemoteCommunicationException, DocumentVerificationException { + if (CollectionUtils.isEmpty(documents)) { + logger.info("Empty documents list passed to document provider, {}", id); + return new DocumentsSubmitResult(); + } + + final DocumentType documentType = documents.get(0).getType(); + if (DocumentType.SELFIE_PHOTO == documentType) { + logger.debug("Selfie photo passed as a document, {}", id); + throw new DocumentVerificationException("Selfie photo cannot be submitted as a document"); + } + + if (DocumentType.SELFIE_VIDEO == documentType) { + logger.debug("Selfie video passed as a document, {}", id); + throw new DocumentVerificationException("Selfie video cannot be submitted as a document"); + } + + final String customerId = createCustomer(id); + createDocument(customerId, documentType, id); + logger.debug("Created new customer {}, {}", customerId, id); + + final DocumentsSubmitResult results = new DocumentsSubmitResult(); + for (SubmittedDocument page : documents) { + final CreateDocumentPageResponse createDocumentPageResponse = provideDocumentPage(customerId, page, id); + if (containsError(createDocumentPageResponse)) { + logger.debug("Page upload was not successful, {}", id); + results.getResults().add(createErrorSubmitResult(customerId, createDocumentPageResponse, page)); + } else { + logger.debug("Document page was read successfully by provider, {}", id); + results.getResults().add(createSubmitResult(customerId, page)); + } + } + + final Optional primaryPage = results.getResults().stream() + .filter(result -> Strings.isNullOrEmpty(result.getRejectReason()) && Strings.isNullOrEmpty(result.getErrorDetail())) + .findFirst(); + + if (primaryPage.isPresent()) { + // Only first found successfully submitted page has extracted data, others has empty JSON + primaryPage.get().setExtractedData(getExtractedData(customerId, id)); + if (hasDocumentPortrait(customerId, id)) { + results.setExtractedPhotoId(customerId); + } + } + + return results; + } + + @Override + public boolean shouldStoreSelfie() { + return false; + } + + @Override + public DocumentsVerificationResult verifyDocuments(OwnerId id, List uploadIds) throws RemoteCommunicationException, DocumentVerificationException { + final DocumentsVerificationResult results = new DocumentsVerificationResult(); + results.setResults(new ArrayList<>()); + + // Pages of the same document have same uploadId (= customerId), no reason to generate verification for each one. + final List distinctUploadIds = uploadIds.stream().distinct().toList(); + for (String customerId : distinctUploadIds) { + final DocumentVerificationResult result = createVerificationResult(customerId, id); + results.getResults().add(result); + } + + final String rejectReasons = results.getResults().stream() + .map(DocumentVerificationResult::getRejectReason) + .filter(StringUtils::hasText) + .collect(Collectors.joining(";")); + if (StringUtils.hasText(rejectReasons)) { + logger.debug("Some documents were rejected: rejectReasons={}, {}", rejectReasons, id); + results.setStatus(DocumentVerificationStatus.REJECTED); + results.setRejectReason(rejectReasons); + } else { + logger.debug("All documents accepted, {}", id); + results.setStatus(DocumentVerificationStatus.ACCEPTED); + } + results.setVerificationId(UUID.randomUUID().toString()); + return results; + } + + @Override + public DocumentsVerificationResult getVerificationResult(OwnerId id, String verificationId) throws RemoteCommunicationException, DocumentVerificationException { + logger.warn("Unexpected state of documents with verificationId={}, {}", verificationId, id); + throw new UnsupportedOperationException("Method getVerificationResult is not supported by Innovatrics provider."); + } + + @Override + public Image getPhoto(String photoId) throws RemoteCommunicationException, DocumentVerificationException { + logger.warn("Unexpected document portrait query for customerId={}", photoId); + throw new UnsupportedOperationException("Method getPhoto is not implemented by Innovatrics provider."); + } + + @Override + public void cleanupDocuments(OwnerId id, List uploadIds) throws RemoteCommunicationException, DocumentVerificationException { + // Pages of the same document have same uploadId (= customerId), no reason to call delete for each one. + final List distinctUploadIds = uploadIds.stream().distinct().toList(); + logger.info("Invoked cleanupDocuments, {}", id); + for (String customerId : distinctUploadIds) { + innovatricsApiService.deleteCustomer(customerId, id); + } + } + + @Override + public List parseRejectionReasons(DocumentResultEntity docResult) throws DocumentVerificationException { + logger.debug("Parsing rejection reasons of {}", docResult); + final String rejectionReasons = docResult.getRejectReason(); + if (!StringUtils.hasText(rejectionReasons)) { + return Collections.emptyList(); + } + + return deserializeFromString(rejectionReasons); + } + + @Override + public VerificationSdkInfo initVerificationSdk(OwnerId id, Map initAttributes) throws RemoteCommunicationException, DocumentVerificationException { + logger.debug("#initVerificationSdk does nothing for Innovatrics, {}", id); + return new VerificationSdkInfo(); + } + + /** + * Create a new customer resource. + * @param ownerId owner identification. + * @return ID of the new customer. + * @throws RemoteCommunicationException if the resource was not created properly. + */ + private String createCustomer(final OwnerId ownerId) throws RemoteCommunicationException { + return innovatricsApiService.createCustomer(ownerId).getId(); + } + + /** + * Create a new document resource to an existing customer. + * @param customerId id of the customer to assign the resource to. + * @param documentType type of the document that will be uploaded later. + * @param ownerId owner identification. + * @throws RemoteCommunicationException if the resource was not created properly. + */ + private void createDocument(final String customerId, final DocumentType documentType, final OwnerId ownerId) throws RemoteCommunicationException, DocumentVerificationException { + innovatricsApiService.createDocument(customerId, documentType, ownerId); + } + + /** + * Upload a page of a document to a customer. + * @param customerId id of the customer to whom upload the document page. + * @param page SubmittedDocument object representing the page. + * @param ownerId owner identification. + * @return CreateDocumentPageResponse containing info about the document type. An unsuccessful response will contain an error code. + * @throws RemoteCommunicationException if the document page was not uploaded properly. + */ + private CreateDocumentPageResponse provideDocumentPage(final String customerId, final SubmittedDocument page, final OwnerId ownerId) throws RemoteCommunicationException { + return innovatricsApiService.provideDocumentPage(customerId, page.getSide(), page.getPhoto().getData(), ownerId); + } + + /** + * Checks if CreateDocumentPageResponse contains error or warnings. + * @param pageResponse response to a page upload. + * @return true if there is an error or warnings, false otherwise. + */ + private static boolean containsError(CreateDocumentPageResponse pageResponse) { + return pageResponse.getErrorCode() != null || !CollectionUtils.isEmpty(pageResponse.getWarnings()); + } + + /** + * Creates DocumentSubmitResult with error or reject reason. + * @param uploadId external id of the document. + * @param response returned from provider. + * @return DocumentSubmitResult with error or reject reason. + * @throws DocumentVerificationException in case of rejection reason serialization error. + */ + private DocumentSubmitResult createErrorSubmitResult(String uploadId, CreateDocumentPageResponse response, SubmittedDocument submitted) throws DocumentVerificationException { + final DocumentSubmitResult result = new DocumentSubmitResult(); + result.setUploadId(uploadId); + result.setDocumentId(submitted.getDocumentId()); + + final List rejectionReasons = new ArrayList<>(); + if (response.getErrorCode() != null) { + switch (response.getErrorCode()) { + case NO_CARD_CORNERS_DETECTED -> rejectionReasons.add("Document page was not detected in the photo."); + case PAGE_DOESNT_MATCH_DOCUMENT_TYPE_OF_PREVIOUS_PAGE -> rejectionReasons.add("Mismatched document pages types."); + default -> rejectionReasons.add("Unknown error: %s".formatted(response.getErrorCode().getValue())); + } + } + + if (!CollectionUtils.isEmpty(response.getWarnings())) { + for (CreateDocumentPageResponse.WarningsEnum warning : response.getWarnings()) { + switch (warning) { + case DOCUMENT_TYPE_NOT_RECOGNIZED -> rejectionReasons.add("Document type not recognized."); + default -> rejectionReasons.add("Unknown warning: %s".formatted(warning.getValue())); + } + } + } + + if (!rejectionReasons.isEmpty()) { + result.setRejectReason(serializeToString(rejectionReasons)); + } + + return result; + } + + /** + * Gets all customer data extracted from uploaded documents in a JSON form. + * @param customerId id of the customer. + * @param ownerId owner identification. + * @return JSON serialized data. + * @throws RemoteCommunicationException in case of the remote service error. + * @throws DocumentVerificationException if the returned data could not be provided. + */ + private String getExtractedData(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException, DocumentVerificationException { + return serializeToString(innovatricsApiService.getCustomer(customerId, ownerId)); + } + + /** + * Checks if a document portrait of the customer is available. + * @param customerId id of the customer. + * @param ownerId owner identification. + * @return true if document portrait is available, false otherwise. + */ + private boolean hasDocumentPortrait(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + return innovatricsApiService.getDocumentPortrait(customerId, ownerId).isPresent(); + } + + /** + * Creates DocumentSubmitResult containing extracted data. + * @param customerId id of the customer to get data from. + * @return DocumentSubmitResult containing extracted data. + */ + private static DocumentSubmitResult createSubmitResult(final String customerId, final SubmittedDocument submitted) { + final DocumentSubmitResult result = new DocumentSubmitResult(); + result.setUploadId(customerId); + result.setDocumentId(submitted.getDocumentId()); + result.setExtractedData(DocumentSubmitResult.NO_DATA_EXTRACTED); + return result; + } + + /** + * Gets document inspection from Innovatrics and parses it to the verification result. + * @param customerId id of the customer the document belongs to. + * @param ownerId owner identification. + * @return DocumentVerificationResult + */ + private DocumentVerificationResult createVerificationResult(String customerId, OwnerId ownerId) throws DocumentVerificationException, RemoteCommunicationException { + final DocumentInspectResponse response = innovatricsApiService.inspectDocument(customerId, ownerId); + + final List rejectionReasons = new ArrayList<>(); + if (Boolean.TRUE.equals(response.getExpired())) { + rejectionReasons.add("Document expired."); + } + + if (response.getMrzInspection() != null && !Boolean.TRUE.equals(response.getMrzInspection().getValid())) { + rejectionReasons.add("MRZ does not conform the ICAO specification."); + } + + final VisualZoneInspection viz = response.getVisualZoneInspection(); + rejectionReasons.addAll(parseVisualZoneInspection(viz)); + + if (response.getPageTampering() != null) { + response.getPageTampering().forEach((side, inspection) -> { + if (Boolean.TRUE.equals(inspection.getColorProfileChangeDetected())) { + rejectionReasons.add("Colors on the document %s does not corresponds to the expected color profile.".formatted(side)); + } + if (Boolean.TRUE.equals(inspection.getLooksLikeScreenshot())) { + rejectionReasons.add("Provided image of the document %s was taken from a screen of another device.".formatted(side)); + } + if (Boolean.TRUE.equals(inspection.getTamperedTexts())) { + rejectionReasons.add("Text of the document %s is tampered.".formatted(side)); + } + }); + } + + final DocumentVerificationResult result = new DocumentVerificationResult(); + result.setUploadId(customerId); + result.setVerificationResult(serializeToString(response)); + if (!rejectionReasons.isEmpty()) { + result.setRejectReason(serializeToString(rejectionReasons)); + } + return result; + } + + /** + * Parse VisualZoneInspection of a document provided by Innovatrics. + * @param visualZoneInspection inspection of a document by Innovatrics. + * @return List of reasons to reject the document. + */ + private List parseVisualZoneInspection(final VisualZoneInspection visualZoneInspection) { + final List rejectionReasons = new ArrayList<>(); + if (visualZoneInspection == null) { + return rejectionReasons; + } + + // Contains fields with a ocr confidence lower than ocr-text-field-threshold settings. + final List lowOcrConfidenceAttributes = visualZoneInspection.getOcrConfidence().getLowOcrConfidenceTexts(); + if (!CollectionUtils.isEmpty(lowOcrConfidenceAttributes)) { + rejectionReasons.add("Low OCR confidence of attributes: %s".formatted(lowOcrConfidenceAttributes)); + } + + final TextConsistency textConsistency = visualZoneInspection.getTextConsistency(); + if (textConsistency == null) { + return rejectionReasons; + } + + final TextConsistentWith textConsistentWith = textConsistency.getConsistencyWith(); + if (textConsistentWith == null) { + return rejectionReasons; + } + + final MrzConsistency mrzConsistency = textConsistentWith.getMrz(); + if (mrzConsistency != null) { + final List inconsistentAttributes = mrzConsistency.getInconsistentTexts(); + if (!inconsistentAttributes.isEmpty()) { + rejectionReasons.add("Inconsistent attributes with MRZ: %s".formatted(inconsistentAttributes)); + } + } + + final BarcodesConsistency barcodesConsistency = textConsistentWith.getBarcodes(); + if (barcodesConsistency != null) { + final List inconsistentAttributes = barcodesConsistency.getInconsistentTexts(); + if (!inconsistentAttributes.isEmpty()) { + rejectionReasons.add("Inconsistent attributes with barcode: %s".formatted(inconsistentAttributes)); + } + } + + return rejectionReasons; + } + + private String serializeToString(T src) throws DocumentVerificationException { + try { + return objectMapper.writeValueAsString(src); + } catch (JsonProcessingException e) { + throw new DocumentVerificationException("Unexpected error when serializing data", e); + } + } + + private T deserializeFromString(String src) throws DocumentVerificationException { + try { + return objectMapper.readValue(src, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + throw new DocumentVerificationException("Unexpected error when deserializing data", e); + } + } + +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessController.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessController.java new file mode 100644 index 000000000..cdc3ab460 --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessController.java @@ -0,0 +1,93 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.wultra.app.onboardingserver.common.errorhandling.IdentityVerificationException; +import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; +import io.getlime.core.rest.model.base.response.Response; +import io.getlime.security.powerauth.crypto.lib.enums.PowerAuthSignatureTypes; +import io.getlime.security.powerauth.rest.api.spring.annotation.EncryptedRequestBody; +import io.getlime.security.powerauth.rest.api.spring.annotation.PowerAuth; +import io.getlime.security.powerauth.rest.api.spring.annotation.PowerAuthEncryption; +import io.getlime.security.powerauth.rest.api.spring.authentication.PowerAuthApiAuthentication; +import io.getlime.security.powerauth.rest.api.spring.encryption.EncryptionContext; +import io.getlime.security.powerauth.rest.api.spring.encryption.EncryptionScope; +import io.getlime.security.powerauth.rest.api.spring.exception.PowerAuthAuthenticationException; +import io.getlime.security.powerauth.rest.api.spring.exception.PowerAuthEncryptionException; +import io.getlime.security.powerauth.rest.api.spring.exception.authentication.PowerAuthTokenInvalidException; +import io.swagger.v3.oas.annotations.Parameter; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + + +/** + * Controller publishing REST services for uploading Innovatrics liveness data. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@ConditionalOnExpression(""" + '${enrollment-server-onboarding.presence-check.provider}' == 'innovatrics' and ${enrollment-server-onboarding.onboarding-process.enabled} == true + """) +@RestController +@RequestMapping(value = "api/identity") +@AllArgsConstructor +@Slf4j +class InnovatricsLivenessController { + + private InnovatricsLivenessService innovatricsLivenessService; + + /** + * Upload Innovatrics liveness data. + * + * @param requestData Binary request data + * @param encryptionContext Encryption context. + * @param apiAuthentication PowerAuth authentication. + * @return Presence check initialization response. + * @throws PowerAuthAuthenticationException Thrown when request authentication fails. + * @throws PowerAuthEncryptionException Thrown when request decryption fails. + * @throws IdentityVerificationException Thrown when identity verification is invalid. + * @throws RemoteCommunicationException Thrown when there is a problem with the remote communication. + */ + @PostMapping("presence-check/upload") + @PowerAuthEncryption(scope = EncryptionScope.ACTIVATION_SCOPE) + @PowerAuth(resourceId = "/api/identity/presence-check/upload", signatureType = PowerAuthSignatureTypes.POSSESSION) + public Response upload( + @EncryptedRequestBody byte[] requestData, + @Parameter(hidden = true) EncryptionContext encryptionContext, + @Parameter(hidden = true) PowerAuthApiAuthentication apiAuthentication) throws IdentityVerificationException, PowerAuthAuthenticationException, PowerAuthEncryptionException, RemoteCommunicationException { + + if (apiAuthentication == null) { + throw new PowerAuthTokenInvalidException("Unable to verify device registration when uploading liveness"); + } + + if (encryptionContext == null) { + throw new PowerAuthEncryptionException("ECIES encryption failed when uploading liveness"); + } + + if (requestData == null) { + throw new PowerAuthEncryptionException("Invalid request received when uploading liveness"); + } + + innovatricsLivenessService.upload(requestData, encryptionContext); + return new Response(); + } +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessService.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessService.java new file mode 100644 index 000000000..8b97aa52e --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessService.java @@ -0,0 +1,133 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Strings; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import com.wultra.app.onboardingserver.common.database.IdentityVerificationRepository; +import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity; +import com.wultra.app.onboardingserver.common.errorhandling.IdentityVerificationException; +import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; +import com.wultra.app.onboardingserver.common.service.AuditService; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CreateCustomerLivenessRecordResponse; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CreateSelfieResponse; +import io.getlime.security.powerauth.rest.api.spring.encryption.EncryptionContext; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Service providing Innovatrics business features beyond {@link InnovatricsPresenceCheckProvider}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Service +@Transactional(readOnly = true) +@Slf4j +@AllArgsConstructor +@ConditionalOnExpression(""" + '${enrollment-server-onboarding.presence-check.provider}' == 'innovatrics' and ${enrollment-server-onboarding.onboarding-process.enabled} == true + """) +class InnovatricsLivenessService { + + private final InnovatricsApiService innovatricsApiService; + + private final IdentityVerificationRepository identityVerificationRepository; + + private AuditService auditService; + + public void upload(final byte[] requestData, final EncryptionContext encryptionContext) throws IdentityVerificationException, RemoteCommunicationException { + final String activationId = encryptionContext.getActivationId(); + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findFirstByActivationIdOrderByTimestampCreatedDesc(activationId).orElseThrow(() -> + new IdentityVerificationException("No identity verification entity found for Activation ID: " + activationId)); + + final OwnerId ownerId = extractOwnerId(identityVerification); + final String customerId = fetchCustomerId(ownerId, identityVerification); + + createLiveness(customerId, ownerId); + final CreateCustomerLivenessRecordResponse livenessRecordResponse = createLivenessRecord(requestData, customerId, ownerId); + createSelfie(livenessRecordResponse, customerId, ownerId); + + auditService.auditPresenceCheckProvider(identityVerification, "Uploaded presence check data for user: {}", ownerId.getUserId()); + logger.info("Liveness record successfully uploaded, {}", ownerId); + } + + private void createSelfie(final CreateCustomerLivenessRecordResponse livenessRecordResponse, final String customerId, final OwnerId ownerId) throws IdentityVerificationException, RemoteCommunicationException { + final String livenessSelfieLink = fetchSelfieLink(livenessRecordResponse); + final CreateSelfieResponse createSelfieResponse = innovatricsApiService.createSelfie(customerId, livenessSelfieLink, ownerId); + if (createSelfieResponse.getErrorCode() != null) { + logger.warn("Customer selfie error: {}, {}", createSelfieResponse.getErrorCode(), ownerId); + } + if (createSelfieResponse.getWarnings() != null) { + for (CreateSelfieResponse.WarningsEnum warning : createSelfieResponse.getWarnings()) { + logger.warn("Customer selfie warning: {}, {}", warning.getValue(), ownerId); + } + } + logger.debug("Selfie created, {}", ownerId); + } + + private CreateCustomerLivenessRecordResponse createLivenessRecord(final byte[] requestData, final String customerId, final OwnerId ownerId) throws RemoteCommunicationException, IdentityVerificationException { + final CreateCustomerLivenessRecordResponse livenessRecordResponse = innovatricsApiService.createLivenessRecord(customerId, requestData, ownerId); + if (livenessRecordResponse.getErrorCode() != null) { + throw new IdentityVerificationException("Unable to create liveness record: " + livenessRecordResponse.getErrorCode()); + } + logger.debug("Liveness record created, {}", ownerId); + return livenessRecordResponse; + } + + private void createLiveness(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException { + innovatricsApiService.createLiveness(customerId, ownerId); + logger.debug("Liveness created, {}", ownerId); + } + + private static String fetchSelfieLink(final CreateCustomerLivenessRecordResponse livenessRecordResponse) throws IdentityVerificationException { + if (livenessRecordResponse.getLinks() == null) { + throw new IdentityVerificationException("Unable to get selfie link"); + } + return livenessRecordResponse.getLinks().getSelfie(); + } + + private static OwnerId extractOwnerId(final IdentityVerificationEntity identityVerification) { + final OwnerId ownerId = new OwnerId(); + ownerId.setActivationId(identityVerification.getActivationId()); + ownerId.setUserId(identityVerification.getUserId()); + return ownerId; + } + + private static String fetchCustomerId(final OwnerId id, final IdentityVerificationEntity identityVerification) throws IdentityVerificationException { + final String sessionInfoString = StringUtils.defaultIfEmpty(identityVerification.getSessionInfo(), "{}"); + final SessionInfo sessionInfo; + try { + sessionInfo = new ObjectMapper().readValue(sessionInfoString, SessionInfo.class); + } catch (JsonProcessingException e) { + throw new IdentityVerificationException("Unable to deserialize session info", e); + } + + final String customerId = (String) sessionInfo.getSessionAttributes().get(SessionInfo.ATTRIBUTE_PRIMARY_DOCUMENT_REFERENCE); + if (Strings.isNullOrEmpty(customerId)) { + throw new IdentityVerificationException("Missing a customer ID value for calling Innovatrics, " + id); + } + return customerId; + } +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProvider.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProvider.java new file mode 100644 index 000000000..007c06c6a --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProvider.java @@ -0,0 +1,168 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.google.common.base.Strings; +import com.wultra.app.enrollmentserver.model.enumeration.PresenceCheckStatus; +import com.wultra.app.enrollmentserver.model.integration.Image; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.model.integration.PresenceCheckResult; +import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import com.wultra.app.onboardingserver.api.errorhandling.PresenceCheckException; +import com.wultra.app.onboardingserver.api.provider.PresenceCheckProvider; +import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CustomerInspectResponse; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.EvaluateCustomerLivenessResponse; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.SelfieSimilarityWith; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.Locale; +import java.util.Optional; + +/** + * Implementation of the {@link PresenceCheckProvider} with Innovatrics. + * + * @author Jan Pesek, jan.pesek@wultra.com + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@ConditionalOnProperty(value = "enrollment-server-onboarding.presence-check.provider", havingValue = "innovatrics") +@Component +@Slf4j +@AllArgsConstructor +class InnovatricsPresenceCheckProvider implements PresenceCheckProvider { + + private final InnovatricsApiService innovatricsApiService; + + private final InnovatricsConfigProps configuration; + + @Override + public void initPresenceCheck(final OwnerId id, final Image photo) { + logger.debug("#initPresenceCheck does nothing for Innovatrics, {}", id); + } + + @Override + public TrustedPhotoSource trustedPhotoSource() { + return TrustedPhotoSource.REFERENCE; + } + + @Override + public SessionInfo startPresenceCheck(final OwnerId id) { + logger.debug("#startPresenceCheck does nothing for Innovatrics, {}", id); + return new SessionInfo(); + } + + @Override + public PresenceCheckResult getResult(final OwnerId id, final SessionInfo sessionInfo) throws PresenceCheckException, RemoteCommunicationException { + final String customerId = fetchCustomerId(id, sessionInfo); + + final Optional evaluateLivenessError = evaluateLiveness(customerId, id); + if (evaluateLivenessError.isPresent()) { + return convert(evaluateLivenessError.get()); + } + logger.debug("Liveness passed, {}", id); + + // do not be afraid of the timing attack, the action is invoked by the state machine, not by the user + final Optional inspectCustomerError = inspectCustomer(customerId, id); + if (inspectCustomerError.isPresent()) { + return convert(inspectCustomerError.get()); + } + logger.debug("Customer inspection passed, {}", id); + + return accepted(); + } + + private static PresenceCheckResult accepted() { + final PresenceCheckResult result = new PresenceCheckResult(); + result.setStatus(PresenceCheckStatus.ACCEPTED); + return result; + } + + private static PresenceCheckResult convert(final PresenceCheckError source) { + final PresenceCheckResult target = new PresenceCheckResult(); + target.setStatus(source.status()); + target.setErrorDetail(source.errorDetail()); + target.setRejectReason(source.rejectReason()); + return target; + } + + private Optional evaluateLiveness(final String customerId, final OwnerId id) throws RemoteCommunicationException { + final EvaluateCustomerLivenessResponse livenessResponse = innovatricsApiService.evaluateLiveness(customerId, id); + final Double score = livenessResponse.getScore(); + final EvaluateCustomerLivenessResponse.ErrorCodeEnum errorCode = livenessResponse.getErrorCode(); + logger.debug("Presence check score: {}, errorCode: {}, {}", score, errorCode, id); + final double scoreThreshold = configuration.getPresenceCheckConfiguration().getScore(); + + if (score == null) { + return fail(errorCode == null ? "Score is null" : errorCode.getValue()); + } else if (score < scoreThreshold) { + return reject(String.format(Locale.ENGLISH, "Score %.3f is bellow the threshold %.3f", score, scoreThreshold)); + } else { + return success(); + } + } + + private Optional inspectCustomer(final String customerId, final OwnerId id) throws RemoteCommunicationException{ + final CustomerInspectResponse customerInspectResponse = innovatricsApiService.inspectCustomer(customerId, id); + + if (customerInspectResponse.getSelfieInspection() == null || customerInspectResponse.getSelfieInspection().getSimilarityWith() == null) { + return fail("Missing selfie inspection payload"); + } + + final SelfieSimilarityWith similarityWith = customerInspectResponse.getSelfieInspection().getSimilarityWith(); + + if (!Boolean.TRUE.equals(similarityWith.getLivenessSelfies())) { + return reject("The person in the selfie does not match a person in each liveness selfie"); + } else if (!Boolean.TRUE.equals(similarityWith.getDocumentPortrait())) { + return reject("The person in the selfie does not match a person in the document portrait"); + } else { + return success(); + } + } + + private static Optional success() { + return Optional.empty(); + } + + private static Optional reject(final String rejectReason) { + return Optional.of(new PresenceCheckError(PresenceCheckStatus.REJECTED, rejectReason, null)); + } + + private static Optional fail(final String errorDetail) { + return Optional.of(new PresenceCheckError(PresenceCheckStatus.FAILED, null, errorDetail)); + } + + private static String fetchCustomerId(final OwnerId id, final SessionInfo sessionInfo) throws PresenceCheckException { + final String customerId = (String) sessionInfo.getSessionAttributes().get(SessionInfo.ATTRIBUTE_PRIMARY_DOCUMENT_REFERENCE); + if (Strings.isNullOrEmpty(customerId)) { + throw new PresenceCheckException("Missing a customer ID value for calling Innovatrics, " + id); + } + return customerId; + } + + @Override + public void cleanupIdentityData(final OwnerId id, final SessionInfo sessionInfo) throws PresenceCheckException, RemoteCommunicationException { + logger.info("Invoked cleanupIdentityData, {}", id); + final String customerId = fetchCustomerId(id, sessionInfo); + innovatricsApiService.deleteCustomer(customerId, id); + } + + record PresenceCheckError(PresenceCheckStatus status, String rejectReason, String errorDetail){} +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/resources/api/api-innovatrics.json b/enrollment-server-onboarding-provider-innovatrics/src/main/resources/api/api-innovatrics.json new file mode 100644 index 000000000..dea3bf779 --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/main/resources/api/api-innovatrics.json @@ -0,0 +1,5653 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Digital Identity Service API", + "version": "1.25.0" + }, + "servers": [ + { + "url": "https://dot.innovatrics.com/identity" + } + ], + "paths": { + "/api/v1/info": { + "get": { + "tags": [ + "Actuator" + ], + "summary": "Get application info", + "operationId": "info", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActuatorInfo" + } + } + } + } + } + } + }, + "/api/v1/health": { + "get": { + "tags": [ + "Actuator" + ], + "summary": "Get application health", + "operationId": "health", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActuatorHealth" + } + } + } + } + } + } + }, + "/api/v1/customers/{id}/selfie": { + "put": { + "tags": [ + "Customer onboarding" + ], + "summary": "Provide customer\u0027s selfie", + "operationId": "createSelfie_1", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "description": "The data bytes as Base64 encoded string.", + "format": "byte" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSelfieRequest" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_BODY\n - INVALID_IMAGE", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict\n\nPossible error codes:\n - CONFLICT", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSelfieResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + }, + "delete": { + "tags": [ + "Customer onboarding" + ], + "summary": "Delete customer\u0027s selfie", + "operationId": "deleteSelfie", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "204": { + "description": "No Content" + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/liveness": { + "put": { + "tags": [ + "Customer onboarding" + ], + "summary": "Create customer\u0027s liveness", + "operationId": "createLiveness", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomerLivenessResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict\n\nPossible error codes:\n - CONFLICT", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + }, + "delete": { + "tags": [ + "Customer onboarding" + ], + "summary": "Delete customer\u0027s liveness", + "operationId": "deleteLiveness", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "204": { + "description": "No Content" + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/document": { + "put": { + "tags": [ + "Customer onboarding" + ], + "summary": "Create customer\u0027s document", + "operationId": "createDocument", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDocumentRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDocumentResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_BODY", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict\n\nPossible error codes:\n - CONFLICT", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + }, + "delete": { + "tags": [ + "Customer onboarding" + ], + "summary": "Delete customer\u0027s document", + "operationId": "deleteDocument", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "204": { + "description": "No Content" + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/document/pages": { + "put": { + "tags": [ + "Customer onboarding" + ], + "summary": "Provide customer\u0027s document page", + "operationId": "createDocumentPage_1", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "description": "The data bytes as Base64 encoded string.", + "format": "byte" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDocumentPageRequest" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_BODY\n - INVALID_IMAGE", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict\n\nPossible error codes:\n - CONFLICT", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDocumentPageResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/faces": { + "post": { + "tags": [ + "Face operations" + ], + "summary": "Create a face from the photo (face detection)", + "operationId": "detect_1", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "description": "The data bytes as Base64 encoded string.", + "format": "byte" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFaceRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFaceResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_REQUEST_BODY\n - INVALID_IMAGE\n - FACE_SIZE_MEMORY_LIMIT", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/faces/{probe_face_id}/similarity": { + "post": { + "tags": [ + "Face operations" + ], + "summary": "Match the probe face to the reference face", + "operationId": "checkSimilarity", + "parameters": [ + { + "name": "probe_face_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FaceSimilarityRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FaceSimilarityResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_REQUEST_BODY\n - INVALID_IMAGE\n - UNSUPPORTED_VERSION_TEMPLATE\n - CORRUPTED_TEMPLATE\n - INCOMPATIBLE_TEMPLATE", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers": { + "post": { + "tags": [ + "Customer onboarding" + ], + "summary": "Create a customer", + "operationId": "createCustomer", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomerResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}": { + "get": { + "tags": [ + "Customer onboarding" + ], + "summary": "Get the customer", + "operationId": "getCustomer", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetCustomerResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + }, + "post": { + "tags": [ + "Customer onboarding" + ], + "summary": "Create a customer with a custom UUIDv4", + "operationId": "createCustomerWithUuid", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomerResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Unprocessable request\n\nPossible error codes:\n - ALREADY_EXISTS", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + }, + "delete": { + "tags": [ + "Customer onboarding" + ], + "summary": "Delete the customer", + "operationId": "deleteCustomer", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "204": { + "description": "No Content" + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/store": { + "post": { + "tags": [ + "Trust Platform" + ], + "summary": "Store customer in the Trust Platform", + "operationId": "storeInTrustPlatform", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerStoreRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_BODY", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict\n\nPossible error codes:\n - CONFLICT", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "204": { + "description": "No Content" + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/liveness/selfies": { + "post": { + "tags": [ + "Customer onboarding" + ], + "summary": "Provide customer\u0027s liveness selfie", + "operationId": "createLivenessSelfie", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomerLivenessSelfieRequest" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_BODY\n - INVALID_IMAGE", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomerLivenessSelfieResponse" + } + } + } + }, + "409": { + "description": "Conflict\n\nPossible error codes:\n - CONFLICT", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/liveness/records": { + "post": { + "tags": [ + "Customer onboarding" + ], + "summary": "Provide customer\u0027s liveness record", + "operationId": "createLivenessRecord", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "description": "The data bytes as Base64 encoded string.", + "format": "byte" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomerLivenessRecordResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_BODY\n - INVALID_IMAGE", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict\n\nPossible error codes:\n - CONFLICT", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/liveness/evaluation": { + "post": { + "tags": [ + "Customer onboarding" + ], + "summary": "Evaluate customer\u0027s liveness", + "operationId": "evaluateLiveness", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvaluateCustomerLivenessRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_BODY", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvaluateCustomerLivenessResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict\n\nPossible error codes:\n - CONFLICT", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/inspect": { + "post": { + "tags": [ + "Customer onboarding" + ], + "summary": "Inspect customer", + "operationId": "inspect", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerInspectResponse" + } + } + } + }, + "409": { + "description": "Conflict\n\nPossible error codes:\n - CONFLICT", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/inspect/disclose": { + "post": { + "tags": [ + "Customer onboarding" + ], + "summary": "Disclose customer inspection", + "operationId": "inspectDisclose", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerInspectDiscloseResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict\n\nPossible error codes:\n - CONFLICT", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/document/inspect": { + "post": { + "tags": [ + "Customer onboarding" + ], + "summary": "Inspect customer\u0027s document", + "operationId": "documentInspect", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentInspectResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict\n\nPossible error codes:\n - CONFLICT", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/document/inspect/disclose": { + "post": { + "tags": [ + "Customer onboarding" + ], + "summary": "Disclose customer\u0027s document inspection", + "operationId": "documentInspectDisclose", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentInspectDiscloseResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict\n\nPossible error codes:\n - CONFLICT", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/metadata": { + "get": { + "tags": [ + "Metadata" + ], + "summary": "Get metadata", + "operationId": "metadata", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentMetadataResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/faces/{id}/quality": { + "get": { + "tags": [ + "Face operations" + ], + "summary": "Get the face\u0027s quality", + "operationId": "checkQuality", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FaceQualityResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/faces/{id}/glasses": { + "get": { + "tags": [ + "Face operations" + ], + "summary": "Check if glasses are present on the face", + "operationId": "checkGlasses", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlassesResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/faces/{id}/face-template": { + "get": { + "tags": [ + "Face operations" + ], + "summary": "Get the face\u0027s template", + "operationId": "createTemplate", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FaceTemplateResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/faces/{id}/face-mask": { + "get": { + "tags": [ + "Face operations" + ], + "summary": "Check if the face is covered by a mask", + "operationId": "checkFaceMask", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FaceMaskResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/faces/{id}/crop": { + "get": { + "tags": [ + "Face operations" + ], + "summary": "Get the face\u0027s crop", + "operationId": "doCrop", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "width", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCrop" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_PARAMETER", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/faces/{id}/crop/removed-background": { + "get": { + "tags": [ + "Face operations" + ], + "summary": "Get the face\u0027s crop with removed background", + "operationId": "doCropRemoveBackground", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "width", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCrop" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_PARAMETER", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/faces/{id}/crop/coordinates": { + "get": { + "tags": [ + "Face operations" + ], + "summary": "Get face\u0027s crop coordinates", + "operationId": "doCropCoordinates", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CropCoordinatesResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/faces/{id}/aspects": { + "get": { + "tags": [ + "Face operations" + ], + "summary": "Get face\u0027s aspects", + "operationId": "evaluateAspects", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FaceAspectsResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/liveness/records/{recordId}/selfie": { + "get": { + "tags": [ + "Customer onboarding" + ], + "summary": "Get customer\u0027s liveness record\u0027s selfie", + "operationId": "customerLivenessRecordSelfie", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "recordId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "width", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCrop" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_PARAMETER", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/document/signature": { + "get": { + "tags": [ + "Customer onboarding" + ], + "summary": "Get customer\u0027s document signature", + "operationId": "documentSignature", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "width", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCrop" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_PARAMETER", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/document/portrait": { + "get": { + "tags": [ + "Customer onboarding" + ], + "summary": "Get customer\u0027s document portrait", + "operationId": "documentPortrait", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "width", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCrop" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_PARAMETER", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/document/pages/{page-type}": { + "get": { + "tags": [ + "Customer onboarding" + ], + "summary": "Get normalized image of the customer\u0027s document page", + "operationId": "documentPageCrop", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page-type", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "width", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCrop" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_PARAMETER", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/document/pages/{page-type}/quality": { + "get": { + "tags": [ + "Customer onboarding" + ], + "summary": "Check quality of the customer\u0027s document page", + "operationId": "documentPageQuality", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page-type", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentPageQuality" + } + } + } + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/document/ghost-portrait": { + "get": { + "tags": [ + "Customer onboarding" + ], + "summary": "Get customer\u0027s document ghost portrait", + "operationId": "documentGhostPortrait", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "width", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCrop" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_PARAMETER", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/customers/{id}/document/fingerprint": { + "get": { + "tags": [ + "Customer onboarding" + ], + "summary": "Get customer\u0027s fingerprint from the document", + "operationId": "documentFingerprint", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "width", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCrop" + } + } + } + }, + "404": { + "description": "Not found\n\nPossible error codes:\n - NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID\n - INVALID_REQUEST_PARAMETER", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api": [] + } + ] + } + }, + "/api/v1/faces/{id}": { + "delete": { + "tags": [ + "Face operations" + ], + "summary": "Delete the face", + "operationId": "deleteFace", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad request\n\nPossible error codes:\n - INVALID_ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error\n\nPossible error codes:\n - UNEXPECTED_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "204": { + "description": "No Content" + } + }, + "security": [ + { + "api": [] + } + ] + } + } + }, + "components": { + "schemas": { + "ActuatorInfo": { + "required": [ + "build", + "iface", + "sam" + ], + "type": "object", + "properties": { + "build": { + "required": [ + "artifact", + "group", + "name", + "version" + ], + "type": "object", + "properties": { + "artifact": { + "type": "string", + "readOnly": true, + "example": "digital-identity-service" + }, + "name": { + "type": "string", + "readOnly": true, + "example": "digital-identity-service" + }, + "group": { + "type": "string", + "readOnly": true, + "example": "com.innovatrics.dot" + }, + "version": { + "type": "string", + "readOnly": true, + "example": "1.0.0" + } + }, + "description": "The application build info", + "readOnly": true + }, + "iface": { + "required": [ + "license", + "version" + ], + "type": "object", + "properties": { + "version": { + "type": "string", + "readOnly": true, + "example": "4.18.0" + }, + "license": { + "required": [ + "day", + "month", + "year" + ], + "type": "object", + "properties": { + "year": { + "type": "string", + "readOnly": true, + "example": "2024" + }, + "month": { + "type": "string", + "readOnly": true, + "example": "3" + }, + "day": { + "type": "string", + "readOnly": true, + "example": "4" + } + }, + "description": "The SAM license info", + "readOnly": true + } + }, + "description": "The IFace info", + "readOnly": true + }, + "sam": { + "required": [ + "version" + ], + "type": "object", + "properties": { + "version": { + "type": "string", + "readOnly": true, + "example": "1.28.1" + } + }, + "description": "The SAM info", + "readOnly": true + } + }, + "readOnly": true + }, + "ActuatorHealth": { + "required": [ + "status" + ], + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "The application health status", + "readOnly": true, + "example": "UP", + "enum": [ + "UP", + "DOWN" + ] + } + }, + "readOnly": true + }, + "ErrorResponse": { + "required": [ + "errorCode", + "errorMessage" + ], + "type": "object", + "properties": { + "errorCode": { + "type": "string", + "description": "Error code", + "readOnly": true, + "enum": [ + "NOT_FOUND", + "INVALID_ID", + "INVALID_IMAGE", + "INVALID_REQUEST_BODY", + "INVALID_REQUEST_PARAMETER", + "INVALID_REQUEST_METHOD", + "CONFLICT", + "UNEXPECTED_ERROR", + "ALREADY_EXISTS", + "FACE_SIZE_MEMORY_LIMIT", + "INCOMPATIBLE_TEMPLATE", + "CORRUPTED_TEMPLATE", + "UNSUPPORTED_VERSION_TEMPLATE" + ] + }, + "errorMessage": { + "type": "string", + "description": "Error detailed description. It\u0027s only informative.", + "readOnly": true + } + }, + "description": "Error response", + "readOnly": true + }, + "CreateSelfieRequest": { + "type": "object", + "properties": { + "image": { + "$ref": "#/components/schemas/Image" + }, + "selfieOrigin": { + "$ref": "#/components/schemas/LivenessSelfieOrigin" + } + }, + "writeOnly": true + }, + "Image": { + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "The data bytes as Base64 encoded string.", + "format": "byte" + }, + "url": { + "type": "string", + "description": "Image\u0027s URL", + "example": "http://localhost/example.png" + } + }, + "description": "Image provided as Base64 encoded string or via URL. Data or URL have to be provided.", + "writeOnly": true + }, + "LivenessSelfieOrigin": { + "required": [ + "link" + ], + "type": "object", + "properties": { + "link": { + "type": "string", + "description": "The liveness selfie origin link", + "example": "/api/v1/customers/65ebc529-50da-43cb-9963-e15fbf524f8e/liveness/records/df0d69e3-9d59-4a8b-82de-510b3950dc39/selfie" + } + }, + "description": "This allows to link the image from liveness to be selfie photo. Do not upload the photo in Image class in this case.", + "writeOnly": true + }, + "CreateSelfieResponse": { + "type": "object", + "properties": { + "detection": { + "$ref": "#/components/schemas/FaceDetection" + }, + "links": { + "$ref": "#/components/schemas/Links" + }, + "errorCode": { + "type": "string", + "description": "The face detection error code", + "readOnly": true, + "enum": [ + "NO_FACE_DETECTED" + ] + }, + "warnings": { + "type": "array", + "description": "The face detection warnings", + "readOnly": true, + "items": { + "type": "string", + "description": "The face detection warnings", + "readOnly": true, + "enum": [ + "MULTIPLE_FACES_DETECTED" + ] + } + } + }, + "readOnly": true + }, + "FaceDetection": { + "required": [ + "confidence", + "faceRectangle" + ], + "type": "object", + "properties": { + "confidence": { + "maximum": 1, + "minimum": 0, + "type": "number", + "description": "The face detection confidence. Values near 1.0 indicates a high confidence a human face was detected.", + "format": "double", + "readOnly": true, + "example": 0.34 + }, + "faceRectangle": { + "$ref": "#/components/schemas/Roi" + } + }, + "description": "Result of the face detection containing coordinates of rectangle where the face was detected and the confidence score there is a face.", + "readOnly": true + }, + "Links": { + "required": [ + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string", + "description": "The resource\u0027s self link.", + "readOnly": true + } + }, + "description": "The resource\u0027s links", + "readOnly": true + }, + "Point": { + "required": [ + "x", + "y" + ], + "type": "object", + "properties": { + "x": { + "type": "integer", + "description": "The x-axis coordinate of the point, in pixels", + "format": "int32", + "readOnly": true, + "example": 10 + }, + "y": { + "type": "integer", + "description": "The y-axis coordinate of the point, in pixels", + "format": "int32", + "readOnly": true, + "example": 20 + } + }, + "description": "The point coordinates.", + "readOnly": true + }, + "Roi": { + "required": [ + "bottomLeft", + "bottomRight", + "topLeft", + "topRight" + ], + "type": "object", + "properties": { + "topLeft": { + "$ref": "#/components/schemas/Point" + }, + "topRight": { + "$ref": "#/components/schemas/Point" + }, + "bottomRight": { + "$ref": "#/components/schemas/Point" + }, + "bottomLeft": { + "$ref": "#/components/schemas/Point" + } + }, + "description": "The region of the interest. The coordinates of the rectangle.", + "readOnly": true + }, + "CreateCustomerLivenessResponse": { + "required": [ + "links" + ], + "type": "object", + "properties": { + "links": { + "$ref": "#/components/schemas/Links" + } + }, + "readOnly": true + }, + "CreateDocumentRequest": { + "type": "object", + "properties": { + "advice": { + "$ref": "#/components/schemas/DocumentAdvice" + }, + "sources": { + "type": "array", + "description": "The document sources. The chosen document sources indicate which part of the document should be processed. For instance, if only MRZ is requested, the document is classified only by MRZ, and only MRZ is recognized and processed.", + "items": { + "type": "string", + "description": "The document sources. The chosen document sources indicate which part of the document should be processed. For instance, if only MRZ is requested, the document is classified only by MRZ, and only MRZ is recognized and processed.", + "enum": [ + "VIZ", + "MRZ", + "BARCODE", + "DOCUMENT_PORTRAIT" + ] + } + } + }, + "writeOnly": true + }, + "DocumentAdvice": { + "type": "object", + "properties": { + "classification": { + "$ref": "#/components/schemas/DocumentClassificationAdvice" + } + }, + "description": "Advice to the OCR document classification process about expected document type in the uploaded image.", + "writeOnly": true + }, + "DocumentClassificationAdvice": { + "type": "object", + "properties": { + "countries": { + "type": "array", + "description": "The list of Alpha-3 ISO 3166 country codes", + "example": [ + "svk", + "cze" + ], + "items": { + "type": "string", + "description": "The list of Alpha-3 ISO 3166 country codes", + "example": "[\"svk\",\"cze\"]" + } + }, + "types": { + "type": "array", + "description": "The list of the identity document types", + "example": [ + "identity-card", + "passport" + ], + "items": { + "type": "string", + "description": "The list of the identity document types", + "example": "[\"identity-card\",\"passport\"]" + } + }, + "editions": { + "type": "array", + "description": "The list of the document editions", + "example": [ + "2008-2019" + ], + "items": { + "type": "string", + "description": "The list of the document editions", + "example": "[\"2008-2019\"]" + } + }, + "machineReadableTravelDocuments": { + "type": "array", + "description": "The list of the MRZ types", + "example": [ + "td1", + "td2", + "td3" + ], + "items": { + "type": "string", + "description": "The list of the MRZ types", + "example": "[\"td1\",\"td2\",\"td3\"]" + } + } + }, + "description": "Advice to the classification process defining the expected document type, issuing country, edition or type of travel document. This is optional input. If not provided, classification will try to match among all supported documents. If provided and document in image is different, an error is returned.", + "writeOnly": true + }, + "CreateDocumentResponse": { + "required": [ + "links" + ], + "type": "object", + "properties": { + "links": { + "$ref": "#/components/schemas/Links" + } + }, + "readOnly": true + }, + "CreateDocumentPageRequest": { + "required": [ + "image" + ], + "type": "object", + "properties": { + "image": { + "$ref": "#/components/schemas/Image" + }, + "advice": { + "$ref": "#/components/schemas/DocumentPageAdvice" + } + }, + "writeOnly": true + }, + "DocumentPageAdvice": { + "type": "object", + "properties": { + "classification": { + "$ref": "#/components/schemas/DocumentPageClassificationAdvice" + } + }, + "description": "The document page processing advice.", + "writeOnly": true + }, + "DocumentPageClassificationAdvice": { + "required": [ + "pageTypes" + ], + "type": "object", + "properties": { + "pageTypes": { + "type": "array", + "description": "The list of the page types", + "example": [ + "front", + "back" + ], + "items": { + "type": "string", + "description": "The list of the page types", + "example": "[\"front\",\"back\"]" + } + } + }, + "description": "Advice to the OCR document classification process about expected document page in the uploaded image. This is optional input. If not provided, classification will try to match both front and back pages. If provided and document page in image is different, an error is returned.", + "writeOnly": true + }, + "CreateDocumentPageResponse": { + "type": "object", + "properties": { + "documentType": { + "$ref": "#/components/schemas/DocumentType" + }, + "pageType": { + "type": "string", + "description": "Document\u0027s page type", + "readOnly": true, + "example": "front" + }, + "detection": { + "$ref": "#/components/schemas/DocumentDetection" + }, + "errorCode": { + "type": "string", + "description": "The document page processing error code", + "readOnly": true, + "enum": [ + "NO_CARD_CORNERS_DETECTED", + "PAGE_DOESNT_MATCH_DOCUMENT_TYPE_OF_PREVIOUS_PAGE" + ] + }, + "warnings": { + "type": "array", + "description": "The document page processing warnings", + "readOnly": true, + "items": { + "type": "string", + "description": "The document page processing warnings", + "readOnly": true, + "enum": [ + "DOCUMENT_TYPE_NOT_RECOGNIZED" + ] + } + }, + "links": { + "$ref": "#/components/schemas/Links" + } + }, + "readOnly": true + }, + "DocumentCoordinates": { + "required": [ + "bottomLeftCorner", + "bottomRightCorner", + "topLeftCorner", + "topRightCorner" + ], + "type": "object", + "properties": { + "topLeftCorner": { + "$ref": "#/components/schemas/Point" + }, + "topRightCorner": { + "$ref": "#/components/schemas/Point" + }, + "bottomLeftCorner": { + "$ref": "#/components/schemas/Point" + }, + "bottomRightCorner": { + "$ref": "#/components/schemas/Point" + } + }, + "description": "The document\u0027s position in the image. The position is defined by document corner\u0027s coordinates.", + "readOnly": true + }, + "DocumentDetection": { + "required": [ + "confidence", + "coordinates" + ], + "type": "object", + "properties": { + "confidence": { + "type": "number", + "description": "The document\u0027s detection confidence.", + "format": "double", + "example": 0.8 + }, + "coordinates": { + "$ref": "#/components/schemas/DocumentCoordinates" + } + }, + "readOnly": true + }, + "DocumentType": { + "type": "object", + "properties": { + "country": { + "type": "string", + "description": "The Alpha-3 ISO 3166 country code", + "readOnly": true, + "example": "svk" + }, + "edition": { + "type": "string", + "description": "The edition", + "readOnly": true, + "example": "2008-2019" + }, + "type": { + "type": "string", + "description": "The identity document type: passport, identity-card,...", + "readOnly": true, + "example": "identity-card" + }, + "machineReadableTravelDocument": { + "type": "string", + "description": "ICAO Machine Readable Travel Document (MRTD) Specification", + "readOnly": true, + "example": "TD1" + } + }, + "description": "Recognized type of document and issuing country, in case it is recognized.", + "readOnly": true + }, + "CreateFaceRequest": { + "type": "object", + "properties": { + "image": { + "$ref": "#/components/schemas/Image" + }, + "detection": { + "$ref": "#/components/schemas/FaceDetectionProperties" + }, + "faceOrigin": { + "$ref": "#/components/schemas/CustomerSelfieOrigin" + } + }, + "writeOnly": true + }, + "CustomerSelfieOrigin": { + "required": [ + "link" + ], + "type": "object", + "properties": { + "link": { + "type": "string", + "description": "The customer selfie origin link", + "example": "/api/v1/customers/65ebc529-50da-43cb-9963-e15fbf524f8e/selfie" + } + }, + "description": "This allows to link the image from customer selfie. Do not upload the photo in Image class in this case and do not set detection properties.", + "writeOnly": true + }, + "FaceDetectionProperties": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "description": "The face detection mode. The `strict` detection detects face but returns error if multiple faces are detected in the image. The `free` detection detects faces in the image and returns the biggest one with warning if there are multiple faces detected. ", + "example": "FREE", + "enum": [ + "FREE", + "STRICT" + ] + }, + "faceSizeRatio": { + "$ref": "#/components/schemas/FaceSizeRatio" + } + }, + "description": "The face detection properties", + "writeOnly": true + }, + "FaceSizeRatio": { + "required": [ + "max", + "min" + ], + "type": "object", + "properties": { + "min": { + "maximum": 1.0, + "exclusiveMaximum": false, + "minimum": 0.0, + "exclusiveMinimum": false, + "type": "number", + "description": "The minimum face size ratio", + "format": "float", + "example": 0.05 + }, + "max": { + "maximum": 1.0, + "exclusiveMaximum": false, + "minimum": 0.0, + "exclusiveMinimum": false, + "type": "number", + "description": "The maximum face size ratio", + "format": "float", + "example": 0.5 + } + }, + "description": "The face size ratio configuration. The minimum have to be less then the maximum.", + "writeOnly": true + }, + "CreateFaceResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The face\u0027s ID", + "readOnly": true + }, + "detection": { + "$ref": "#/components/schemas/FaceDetection" + }, + "links": { + "$ref": "#/components/schemas/Links" + }, + "errorCode": { + "type": "string", + "description": "The face detection error code", + "readOnly": true, + "enum": [ + "MULTIPLE_FACES_DETECTED", + "NO_FACE_DETECTED" + ] + }, + "warnings": { + "type": "array", + "description": "The face detection warnings", + "readOnly": true, + "items": { + "type": "string", + "description": "The face detection warnings", + "readOnly": true, + "enum": [ + "MULTIPLE_FACES_DETECTED" + ] + } + } + }, + "readOnly": true + }, + "FaceSimilarityRequest": { + "type": "object", + "properties": { + "referenceFace": { + "type": "string", + "description": "The reference face", + "example": "/api/v1/faces/ff0d8fb4-be47-4858-b03c-6f21b479c302" + }, + "referenceFaceTemplate": { + "type": "string", + "description": "The data bytes as Base64 encoded string.", + "format": "byte" + } + }, + "writeOnly": true + }, + "FaceSimilarityResponse": { + "required": [ + "score" + ], + "type": "object", + "properties": { + "score": { + "maximum": 1, + "minimum": 0, + "type": "number", + "description": "The face similarity score. The higher score indicates higher similarity of matched faces.", + "format": "double", + "readOnly": true, + "example": 0.83 + } + }, + "readOnly": true + }, + "CreateCustomerResponse": { + "required": [ + "id", + "links" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The customer\u0027s ID", + "readOnly": true + }, + "links": { + "$ref": "#/components/schemas/Links" + } + }, + "readOnly": true + }, + "CustomerStoreRequest": { + "required": [ + "onboardingStatus" + ], + "type": "object", + "properties": { + "externalId": { + "pattern": "^[a-zA-Z\\d._-]{1,64}$", + "type": "string", + "description": "External identifier of the customer (for pairing between Trust Platform and external database), limited to alphanumeric, \u0027.\u0027, \u0027-\u0027 and \u0027_\u0027 characters and maximum length of 64. If not supplied, the UUID of the customer present in the request URL is used instead.", + "writeOnly": true, + "example": "55bd71d3-12cc-4c15-accd-b6dc8702bf3b" + }, + "onboardingStatus": { + "type": "string", + "description": "Onboarding status of the customer to be stored in the Trust Platform. Use the FINISHED status only if you have collected all required data of the customer.", + "writeOnly": true, + "example": "IN_PROGRESS", + "enum": [ + "IN_PROGRESS", + "FINISHED" + ] + } + } + }, + "CreateCustomerLivenessSelfieRequest": { + "required": [ + "assertion" + ], + "type": "object", + "properties": { + "image": { + "$ref": "#/components/schemas/Image" + }, + "selfieOrigin": { + "$ref": "#/components/schemas/SelfieOrigin" + }, + "assertion": { + "type": "string", + "description": "Definition of the active liveness challenge that the customer\u0027s face has to display in the provided selfie photo. E.g. if it is expected that the face is looking to top left corner of the screen, assertion EYE_GAZE_TOP_LEFT has to be used.", + "example": "EYE_GAZE_TOP_LEFT", + "enum": [ + "EYE_GAZE_TOP_LEFT", + "EYE_GAZE_TOP_RIGHT", + "EYE_GAZE_BOTTOM_LEFT", + "EYE_GAZE_BOTTOM_RIGHT", + "SMILE", + "NEUTRAL", + "NONE" + ] + } + }, + "writeOnly": true + }, + "SelfieOrigin": { + "required": [ + "link" + ], + "type": "object", + "properties": { + "link": { + "type": "string", + "description": "The selfie origin link", + "example": "/api/v1/customers/65ebc529-50da-43cb-9963-e15fbf524f8e/selfie" + } + }, + "description": "This allows to link the image already used for customer selfie to be reused as one of the liveness photos, ideal for using the selfie photo for passive liveness. Do not upload the photo in Image class in this case.", + "writeOnly": true + }, + "CreateCustomerLivenessSelfieResponse": { + "type": "object", + "properties": { + "errorCode": { + "type": "string", + "description": "The face detection error code", + "readOnly": true, + "enum": [ + "NO_FACE_DETECTED" + ] + }, + "warnings": { + "type": "array", + "description": "The face detection warnings", + "readOnly": true, + "items": { + "type": "string", + "description": "The face detection warnings", + "readOnly": true, + "enum": [ + "MULTIPLE_FACES_DETECTED", + "LOW_QUALITY" + ] + } + } + }, + "readOnly": true + }, + "CreateCustomerLivenessRecordResponse": { + "type": "object", + "properties": { + "selfie": { + "$ref": "#/components/schemas/SelfieFromLivenessRecord" + }, + "links": { + "$ref": "#/components/schemas/LivenessRecordLinks" + }, + "errorCode": { + "type": "string", + "description": "The face detection error code", + "readOnly": true, + "enum": [ + "INVALID_DATA" + ] + } + } + }, + "LivenessRecordLinks": { + "required": [ + "selfie" + ], + "type": "object", + "properties": { + "selfie": { + "type": "string", + "description": "The resource\u0027s selfie link.", + "readOnly": true + } + }, + "description": "The resource\u0027s links", + "readOnly": true + }, + "SelfieFromLivenessRecord": { + "required": [ + "detection" + ], + "type": "object", + "properties": { + "detection": { + "$ref": "#/components/schemas/FaceDetection" + } + }, + "readOnly": true + }, + "EvaluateCustomerLivenessRequest": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "EYE_GAZE_LIVENESS", + "PASSIVE_LIVENESS", + "SMILE_LIVENESS", + "MAGNIFEYE_LIVENESS" + ] + } + }, + "description": "Definition of the type of liveness evaluation to be performed on the provided photos. Please, read the chapters \"Passive Liveness\" and \"Active Liveness\" of the DOT documentation.", + "writeOnly": true + }, + "EvaluateCustomerLivenessResponse": { + "type": "object", + "properties": { + "score": { + "maximum": 1, + "minimum": 0, + "type": "number", + "description": "The liveness score", + "format": "double", + "readOnly": true, + "example": 0.8 + }, + "errorCode": { + "type": "string", + "description": "The liveness error code", + "readOnly": true, + "example": "INVALID_DATA", + "enum": [ + "NOT_ENOUGH_DATA", + "INVALID_DATA" + ] + } + }, + "description": "Result of the liveness evaluation. Please, read the chapters \"Passive Liveness\" and \"Active Liveness\" of the DOT documentation to evaluate the calculated score.", + "readOnly": true + }, + "CustomerInspectResponse": { + "type": "object", + "properties": { + "selfieInspection": { + "$ref": "#/components/schemas/SelfieInspection" + }, + "security": { + "$ref": "#/components/schemas/SecurityInspection" + }, + "links": { + "$ref": "#/components/schemas/CustomerInspectionLinks" + } + }, + "readOnly": true + }, + "CustomerInspectionLinks": { + "required": [ + "documentInspection" + ], + "type": "object", + "properties": { + "documentInspection": { + "type": "string", + "description": "The document\u0027s inspection link", + "readOnly": true + } + }, + "description": "The customer\u0027s inspection links.", + "readOnly": true + }, + "SecurityInspection": { + "required": [ + "videoInjection" + ], + "type": "object", + "properties": { + "videoInjection": { + "$ref": "#/components/schemas/VideoInjectionInspection" + } + }, + "description": "The customer\u0027s security inspection.", + "readOnly": true + }, + "SelfieAgeDifferenceWith": { + "type": "object", + "properties": { + "documentPortrait": { + "type": "integer", + "description": "The difference in years between the estimated age of a person in the selfie and the estimated age of a person in the document portrait. The age estimated based on the document portrait is adjusted by the age of the document. This attribute is available only if the document\u0027s issue date is available.", + "format": "int32", + "readOnly": true + }, + "dateOfBirth": { + "type": "integer", + "description": "Max difference in years between the estimated age of a person in the selfie and the age extracted from the document\u0027s visual zone and MRZ.", + "format": "int32", + "readOnly": true + } + }, + "description": "Difference of the estimated age in years between the customer\u0027s selfie and other data extracted from ID document.", + "readOnly": true + }, + "SelfieInspection": { + "type": "object", + "properties": { + "similarityWith": { + "$ref": "#/components/schemas/SelfieSimilarityWith" + }, + "genderEstimate": { + "type": "string", + "description": "Estimated gender of the customer in the selfie, represented by: M for male, F for female", + "readOnly": true, + "example": "F" + }, + "genderConsistency": { + "$ref": "#/components/schemas/SelfieInspectionGenderConsistency" + }, + "ageEstimate": { + "type": "integer", + "description": "Estimated age of the customer in the selfie in years.", + "format": "int32", + "readOnly": true, + "example": 32 + }, + "ageDifferenceWith": { + "$ref": "#/components/schemas/SelfieAgeDifferenceWith" + }, + "hasMask": { + "type": "boolean", + "description": "True if a person in the selfie is wearing a face mask", + "readOnly": true, + "example": false + } + }, + "description": "Results of the inspection of customer\u0027s selfie versus document. Please, read the chapter \"Trust Factors\" of the DOT documentation.", + "readOnly": true + }, + "SelfieInspectionGenderConsistency": { + "type": "object", + "properties": { + "documentPortrait": { + "type": "boolean", + "description": "True if selfie\u0027s gender is consistent with gender from the document\u0027s portrait", + "readOnly": true + }, + "viz": { + "type": "boolean", + "description": "True if selfie\u0027s gender is consistent with gender from the document\u0027s visual zone", + "readOnly": true + }, + "mrz": { + "type": "boolean", + "description": "True if selfie\u0027s gender is consistent with gender from the document\u0027s MRZ", + "readOnly": true + } + }, + "description": "Consistency of estimated gender on the selfie with customer\u0027s data extracted from ID document.", + "readOnly": true + }, + "SelfieSimilarityWith": { + "type": "object", + "properties": { + "documentPortrait": { + "type": "boolean", + "description": "True if the person in the selfie matches a person in the document portrait", + "readOnly": true, + "example": true + }, + "livenessSelfies": { + "type": "boolean", + "description": "True if the person in the selfie matches a person in each liveness selfie", + "readOnly": true, + "example": true + } + }, + "description": "Similarity of the face between the selfie, document portrait and the liveness selfies. Please, read the chapter \"Face Matching\" of the DOT documentation.", + "readOnly": true + }, + "VideoInjectionInspection": { + "required": [ + "evaluated" + ], + "type": "object", + "properties": { + "evaluated": { + "type": "boolean", + "description": "True if video injection was evaluated on the customer\u0027s resources.", + "readOnly": true + }, + "detected": { + "type": "boolean", + "description": "True if video injection was detected on the customer\u0027s resources.", + "readOnly": true + } + }, + "description": "The video injection inspection.", + "readOnly": true + }, + "CustomerInspectDiscloseResponse": { + "type": "object", + "properties": { + "selfieInspection": { + "$ref": "#/components/schemas/SelfieInspectionDisclose" + } + }, + "readOnly": true + }, + "DocumentPortraitDisclose": { + "required": [ + "score" + ], + "type": "object", + "properties": { + "score": { + "type": "number", + "description": "The resulting score of the similarity between the user\u0027s selfie and the document portrait.", + "format": "float", + "readOnly": true, + "example": 0.955 + } + }, + "description": "Result of the document portrait similarity check.", + "readOnly": true + }, + "SelfieInspectionDisclose": { + "type": "object", + "properties": { + "similarityWith": { + "$ref": "#/components/schemas/SelfieSimilarityWithDisclose" + } + }, + "readOnly": true + }, + "SelfieSimilarityWithDisclose": { + "type": "object", + "properties": { + "documentPortrait": { + "$ref": "#/components/schemas/DocumentPortraitDisclose" + } + }, + "description": "Similarity of the face between the selfie and the document portrait.", + "readOnly": true + }, + "AgeDifferenceWith": { + "required": [ + "dateOfBirth" + ], + "type": "object", + "properties": { + "dateOfBirth": { + "type": "integer", + "description": "Max difference with MRZ and VIZ birth date", + "format": "int32", + "readOnly": true, + "example": 7 + } + }, + "description": "Difference of age with particular fields. Only available if a difference has been detected", + "readOnly": true + }, + "BarcodesConsistency": { + "required": [ + "inconsistentTexts" + ], + "type": "object", + "properties": { + "inconsistentTexts": { + "type": "array", + "description": "All recognized text fields of the visual zone those are inconsistent with barcodes. It works only for unencrypted barcodes that represent text in the same format as in the visual zone.", + "readOnly": true, + "items": { + "type": "string", + "description": "All recognized text fields of the visual zone those are inconsistent with barcodes. It works only for unencrypted barcodes that represent text in the same format as in the visual zone.", + "readOnly": true + } + } + }, + "description": "The visual zone text consistency with document barcodes", + "readOnly": true + }, + "DocumentInspectResponse": { + "type": "object", + "properties": { + "expired": { + "type": "boolean", + "description": "True if the document is expired at the time of the customer\u0027s on-boarding. This attribute is available only if the document\u0027s expiry date is available.", + "readOnly": true, + "example": false + }, + "mrzInspection": { + "$ref": "#/components/schemas/MrzInspection" + }, + "portraitInspection": { + "$ref": "#/components/schemas/PortraitInspection" + }, + "visualZoneInspection": { + "$ref": "#/components/schemas/VisualZoneInspection" + }, + "pageTampering": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/PageTamperingInspection" + }, + "readOnly": true + } + }, + "description": "Results of the inspection of customer\u0027s document. Please, read the chapter \"ID Document Authenticity Evaluation\" of the DOT documentation.", + "readOnly": true + }, + "MrzConsistency": { + "required": [ + "inconsistentTexts" + ], + "type": "object", + "properties": { + "inconsistentTexts": { + "type": "array", + "description": "All recognized text fields of visual zone that are inconsistent with MRZ.", + "readOnly": true, + "items": { + "type": "string", + "description": "All recognized text fields of visual zone that are inconsistent with MRZ.", + "readOnly": true + } + } + }, + "description": "The visual zone text consistency with document MRZ", + "readOnly": true + }, + "MrzInspection": { + "required": [ + "valid" + ], + "type": "object", + "properties": { + "valid": { + "type": "boolean", + "description": "True if the document\u0027s MRZ is valid. A valid MRZ has to match the specification and its checksums have to be correct.", + "readOnly": true, + "example": true + } + }, + "description": "Result of inspection of MRZ zone on the document, if present. Please, read the chapter \"Machine Readable Zone\" of the DOT documentation.", + "readOnly": true + }, + "OcrConfidence": { + "required": [ + "confidence" + ], + "type": "object", + "properties": { + "confidence": { + "type": "number", + "description": "The median of all recognized text fields OCR confidences.", + "format": "double", + "readOnly": true, + "example": 0.98 + }, + "lowOcrConfidenceTexts": { + "type": "array", + "description": "All recognized text fields that have OCR confidence below the configurable threshold.", + "readOnly": true, + "items": { + "type": "string", + "description": "All recognized text fields that have OCR confidence below the configurable threshold.", + "readOnly": true + } + } + }, + "description": "Confidence of the OCR recognition accuracy of the text fields in the visual inspection zone of the document.", + "readOnly": true + }, + "PageTamperingInspection": { + "type": "object", + "properties": { + "colorProfileChangeDetected": { + "type": "boolean", + "description": "True if there is a significant change in color profile of the submitted document against a supported document (e.g. the supplied photo is in greyscale)", + "readOnly": true, + "example": false + }, + "looksLikeScreenshot": { + "type": "boolean", + "description": "True if the submitted document was detected to be photographed from a screen", + "readOnly": true, + "example": false + }, + "tamperedTexts": { + "type": "boolean", + "description": "True if the submitted document shows signs of text manipulation", + "readOnly": true, + "example": false + } + }, + "description": "Result of the visual detection indicating possibly fraudulent documents based on the appearance of the image.", + "readOnly": true + }, + "PortraitInspection": { + "type": "object", + "properties": { + "genderEstimate": { + "type": "string", + "description": "Gender estimate from the document portrait", + "readOnly": true, + "example": "M" + }, + "genderConsistency": { + "$ref": "#/components/schemas/PortraitInspectionGenderConsistency" + }, + "ageEstimate": { + "type": "integer", + "description": "Estimation of age from the document portrait", + "format": "int32", + "readOnly": true, + "example": 42 + }, + "ageDifferenceWith": { + "$ref": "#/components/schemas/AgeDifferenceWith" + } + }, + "description": "Result of document portrait inspection, which checks estimated age and gender against other data on the document. Only available if the document portrait is available", + "readOnly": true + }, + "PortraitInspectionGenderConsistency": { + "type": "object", + "properties": { + "viz": { + "type": "boolean", + "description": "True if the gender is consistent with the visual zone. Only available if the visual zone of the document has been processed.", + "readOnly": true, + "example": true + }, + "mrz": { + "type": "boolean", + "description": "True if the gender is consistent with the MRZ. Only available if the document\u0027s MRZ is available", + "readOnly": true, + "example": true + } + }, + "description": "Gender consistency between document portrait and document data. Only available if MRZ and VIZ are available for comparison", + "readOnly": true + }, + "TextConsistency": { + "required": [ + "consistent" + ], + "type": "object", + "properties": { + "consistent": { + "type": "boolean", + "description": "True if the document\u0027s VIZ is consistent with other document data.", + "readOnly": true, + "example": true + }, + "consistencyWith": { + "$ref": "#/components/schemas/TextConsistentWith" + } + }, + "description": "Cross-check of the text fields extracted with OCR recognition from the visual inspection zone against the texts extracted from other sources in the document.", + "readOnly": true + }, + "TextConsistentWith": { + "type": "object", + "properties": { + "mrz": { + "$ref": "#/components/schemas/MrzConsistency" + }, + "barcodes": { + "$ref": "#/components/schemas/BarcodesConsistency" + } + }, + "description": "The visual zone text consistency with other document data", + "readOnly": true + }, + "VisualZoneInspection": { + "required": [ + "ocrConfidence" + ], + "type": "object", + "properties": { + "ocrConfidence": { + "$ref": "#/components/schemas/OcrConfidence" + }, + "textConsistency": { + "$ref": "#/components/schemas/TextConsistency" + } + }, + "description": "The document visual zone inspection result", + "readOnly": true + }, + "ColorProfileChangeDetectedDisclose": { + "required": [ + "score" + ], + "type": "object", + "properties": { + "score": { + "type": "number", + "description": "The resulting score of the color profile check representing difference between the submitted document and the reference documents (e.g. the supplied photo is in greyscale). The score is normalized to the interval from 0 to 1. Lower the score, higher the color difference between documents.", + "format": "float", + "readOnly": true, + "example": 0.2345 + } + }, + "description": "Result of the color profile difference check.", + "readOnly": true + }, + "DocumentInspectDiscloseResponse": { + "type": "object", + "properties": { + "pageTampering": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/PageTamperingInspectionDisclose" + }, + "readOnly": true + } + }, + "readOnly": true + }, + "DocumentPortraitGenuineDisclose": { + "required": [ + "score" + ], + "type": "object", + "properties": { + "score": { + "type": "number", + "description": "The resulting score of the document portrait genuine check on the submitted document. The score is normalized to the interval from 0 to 1. Higher the score, higher the probability of the document portrait being genuine.", + "format": "float", + "readOnly": true, + "example": 0.2165 + } + }, + "description": "Result of the document portrait genuine check.", + "readOnly": true + }, + "LooksLikeScreenshotDisclose": { + "required": [ + "score" + ], + "type": "object", + "properties": { + "score": { + "type": "number", + "description": "The resulting score of the screenshot detection on the submitted document. The score is normalized to the interval from 0 to 1. Lower the score, higher the probability of the document being photographed from the screen.", + "format": "double", + "readOnly": true, + "example": 0.1455 + } + }, + "description": "Result of the screenshot detection check.", + "readOnly": true + }, + "PageTamperingInspectionDisclose": { + "type": "object", + "properties": { + "colorProfileChangeDetected": { + "$ref": "#/components/schemas/ColorProfileChangeDetectedDisclose" + }, + "looksLikeScreenshot": { + "$ref": "#/components/schemas/LooksLikeScreenshotDisclose" + }, + "documentPortraitGenuine": { + "$ref": "#/components/schemas/DocumentPortraitGenuineDisclose" + }, + "tamperedTexts": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/TamperedTextDetectionScore" + }, + "readOnly": true + } + }, + "description": "Result of the visual detection indicating possibly fraudulent documents based on the appearance of the image.", + "readOnly": true + }, + "TamperedTextDetectionScore": { + "required": [ + "score" + ], + "type": "object", + "properties": { + "score": { + "type": "number", + "description": "The resulting score of the tampered text check for text field on the submitted document. The score is normalized to the interval from 0 to 1. Higher the score, higher the probability of the document texts being genuine.", + "format": "float", + "readOnly": true, + "example": 0.2165 + } + }, + "description": "Result of the document tampered text check.", + "readOnly": true + }, + "Document": { + "title": "MetadataDocument", + "required": [ + "documentType", + "pages" + ], + "type": "object", + "properties": { + "documentType": { + "$ref": "#/components/schemas/DocumentType" + }, + "pages": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/PageMetadata" + }, + "description": "The map of document page\u0027s metadata. The map keys are page types.", + "readOnly": true + } + }, + "description": "Document metadata", + "readOnly": true + }, + "DocumentMetadataResponse": { + "required": [ + "documents" + ], + "type": "object", + "properties": { + "documents": { + "type": "array", + "description": "The list of document\u0027s metadata", + "readOnly": true, + "items": { + "$ref": "#/components/schemas/Document" + } + } + }, + "description": "Document metadata response", + "readOnly": true + }, + "PageMetadata": { + "required": [ + "visualZone" + ], + "type": "object", + "properties": { + "portrait": { + "$ref": "#/components/schemas/Portrait" + }, + "visualZone": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/TextField" + }, + "description": "The map of text field\u0027s metadata. The map keys are text field types.", + "readOnly": true + } + }, + "description": "Document page metadata", + "readOnly": true + }, + "Portrait": { + "title": "MetadataPortrait", + "required": [ + "present" + ], + "type": "object", + "properties": { + "present": { + "type": "boolean", + "description": "True if the document portrait image field is present", + "readOnly": true + } + }, + "description": "Portrait presence", + "readOnly": true + }, + "TextField": { + "title": "MetadataTextField", + "required": [ + "valueNormalized" + ], + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Label printed on card (if present)", + "readOnly": true, + "example": "Surname" + }, + "valueNormalized": { + "type": "boolean", + "description": "True if the value is being normalized", + "readOnly": true + } + }, + "description": "Text field metadata", + "readOnly": true + }, + "FaceAttribute": { + "required": [ + "preconditionsMet", + "score" + ], + "type": "object", + "properties": { + "score": { + "maximum": 1, + "minimum": 0, + "type": "number", + "description": "The face\u0027s attribute score", + "format": "double", + "readOnly": true, + "example": 0.24 + }, + "preconditionsMet": { + "type": "boolean", + "description": "The flag indicates if the face\u0027s attribute score is reliable.", + "readOnly": true, + "example": true + } + }, + "description": "The face\u0027s attribute", + "readOnly": true + }, + "FaceQualityResponse": { + "type": "object", + "properties": { + "sharpness": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "brightness": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "contrast": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "uniqueIntensityLevels": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "shadow": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "noseShadow": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "specularity": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "backgroundUniformity": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "redRightEye": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "redLeftEye": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "roll": { + "$ref": "#/components/schemas/HeadPoseAttribute" + }, + "yaw": { + "$ref": "#/components/schemas/HeadPoseAttribute" + }, + "pitch": { + "$ref": "#/components/schemas/HeadPoseAttribute" + }, + "eyeDistance": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "eyeGaze": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "rightEye": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "leftEye": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "mouth": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "faceSize": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "faceRelativeArea": { + "$ref": "#/components/schemas/FaceAttribute" + }, + "faceRelativeAreaInImage": { + "$ref": "#/components/schemas/FaceAttribute" + } + }, + "readOnly": true + }, + "HeadPoseAttribute": { + "required": [ + "angle", + "preconditionsMet" + ], + "type": "object", + "properties": { + "angle": { + "type": "integer", + "description": "The head pose angle", + "format": "int32", + "readOnly": true, + "example": 25 + }, + "preconditionsMet": { + "type": "boolean", + "description": "The flag indicates if the face\u0027s attribute score is reliable.", + "readOnly": true, + "example": true + } + }, + "readOnly": true + }, + "GlassesResponse": { + "type": "object", + "properties": { + "score": { + "maximum": 1, + "minimum": 0, + "type": "number", + "description": "The glasses score", + "format": "double", + "readOnly": true, + "example": 0.83 + }, + "tinted": { + "maximum": 1, + "minimum": 0, + "type": "number", + "description": "The tinted glasses score", + "format": "double", + "readOnly": true, + "example": 0.83 + }, + "heavyFrame": { + "maximum": 1, + "minimum": 0, + "type": "number", + "description": "The glasses with heavy frame score", + "format": "double", + "readOnly": true, + "example": 0.83 + } + }, + "readOnly": true + }, + "FaceTemplateResponse": { + "required": [ + "data", + "version" + ], + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "The data bytes as Base64 encoded string.", + "format": "byte", + "readOnly": true + }, + "version": { + "type": "string", + "description": "The template version", + "readOnly": true, + "example": "1.13" + } + }, + "readOnly": true + }, + "FaceMaskResponse": { + "required": [ + "score" + ], + "type": "object", + "properties": { + "score": { + "maximum": 1, + "minimum": 0, + "type": "number", + "description": "The face mask score", + "format": "double", + "readOnly": true, + "example": 0.83 + } + }, + "description": "The face mask", + "readOnly": true + }, + "ImageCrop": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "The data bytes as Base64 encoded string.", + "format": "byte", + "readOnly": true + } + }, + "description": "The image crop.", + "readOnly": true + }, + "CropCoordinatesResponse": { + "required": [ + "coordinates", + "fullyCaptured" + ], + "type": "object", + "properties": { + "fullyCaptured": { + "type": "boolean", + "description": "The flag if whole face is captured in the input image", + "readOnly": true, + "example": true + }, + "coordinates": { + "$ref": "#/components/schemas/Roi" + } + }, + "readOnly": true + }, + "FaceAspectsResponse": { + "required": [ + "age", + "gender" + ], + "type": "object", + "properties": { + "age": { + "type": "integer", + "description": "The age of the face", + "format": "int32", + "readOnly": true, + "example": 24 + }, + "gender": { + "type": "number", + "description": "The gender score of the face", + "format": "double", + "readOnly": true, + "example": 0.5 + } + }, + "readOnly": true + }, + "Barcode": { + "required": [ + "data", + "type" + ], + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "The data bytes as Base64 encoded string.", + "format": "byte", + "readOnly": true + }, + "type": { + "type": "string", + "description": "Barcode type", + "readOnly": true, + "example": "code_39" + } + }, + "description": "Parsed barcode", + "readOnly": true + }, + "BiometricMultiValueAttribute": { + "type": "object", + "properties": { + "visualZone": { + "type": "string", + "description": "The attribute value obtained from the document\u0027s visual zone.", + "readOnly": true + }, + "visualZoneDuplicates": { + "type": "array", + "description": "The attribute is a duplicated field type on the document, the value is obtained from the document\u0027s visual zone.", + "readOnly": true, + "items": { + "type": "string", + "description": "The attribute is a duplicated field type on the document, the value is obtained from the document\u0027s visual zone.", + "readOnly": true + } + }, + "mrz": { + "type": "string", + "description": "The attribute value obtained from the document\u0027s machine readable zone.", + "readOnly": true + }, + "selfie": { + "type": "string", + "description": "The attribute value obtained from the selfie.", + "readOnly": true + }, + "documentPortrait": { + "type": "string", + "description": "The attribute value obtained from the document\u0027s portrait.", + "readOnly": true + } + }, + "description": "Values for the given biometric attribute retrieved from different sources by facial biometry or by OCR.", + "readOnly": true + }, + "Customer": { + "type": "object", + "properties": { + "age": { + "$ref": "#/components/schemas/BiometricMultiValueAttribute" + }, + "gender": { + "$ref": "#/components/schemas/BiometricMultiValueAttribute" + }, + "givenNames": { + "$ref": "#/components/schemas/MultiValueAttribute" + }, + "surname": { + "$ref": "#/components/schemas/MultiValueAttribute" + }, + "fullName": { + "$ref": "#/components/schemas/MultiValueAttribute" + }, + "dateOfBirth": { + "$ref": "#/components/schemas/MultiValueAttribute" + }, + "personalNumber": { + "$ref": "#/components/schemas/MultiValueAttribute" + }, + "citizenship": { + "$ref": "#/components/schemas/MultiValueAttribute" + }, + "nationality": { + "$ref": "#/components/schemas/MultiValueAttribute" + }, + "document": { + "$ref": "#/components/schemas/CustomerDocument" + } + }, + "description": "Details of the customer retrieved by OCR from ID document photo and by facial biometry from the selfie. The date of birth is in format YYYY-MM-DD and the gender is represented by: M for male, F for female, X for undefined.", + "readOnly": true + }, + "CustomerDocument": { + "required": [ + "links", + "pageTypes" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/DocumentType" + }, + "pageTypes": { + "type": "array", + "description": "Document\u0027s page types", + "readOnly": true, + "items": { + "type": "string", + "description": "Document\u0027s page types", + "readOnly": true + } + }, + "dateOfIssue": { + "$ref": "#/components/schemas/MultiValueAttribute" + }, + "dateOfExpiry": { + "$ref": "#/components/schemas/MultiValueAttribute" + }, + "documentNumber": { + "$ref": "#/components/schemas/MultiValueAttribute" + }, + "issuingAuthority": { + "$ref": "#/components/schemas/MultiValueAttribute" + }, + "additionalTexts": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MultiValueAttributeWithoutMrz" + }, + "description": "The map of additional document texts. The map keys are text field types.", + "readOnly": true + }, + "mrz": { + "$ref": "#/components/schemas/Mrz" + }, + "barcodes": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Barcode" + }, + "readOnly": true + }, + "readOnly": true + }, + "links": { + "$ref": "#/components/schemas/CustomerDocumentLinks" + } + }, + "description": "Details of the customer\u0027s document retrieved by OCR from ID document photo. The dates are in format YYYY-MM-DD.", + "readOnly": true + }, + "CustomerDocumentLinks": { + "type": "object", + "properties": { + "portrait": { + "type": "string", + "description": "The document\u0027s portrait link", + "readOnly": true + }, + "ghostPortrait": { + "type": "string", + "description": "The document\u0027s ghost portrait link", + "readOnly": true + }, + "signature": { + "type": "string", + "description": "The document\u0027s signature image link", + "readOnly": true + }, + "fingerprint": { + "type": "string", + "description": "The document\u0027s fingerprint image link", + "readOnly": true + }, + "pages": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "The map of links to the document\u0027s pages. The map keys are page types.", + "readOnly": true + }, + "description": "The map of links to the document\u0027s pages. The map keys are page types.", + "readOnly": true + } + }, + "description": "Links to cropped images from the customer\u0027s document.", + "readOnly": true + }, + "GetCustomerResponse": { + "type": "object", + "properties": { + "customer": { + "$ref": "#/components/schemas/Customer" + } + }, + "readOnly": true + }, + "Mrz": { + "type": "object", + "properties": { + "td1": { + "$ref": "#/components/schemas/Td1Mrz" + }, + "td2": { + "$ref": "#/components/schemas/Td2Mrz" + }, + "td3": { + "$ref": "#/components/schemas/Td3Mrz" + } + }, + "description": "Parsed machine readable zone", + "readOnly": true + }, + "MultiValueAttribute": { + "type": "object", + "properties": { + "visualZone": { + "type": "string", + "description": "The attribute value obtained from the document\u0027s visual zone.", + "readOnly": true + }, + "visualZoneDuplicates": { + "type": "array", + "description": "The attribute is a duplicated field type on the document, the value is obtained from the document\u0027s visual zone.", + "readOnly": true, + "items": { + "type": "string", + "description": "The attribute is a duplicated field type on the document, the value is obtained from the document\u0027s visual zone.", + "readOnly": true + } + }, + "mrz": { + "type": "string", + "description": "The attribute value obtained from the document\u0027s machine readable zone.", + "readOnly": true + }, + "barcode": { + "type": "string", + "description": "The attribute value obtained from the document\u0027s barcode.", + "readOnly": true + } + }, + "description": "Values for the given textual attribute retrieved by OCR from different sources on the document photo.", + "readOnly": true + }, + "MultiValueAttributeWithoutMrz": { + "type": "object", + "properties": { + "visualZone": { + "type": "string", + "description": "The attribute value obtained from the document\u0027s visual zone.", + "readOnly": true + }, + "visualZoneDuplicates": { + "type": "array", + "description": "The attribute is a duplicated field type on the document, the value is obtained from the document\u0027s visual zone.", + "readOnly": true, + "items": { + "type": "string", + "description": "The attribute is a duplicated field type on the document, the value is obtained from the document\u0027s visual zone.", + "readOnly": true + } + }, + "barcode": { + "type": "string", + "description": "The attribute value obtained from the document\u0027s barcode.", + "readOnly": true + } + }, + "description": "Values for the given textual attribute retrieved by OCR from different sources on the document photo.", + "readOnly": true + }, + "Td1Mrz": { + "required": [ + "checkDigitsValidity", + "dateOfBirth", + "dateOfExpiry", + "documentCode", + "documentNumber", + "gender", + "givenNames", + "issuingAuthority", + "nationality", + "surname" + ], + "type": "object", + "properties": { + "documentCode": { + "type": "string", + "description": "Document\u0027s code", + "readOnly": true, + "example": "P" + }, + "issuingAuthority": { + "type": "string", + "description": "Document\u0027s issuing authority", + "readOnly": true, + "example": "SVK" + }, + "surname": { + "type": "string", + "description": "Holder\u0027s surname", + "readOnly": true, + "example": "DOE" + }, + "givenNames": { + "type": "string", + "description": "Holder\u0027s given names", + "readOnly": true, + "example": "JOHN" + }, + "documentNumber": { + "type": "string", + "description": "Document\u0027s number", + "readOnly": true, + "example": "123456789" + }, + "nationality": { + "type": "string", + "description": "Holder\u0027s nationality", + "readOnly": true, + "example": "SVK" + }, + "dateOfBirth": { + "type": "string", + "description": "Holder\u0027s date of the birth, format: `YYMMDD`", + "readOnly": true, + "example": "841102" + }, + "gender": { + "type": "string", + "description": "Holder\u0027s gender, format: `M` for male, `F` for female, empty string for undefined gender.", + "readOnly": true, + "example": "M" + }, + "dateOfExpiry": { + "type": "string", + "description": "Document\u0027s date of the expiration, format: `YYMMDD`", + "readOnly": true, + "example": "261019" + }, + "optionalDataFirstLine": { + "type": "string", + "description": "Optional data - first line", + "readOnly": true, + "example": "2222" + }, + "optionalDataSecondLine": { + "type": "string", + "description": "Optional data - second line", + "readOnly": true, + "example": "2222" + }, + "checkDigitsValidity": { + "$ref": "#/components/schemas/Td1MrzCheckDigitsValidity" + } + }, + "description": "Parsed TD1 machine readable zone", + "readOnly": true + }, + "Td1MrzCheckDigitsValidity": { + "required": [ + "compositeCheckDigitValid", + "dateOfBirthCheckDigitValid", + "dateOfExpiryCheckDigitValid", + "documentNumberCheckDigitValid" + ], + "type": "object", + "properties": { + "documentNumberCheckDigitValid": { + "type": "boolean", + "description": "Check digit\u0027s validity of the document\u0027s number", + "readOnly": true, + "example": true + }, + "dateOfBirthCheckDigitValid": { + "type": "boolean", + "description": "Check digit\u0027s validity of the holder\u0027s date of the birth", + "readOnly": true, + "example": true + }, + "dateOfExpiryCheckDigitValid": { + "type": "boolean", + "description": "Check digit\u0027s validity of the holder\u0027s date of the expiration", + "readOnly": true, + "example": true + }, + "compositeCheckDigitValid": { + "type": "boolean", + "description": "Composite check digit\u0027s validity", + "readOnly": true, + "example": true + } + }, + "description": "Validity of MRZ check digits", + "readOnly": true + }, + "Td2Mrz": { + "required": [ + "checkDigitsValidity", + "dateOfBirth", + "dateOfExpiry", + "documentCode", + "documentNumber", + "gender", + "givenNames", + "issuingAuthority", + "nationality", + "surname" + ], + "type": "object", + "properties": { + "documentCode": { + "type": "string", + "description": "Document\u0027s code", + "readOnly": true, + "example": "P" + }, + "issuingAuthority": { + "type": "string", + "description": "Document\u0027s issuing authority", + "readOnly": true, + "example": "SVK" + }, + "surname": { + "type": "string", + "description": "Holder\u0027s surname", + "readOnly": true, + "example": "DOE" + }, + "givenNames": { + "type": "string", + "description": "Holder\u0027s given names", + "readOnly": true, + "example": "JOHN" + }, + "documentNumber": { + "type": "string", + "description": "Document\u0027s number", + "readOnly": true, + "example": "123456789" + }, + "nationality": { + "type": "string", + "description": "Holder\u0027s nationality", + "readOnly": true, + "example": "SVK" + }, + "dateOfBirth": { + "type": "string", + "description": "Holder\u0027s date of the birth, format: `YYMMDD`", + "readOnly": true, + "example": "841102" + }, + "gender": { + "type": "string", + "description": "Holder\u0027s gender, format: `M` for male, `F` for female, empty string for undefined gender.", + "readOnly": true, + "example": "M" + }, + "dateOfExpiry": { + "type": "string", + "description": "Document\u0027s date of the expiration, format: `YYMMDD`", + "readOnly": true, + "example": "261019" + }, + "optionalDataSecondLine": { + "type": "string", + "description": "Optional data - second line", + "readOnly": true, + "example": "2222" + }, + "checkDigitsValidity": { + "$ref": "#/components/schemas/Td2MrzCheckDigitsValidity" + } + }, + "description": "Parsed TD2 machine readable zone", + "readOnly": true + }, + "Td2MrzCheckDigitsValidity": { + "required": [ + "compositeCheckDigitValid", + "dateOfBirthCheckDigitValid", + "dateOfExpiryCheckDigitValid", + "documentNumberCheckDigitValid" + ], + "type": "object", + "properties": { + "documentNumberCheckDigitValid": { + "type": "boolean", + "description": "Check digit\u0027s validity of the document\u0027s number", + "readOnly": true, + "example": true + }, + "dateOfBirthCheckDigitValid": { + "type": "boolean", + "description": "Check digit\u0027s validity of the holder\u0027s date of the birth", + "readOnly": true, + "example": true + }, + "dateOfExpiryCheckDigitValid": { + "type": "boolean", + "description": "Check digit\u0027s validity of the holder\u0027s date of the expiration", + "readOnly": true, + "example": true + }, + "compositeCheckDigitValid": { + "type": "boolean", + "description": "Composite check digit\u0027s validity", + "readOnly": true, + "example": true + } + }, + "description": "Validity of MRZ check digits", + "readOnly": true + }, + "Td3Mrz": { + "required": [ + "checkDigitsValidity", + "dateOfBirth", + "dateOfExpiry", + "documentCode", + "documentNumber", + "gender", + "givenNames", + "issuingAuthority", + "nationality", + "surname" + ], + "type": "object", + "properties": { + "documentCode": { + "type": "string", + "description": "Document\u0027s code", + "readOnly": true, + "example": "P" + }, + "issuingAuthority": { + "type": "string", + "description": "Document\u0027s issuing authority", + "readOnly": true, + "example": "SVK" + }, + "surname": { + "type": "string", + "description": "Holder\u0027s surname", + "readOnly": true, + "example": "DOE" + }, + "givenNames": { + "type": "string", + "description": "Holder\u0027s given names", + "readOnly": true, + "example": "JOHN" + }, + "documentNumber": { + "type": "string", + "description": "Document\u0027s number", + "readOnly": true, + "example": "123456789" + }, + "nationality": { + "type": "string", + "description": "Holder\u0027s nationality", + "readOnly": true, + "example": "SVK" + }, + "dateOfBirth": { + "type": "string", + "description": "Holder\u0027s date of the birth, format: `YYMMDD`", + "readOnly": true, + "example": "841102" + }, + "gender": { + "type": "string", + "description": "Holder\u0027s gender, format: `M` for male, `F` for female, empty string for undefined gender.", + "readOnly": true, + "example": "M" + }, + "dateOfExpiry": { + "type": "string", + "description": "Document\u0027s date of the expiration, format: `YYMMDD`", + "readOnly": true, + "example": "261019" + }, + "personalNumberOrOptionalDataSecondLine": { + "type": "string", + "description": "Holder\u0027s personal number or other optional data", + "readOnly": true, + "example": "2222" + }, + "checkDigitsValidity": { + "$ref": "#/components/schemas/Td3MrzCheckDigitsValidity" + } + }, + "description": "Parsed TD3 machine readable zone", + "readOnly": true + }, + "Td3MrzCheckDigitsValidity": { + "required": [ + "compositeCheckDigitValid", + "dateOfBirthCheckDigitValid", + "dateOfExpiryCheckDigitValid", + "documentNumberCheckDigitValid", + "personalNumberCheckDigitValid" + ], + "type": "object", + "properties": { + "documentNumberCheckDigitValid": { + "type": "boolean", + "description": "Check digit\u0027s validity of the document\u0027s number", + "readOnly": true, + "example": true + }, + "dateOfBirthCheckDigitValid": { + "type": "boolean", + "description": "Check digit\u0027s validity of the holder\u0027s date of the birth", + "readOnly": true, + "example": true + }, + "dateOfExpiryCheckDigitValid": { + "type": "boolean", + "description": "Check digit\u0027s validity of the holder\u0027s date of the expiration", + "readOnly": true, + "example": true + }, + "personalNumberCheckDigitValid": { + "type": "boolean", + "description": "Check digit\u0027s validity of the holder\u0027s personal number", + "readOnly": true, + "example": true + }, + "compositeCheckDigitValid": { + "type": "boolean", + "description": "Composite check digit\u0027s validity", + "readOnly": true, + "example": true + } + }, + "description": "Validity of MRZ check digits", + "readOnly": true + }, + "DocumentPageQuality": { + "required": [ + "details", + "fine" + ], + "type": "object", + "properties": { + "fine": { + "type": "boolean", + "description": "The quality check result", + "readOnly": true, + "example": false + }, + "issues": { + "type": "array", + "description": "The list of the quality check issues if the quality check failed", + "example": "BRIGHTNESS_HIGH", + "items": { + "type": "string", + "description": "The list of the quality check issues if the quality check failed", + "example": "BRIGHTNESS_HIGH", + "enum": [ + "BRIGHTNESS_HIGH", + "BRIGHTNESS_LOW", + "HOTSPOTS_SCORE_HIGH", + "SHARPNESS_LOW", + "DOCUMENT_SMALL", + "DOCUMENT_OUT_OF_IMAGE" + ] + } + }, + "warnings": { + "type": "array", + "description": "The list of warnings from the quality check", + "readOnly": true, + "example": "DOCUMENT_CLOSE_TO_IMAGE_BORDER", + "items": { + "type": "string", + "description": "The list of warnings from the quality check", + "readOnly": true, + "example": "DOCUMENT_CLOSE_TO_IMAGE_BORDER", + "enum": [ + "DOCUMENT_CLOSE_TO_IMAGE_BORDER" + ] + } + }, + "details": { + "$ref": "#/components/schemas/QualityDetails" + } + }, + "readOnly": true + }, + "QualityCheckDetail": { + "required": [ + "level", + "score" + ], + "type": "object", + "properties": { + "score": { + "maximum": 1, + "minimum": 0, + "type": "number", + "description": "Quality parameter score", + "format": "double", + "readOnly": true, + "example": 0.45 + }, + "level": { + "type": "string", + "description": "Quality parameter level", + "readOnly": true, + "example": "LOW", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ] + } + }, + "description": "Quality check detail", + "readOnly": true + }, + "QualityDetails": { + "required": [ + "brightness", + "hotspots", + "sharpness" + ], + "type": "object", + "properties": { + "sharpness": { + "$ref": "#/components/schemas/QualityCheckDetail" + }, + "brightness": { + "$ref": "#/components/schemas/QualityCheckDetail" + }, + "hotspots": { + "$ref": "#/components/schemas/QualityCheckDetail" + } + }, + "description": "Quality check details", + "readOnly": true + } + }, + "securitySchemes": { + "api": { + "type": "http", + "in": "header", + "scheme": "bearer" + } + } + } +} \ No newline at end of file diff --git a/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/EnrollmentServerTestApplication.java b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/EnrollmentServerTestApplication.java new file mode 100644 index 000000000..107addd41 --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/EnrollmentServerTestApplication.java @@ -0,0 +1,28 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Jan Pesek, jan.pesek@wultra.com + */ +@SpringBootApplication +class EnrollmentServerTestApplication { + +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProviderTest.java b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProviderTest.java new file mode 100644 index 000000000..fa9384eba --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProviderTest.java @@ -0,0 +1,174 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; +import com.wultra.app.enrollmentserver.model.enumeration.DocumentVerificationStatus; +import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.enrollmentserver.model.integration.Image; +import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.*; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.util.StringUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Test of {@link InnovatricsDocumentVerificationProvider}. + * + * @author Jan Pesek, jan.pesek@wultra.com + */ +@SpringBootTest(classes = EnrollmentServerTestApplication.class) +@ActiveProfiles("test") +class InnovatricsDocumentVerificationProviderTest { + + @Autowired + private InnovatricsDocumentVerificationProvider tested; + + @MockBean + private InnovatricsApiService apiService; + + @Test + void testSubmitDocuments() throws Exception { + final OwnerId ownerId = createOwnerId(); + when(apiService.createCustomer(ownerId)).thenReturn(new CreateCustomerResponse("c123")); + + final Links docLink = new Links("docResource"); + final CreateDocumentResponse documentResponse = new CreateDocumentResponse(); + documentResponse.setLinks(docLink); + when(apiService.createDocument("c123", DocumentType.PASSPORT, ownerId)).thenReturn(documentResponse); + + final CreateDocumentPageResponse pageResponse = new CreateDocumentPageResponse(); + when(apiService.provideDocumentPage("c123", CardSide.FRONT, "img".getBytes(), ownerId)).thenReturn(pageResponse); + + final BiometricMultiValueAttribute ageAttr = new BiometricMultiValueAttribute("42", null, null, null, "40"); + final MultiValueAttribute surnameAttr = new MultiValueAttribute("SPECIMEN", null, null, null); + final Customer customer = new Customer(); + customer.age(ageAttr).setSurname(surnameAttr); + final GetCustomerResponse customerResponse = new GetCustomerResponse(); + customerResponse.customer(customer); + when(apiService.getCustomer("c123", ownerId)).thenReturn(customerResponse); + + final SubmittedDocument doc = new SubmittedDocument(); + doc.setType(DocumentType.PASSPORT); + doc.setSide(CardSide.FRONT); + doc.setPhoto(Image.builder().data("img".getBytes()).build()); + + final DocumentsSubmitResult results = tested.submitDocuments(ownerId, List.of(doc)); + verify(apiService).getCustomer("c123", ownerId); + assertEquals(1, results.getResults().size()); + + final DocumentSubmitResult result = results.getResults().get(0); + assertEquals("c123", result.getUploadId()); + assertFalse(StringUtils.hasText(result.getErrorDetail())); + assertFalse(StringUtils.hasText(result.getRejectReason())); + assertNotNull(result.getExtractedData()); + + assertEquals("42", JsonPath.read(result.getExtractedData(), "$.customer.age.visualZone")); + assertEquals("40", JsonPath.read(result.getExtractedData(), "$.customer.age.documentPortrait")); + assertEquals("SPECIMEN", JsonPath.read(result.getExtractedData(), "$.customer.surname.visualZone")); + } + + @Test + void testSubmitDocument_handleProvideDocumentPageError() throws Exception { + final OwnerId ownerId = createOwnerId(); + when(apiService.createCustomer(ownerId)).thenReturn(new CreateCustomerResponse("c123")); + + final Links docLink = new Links("docResource"); + final CreateDocumentResponse documentResponse = new CreateDocumentResponse(); + documentResponse.setLinks(docLink); + when(apiService.createDocument("c123", DocumentType.PASSPORT, ownerId)).thenReturn(documentResponse); + + final CreateDocumentPageResponse pageResponse = new CreateDocumentPageResponse( + "front", + CreateDocumentPageResponse.ErrorCodeEnum.NO_CARD_CORNERS_DETECTED, + List.of(CreateDocumentPageResponse.WarningsEnum.DOCUMENT_TYPE_NOT_RECOGNIZED)); + when(apiService.provideDocumentPage("c123", CardSide.FRONT, "img".getBytes(), ownerId)).thenReturn(pageResponse); + + final SubmittedDocument doc = new SubmittedDocument(); + doc.setType(DocumentType.PASSPORT); + doc.setSide(CardSide.FRONT); + doc.setPhoto(Image.builder().data("img".getBytes()).build()); + + final DocumentsSubmitResult results = tested.submitDocuments(ownerId, List.of(doc)); + verify(apiService).provideDocumentPage("c123", CardSide.FRONT, "img".getBytes(), ownerId); + assertEquals(1, results.getResults().size()); + + final DocumentSubmitResult result = results.getResults().get(0); + assertEquals("c123", result.getUploadId()); + assertTrue(StringUtils.hasText(result.getRejectReason())); + } + + @Test + void testParseRejectionReason() throws Exception { + final DocumentResultEntity entity = new DocumentResultEntity(); + entity.setRejectReason(new ObjectMapper().writeValueAsString(List.of("Reason1", "Reason2"))); + assertEquals(List.of("Reason1", "Reason2"), tested.parseRejectionReasons(entity)); + } + + @Test + void testParseEmptyRejectionReason() throws Exception { + final DocumentResultEntity entity = new DocumentResultEntity(); + assertTrue(tested.parseRejectionReasons(entity).isEmpty()); + } + + @Test + void testVerifyDocuments() throws Exception { + final OwnerId ownerId = createOwnerId(); + final DocumentInspectResponse response = new DocumentInspectResponse(); + when(apiService.inspectDocument("c123", ownerId)).thenReturn(response); + + final DocumentsVerificationResult result = tested.verifyDocuments(ownerId, List.of("c123")); + assertTrue(result.isAccepted()); + assertEquals("c123", result.getResults().get(0).getUploadId()); + assertNotNull(result.getVerificationId()); + assertNotNull(result.getResults().get(0).getVerificationResult()); + } + + @Test + void testVerifyDocuments_expired() throws Exception { + final OwnerId ownerId = createOwnerId(); + final DocumentInspectResponse response = new DocumentInspectResponse(true, null); + when(apiService.inspectDocument("c123", ownerId)).thenReturn(response); + + final DocumentsVerificationResult result = tested.verifyDocuments(ownerId, List.of("c123")); + assertEquals(DocumentVerificationStatus.REJECTED, result.getStatus()); + assertEquals(List.of("Document expired."), new ObjectMapper().readValue(result.getRejectReason(), new TypeReference>() {})); + assertEquals("c123", result.getResults().get(0).getUploadId()); + assertNotNull(result.getVerificationId()); + } + + private OwnerId createOwnerId() { + final OwnerId ownerId = new OwnerId(); + ownerId.setUserId("joe"); + ownerId.setActivationId("a123"); + return ownerId; + } + +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java new file mode 100644 index 000000000..ed57e1559 --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java @@ -0,0 +1,160 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.wultra.app.enrollmentserver.model.enumeration.PresenceCheckStatus; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.enrollmentserver.model.integration.PresenceCheckResult; +import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CustomerInspectResponse; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.EvaluateCustomerLivenessResponse; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.SelfieInspection; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.SelfieSimilarityWith; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.when; + +/** + * Test for {@link InnovatricsPresenceCheckProvider}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@ExtendWith(MockitoExtension.class) +class InnovatricsPresenceCheckProviderTest { + + private static final String CUSTOMER_ID = "customer-1"; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private InnovatricsConfigProps innovatricsConfigProps; + + @Mock + private InnovatricsApiService innovatricsApiService; + + @InjectMocks + private InnovatricsPresenceCheckProvider tested; + + @Test + void testGetResult_success() throws Exception { + final OwnerId id = new OwnerId(); + final SessionInfo sessionInfo = createSessionInfo(); + + when(innovatricsApiService.evaluateLiveness(CUSTOMER_ID, id)) + .thenReturn(new EvaluateCustomerLivenessResponse(0.95, null)); + when(innovatricsConfigProps.getPresenceCheckConfiguration().getScore()) + .thenReturn(0.80); + when(innovatricsApiService.inspectCustomer(CUSTOMER_ID, id)) + .thenReturn(createCustomerInspectResponse(true, true)); + + final PresenceCheckResult result = tested.getResult(id, sessionInfo); + + assertEquals(PresenceCheckStatus.ACCEPTED, result.getStatus()); + assertNull(result.getErrorDetail()); + assertNull(result.getRejectReason()); + } + + @Test + void testGetResult_livenessFailed() throws Exception { + final OwnerId id = new OwnerId(); + final SessionInfo sessionInfo = createSessionInfo(); + + when(innovatricsApiService.evaluateLiveness(CUSTOMER_ID, id)) + .thenReturn(new EvaluateCustomerLivenessResponse(null, EvaluateCustomerLivenessResponse.ErrorCodeEnum.NOT_ENOUGH_DATA)); + + final PresenceCheckResult result = tested.getResult(id, sessionInfo); + + assertEquals(PresenceCheckStatus.FAILED, result.getStatus()); + assertEquals("NOT_ENOUGH_DATA", result.getErrorDetail()); + assertNull(result.getRejectReason()); + } + + @Test + void testGetResult_livenessRejected() throws Exception { + final OwnerId id = new OwnerId(); + final SessionInfo sessionInfo = createSessionInfo(); + + when(innovatricsApiService.evaluateLiveness(CUSTOMER_ID, id)) + .thenReturn(new EvaluateCustomerLivenessResponse(0.70, null)); + when(innovatricsConfigProps.getPresenceCheckConfiguration().getScore()) + .thenReturn(0.875); + + final PresenceCheckResult result = tested.getResult(id, sessionInfo); + + assertEquals(PresenceCheckStatus.REJECTED, result.getStatus()); + assertNull(result.getErrorDetail()); + assertEquals("Score 0.700 is bellow the threshold 0.875", result.getRejectReason()); + } + + @Test + void testGetResult_customerInspectionFailed() throws Exception { + final OwnerId id = new OwnerId(); + final SessionInfo sessionInfo = createSessionInfo(); + + when(innovatricsApiService.evaluateLiveness(CUSTOMER_ID, id)) + .thenReturn(new EvaluateCustomerLivenessResponse(0.95, null)); + when(innovatricsConfigProps.getPresenceCheckConfiguration().getScore()) + .thenReturn(0.80); + when(innovatricsApiService.inspectCustomer(CUSTOMER_ID, id)) + .thenReturn(new CustomerInspectResponse()); // selfieInspection == null + + final PresenceCheckResult result = tested.getResult(id, sessionInfo); + + assertEquals(PresenceCheckStatus.FAILED, result.getStatus()); + assertEquals("Missing selfie inspection payload", result.getErrorDetail()); + assertNull(result.getRejectReason()); + } + + @Test + void testGetResult_customerInspectionRejected() throws Exception { + final OwnerId id = new OwnerId(); + final SessionInfo sessionInfo = createSessionInfo(); + + when(innovatricsApiService.evaluateLiveness(CUSTOMER_ID, id)) + .thenReturn(new EvaluateCustomerLivenessResponse(0.95, null)); + when(innovatricsConfigProps.getPresenceCheckConfiguration().getScore()) + .thenReturn(0.80); + when(innovatricsApiService.inspectCustomer(CUSTOMER_ID, id)) + .thenReturn(createCustomerInspectResponse(false, true)); + + final PresenceCheckResult result = tested.getResult(id, sessionInfo); + + assertEquals(PresenceCheckStatus.REJECTED, result.getStatus()); + assertNull(result.getErrorDetail()); + assertEquals("The person in the selfie does not match a person in the document portrait", result.getRejectReason()); + } + + private static SessionInfo createSessionInfo() { + final SessionInfo sessionInfo = new SessionInfo(); + sessionInfo.setSessionAttributes(Map.of("primaryDocumentReference", CUSTOMER_ID)); + return sessionInfo; + } + + private static CustomerInspectResponse createCustomerInspectResponse(final Boolean documentPortrait, final Boolean livenessSelfies) { + return new CustomerInspectResponse() + .selfieInspection(new SelfieInspection() + .similarityWith(new SelfieSimilarityWith(documentPortrait, livenessSelfies))); + } +} \ No newline at end of file diff --git a/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsRestApiServiceTest.java b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsRestApiServiceTest.java new file mode 100644 index 000000000..f6f065984 --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsRestApiServiceTest.java @@ -0,0 +1,138 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.provider.innovatrics; + +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; +import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CreateCustomerResponse; +import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CreateDocumentPageResponse; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test of {@link InnovatricsApiService}. + * + * @author Jan Pesek, jan.pesek@wultra.com + */ +@SpringBootTest( + classes = EnrollmentServerTestApplication.class, + properties = { + "enrollment-server-onboarding.provider.innovatrics.serviceBaseUrl=http://localhost:" + InnovatricsRestApiServiceTest.PORT + }) +@ActiveProfiles("test") +class InnovatricsRestApiServiceTest { + + static final int PORT = 52936; + + @Autowired + private InnovatricsApiService tested; + + private MockWebServer mockWebServer; + + @BeforeEach + void setup() throws Exception { + mockWebServer = new MockWebServer(); + mockWebServer.start(PORT); + } + + @AfterEach + void cleanup() throws Exception { + mockWebServer.shutdown(); + } + + @Test + void testCreateCustomer() throws Exception { + final OwnerId ownerId = createOwnerId(); + mockWebServer.enqueue(new MockResponse() + .setHeader("Content-Type", MediaType.APPLICATION_JSON) + // Real response to POST /api/v1/customers + .setBody(""" + { + "id": "c2e91b1f-0ccb-4ba0-93ae-d255a2a443af", + "links": { + "self": "/api/v1/customers/c2e91b1f-0ccb-4ba0-93ae-d255a2a443af" + } + } + """) + .setResponseCode(HttpStatus.OK.value())); + + final CreateCustomerResponse response = tested.createCustomer(ownerId); + assertEquals("c2e91b1f-0ccb-4ba0-93ae-d255a2a443af", response.getId()); + assertEquals("/api/v1/customers/c2e91b1f-0ccb-4ba0-93ae-d255a2a443af", response.getLinks().getSelf()); + } + + @Test + void testErrorResponse() { + final OwnerId ownerId = createOwnerId(); + mockWebServer.enqueue(new MockResponse() + .setHeader("Content-Type", MediaType.APPLICATION_JSON) + // Real response to uploading a page without previous document resource creation + .setBody(""" + { + "errorCode": "NOT_FOUND", + "errorMessage": "string" + } + """) + .setResponseCode(500)); + + assertThrows(RemoteCommunicationException.class, () -> tested.createCustomer(ownerId)); + } + + @Test + void testNonMatchingPageType() throws Exception { + final OwnerId ownerId = createOwnerId(); + mockWebServer.enqueue(new MockResponse() + .setHeader("Content-Type", MediaType.APPLICATION_JSON) + // Real response to uploading a second page that is different from the first one + .setBody(""" + { + "errorCode": "PAGE_DOESNT_MATCH_DOCUMENT_TYPE_OF_PREVIOUS_PAGE" + } + """) + .setResponseCode(HttpStatus.OK.value())); + + final CreateDocumentPageResponse response = tested.provideDocumentPage("123", CardSide.FRONT, "data".getBytes(), ownerId); + assertNotNull(response.getErrorCode()); + + final RecordedRequest recordedRequest = mockWebServer.takeRequest(1L, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + assertEquals("PUT /api/v1/customers/123/document/pages HTTP/1.1", recordedRequest.getRequestLine()); + } + + private OwnerId createOwnerId() { + final OwnerId ownerId = new OwnerId(); + ownerId.setUserId("joe"); + ownerId.setActivationId("a123"); + return ownerId; + } + +} diff --git a/enrollment-server-onboarding-provider-innovatrics/src/test/resources/application-test.properties b/enrollment-server-onboarding-provider-innovatrics/src/test/resources/application-test.properties new file mode 100644 index 000000000..4c7f63087 --- /dev/null +++ b/enrollment-server-onboarding-provider-innovatrics/src/test/resources/application-test.properties @@ -0,0 +1,7 @@ +enrollment-server-onboarding.document-verification.provider=innovatrics +enrollment-server-onboarding.presence-check.provider=innovatrics +enrollment-server-onboarding.onboarding-process.enabled=false + +enrollment-server-onboarding.provider.innovatrics.documentVerificationConfiguration.documentCountries=CZE,SVK + +enrollment-server-onboarding.provider.innovatrics.restClientConfig.maxInMemorySize=1048576 \ No newline at end of file diff --git a/enrollment-server-onboarding-provider-iproov/pom.xml b/enrollment-server-onboarding-provider-iproov/pom.xml new file mode 100644 index 000000000..505cfa31c --- /dev/null +++ b/enrollment-server-onboarding-provider-iproov/pom.xml @@ -0,0 +1,92 @@ + + + + + 4.0.0 + + + com.wultra.security + enrollment-server-parent + 1.6.0 + + + com.wultra.security + enrollment-server-onboarding-provider-iproov + + + + com.wultra.security + enrollment-server-onboarding-api + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.h2database + h2 + test + + + + + + + org.openapitools + openapi-generator-maven-plugin + ${openapi-generator-maven-plugin.version} + + + swagger-definitions-iproov + + generate + + + false + ${basedir}/src/main/resources/api/api-iproov.json + java + false + false + true + false + false + true + + native + true + com.wultra.app.onboardingserver.provider.iproov.model.api + true + + + + + + + + \ No newline at end of file diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/iproov/config/IProovConfig.java b/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovConfig.java similarity index 95% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/iproov/config/IProovConfig.java rename to enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovConfig.java index 7f5655563..009867461 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/iproov/config/IProovConfig.java +++ b/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovConfig.java @@ -15,12 +15,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.onboardingserver.presencecheck.iproov.config; +package com.wultra.app.onboardingserver.provider.iproov; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; -import com.wultra.app.onboardingserver.presencecheck.iproov.model.api.AuthTokenResponse; +import com.wultra.app.onboardingserver.provider.iproov.model.api.AuthTokenResponse; import com.wultra.core.rest.client.base.DefaultRestClient; import com.wultra.core.rest.client.base.RestClient; import com.wultra.core.rest.client.base.RestClientConfiguration; @@ -68,10 +68,10 @@ * @author Lukas Lukovsky, lukas.lukovsky@wultra.com */ @ConditionalOnProperty(value = "enrollment-server-onboarding.presence-check.provider", havingValue = "iproov") -@ComponentScan(basePackages = {"com.wultra.app.onboardingserver.presencecheck"}) +@ComponentScan(basePackages = {"com.wultra.app.onboardingserver.provider.iproov"}) @Configuration @Slf4j -public class IProovConfig { +class IProovConfig { private static final String OAUTH_REGISTRATION_ID = "iproov"; @@ -93,11 +93,14 @@ public ObjectMapper objectMapperIproov() { */ @Bean("restClientIProov") public RestClient restClientIProov(IProovConfigProps configProps) throws RestClientException { - HttpHeaders headers = new HttpHeaders(); + final String serviceBaseUrl = configProps.getServiceBaseUrl(); + logger.info("Registering restClientIProov: {}", serviceBaseUrl); + + final HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.USER_AGENT, configProps.getServiceUserAgent()); - RestClientConfiguration restClientConfiguration = configProps.getRestClientConfig(); - restClientConfiguration.setBaseUrl(configProps.getServiceBaseUrl()); + final RestClientConfiguration restClientConfiguration = configProps.getRestClientConfig(); + restClientConfiguration.setBaseUrl(serviceBaseUrl); restClientConfiguration.setDefaultHttpHeaders(headers); return new DefaultRestClient(restClientConfiguration); } diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/iproov/config/IProovConfigProps.java b/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovConfigProps.java similarity index 93% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/iproov/config/IProovConfigProps.java rename to enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovConfigProps.java index 56650f489..beff6b6f8 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/iproov/config/IProovConfigProps.java +++ b/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovConfigProps.java @@ -15,9 +15,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.onboardingserver.presencecheck.iproov.config; +package com.wultra.app.onboardingserver.provider.iproov; -import com.wultra.app.onboardingserver.presencecheck.iproov.model.api.ServerClaimRequest; +import com.wultra.app.onboardingserver.provider.iproov.model.api.ServerClaimRequest; import com.wultra.core.rest.client.base.RestClientConfiguration; import lombok.Getter; import lombok.Setter; @@ -35,7 +35,7 @@ @Configuration @ConfigurationProperties(prefix = "enrollment-server-onboarding.presence-check.iproov") @Getter @Setter -public class IProovConfigProps { +class IProovConfigProps { /** * API key diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/iproov/provider/IProovPresenceCheckProvider.java b/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProvider.java similarity index 92% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/iproov/provider/IProovPresenceCheckProvider.java rename to enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProvider.java index 97061280e..d34579224 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/iproov/provider/IProovPresenceCheckProvider.java +++ b/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProvider.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.onboardingserver.presencecheck.iproov.provider; +package com.wultra.app.onboardingserver.provider.iproov; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -25,14 +25,13 @@ import com.wultra.app.enrollmentserver.model.integration.OwnerId; import com.wultra.app.enrollmentserver.model.integration.PresenceCheckResult; import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import com.wultra.app.onboardingserver.api.errorhandling.PresenceCheckException; +import com.wultra.app.onboardingserver.api.provider.PresenceCheckProvider; import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; -import com.wultra.app.onboardingserver.errorhandling.PresenceCheckException; -import com.wultra.app.onboardingserver.presencecheck.iproov.model.api.ClaimResponse; -import com.wultra.app.onboardingserver.presencecheck.iproov.model.api.ClaimValidateResponse; -import com.wultra.app.onboardingserver.presencecheck.iproov.model.api.ClientErrorResponse; -import com.wultra.app.onboardingserver.presencecheck.iproov.model.api.EnrolResponse; -import com.wultra.app.onboardingserver.presencecheck.iproov.service.IProovRestApiService; -import com.wultra.app.onboardingserver.provider.PresenceCheckProvider; +import com.wultra.app.onboardingserver.provider.iproov.model.api.ClaimResponse; +import com.wultra.app.onboardingserver.provider.iproov.model.api.ClaimValidateResponse; +import com.wultra.app.onboardingserver.provider.iproov.model.api.ClientErrorResponse; +import com.wultra.app.onboardingserver.provider.iproov.model.api.EnrolResponse; import com.wultra.core.rest.client.base.RestClientException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -41,6 +40,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; +import org.springframework.util.Assert; import java.util.Base64; @@ -52,7 +52,7 @@ @ConditionalOnProperty(value = "enrollment-server-onboarding.presence-check.provider", havingValue = "iproov") @Component @Slf4j -public class IProovPresenceCheckProvider implements PresenceCheckProvider { +class IProovPresenceCheckProvider implements PresenceCheckProvider { /** * Session parameter name of the verification token @@ -81,6 +81,7 @@ public IProovPresenceCheckProvider( @Override public void initPresenceCheck(final OwnerId id, final Image photo) throws PresenceCheckException, RemoteCommunicationException { + Assert.notNull(photo, "iProov presence check requires trusted photo"); iProovRestApiService.deleteUserIfAlreadyExists(id); final ResponseEntity responseEntityToken = callGenerateEnrolToken(id); @@ -125,6 +126,11 @@ public void initPresenceCheck(final OwnerId id, final Image photo) throws Presen } } + @Override + public TrustedPhotoSource trustedPhotoSource() { + return TrustedPhotoSource.IMAGE; + } + @Override public SessionInfo startPresenceCheck(OwnerId id) throws PresenceCheckException, RemoteCommunicationException { final ResponseEntity responseEntity; @@ -211,7 +217,7 @@ private static PresenceCheckResult convert(final ClaimValidateResponse source, f } @Override - public void cleanupIdentityData(final OwnerId id) { + public void cleanupIdentityData(final OwnerId id, final SessionInfo sessionInfo) { // https://docs.iproov.com/docs/Content/ImplementationGuide/security/data-retention.htm logger.info("No data deleted, retention policy left to iProov server, {}", id); } diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/iproov/service/IProovRestApiService.java b/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovRestApiService.java similarity index 96% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/iproov/service/IProovRestApiService.java rename to enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovRestApiService.java index fabc95b19..22b06c1c9 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/iproov/service/IProovRestApiService.java +++ b/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovRestApiService.java @@ -15,15 +15,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.onboardingserver.presencecheck.iproov.service; +package com.wultra.app.onboardingserver.provider.iproov; import com.wultra.app.enrollmentserver.model.integration.Image; import com.wultra.app.enrollmentserver.model.integration.OwnerId; import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; -import com.wultra.app.onboardingserver.presencecheck.iproov.config.IProovConfigProps; -import com.wultra.app.onboardingserver.presencecheck.iproov.model.api.ClaimValidateRequest; -import com.wultra.app.onboardingserver.presencecheck.iproov.model.api.ClientErrorResponse; -import com.wultra.app.onboardingserver.presencecheck.iproov.model.api.ServerClaimRequest; +import com.wultra.app.onboardingserver.provider.iproov.model.api.ClaimValidateRequest; +import com.wultra.app.onboardingserver.provider.iproov.model.api.ClientErrorResponse; +import com.wultra.app.onboardingserver.provider.iproov.model.api.ServerClaimRequest; import com.wultra.core.rest.client.base.RestClient; import com.wultra.core.rest.client.base.RestClientException; import lombok.extern.slf4j.Slf4j; @@ -61,7 +60,7 @@ @ConditionalOnProperty(value = "enrollment-server-onboarding.presence-check.provider", havingValue = "iproov") @Service @Slf4j -public class IProovRestApiService { +class IProovRestApiService { private static final MultiValueMap EMPTY_QUERY_PARAMS = new LinkedMultiValueMap<>(); diff --git a/enrollment-server-onboarding/src/main/resources/api/api-iproov.json b/enrollment-server-onboarding-provider-iproov/src/main/resources/api/api-iproov.json similarity index 100% rename from enrollment-server-onboarding/src/main/resources/api/api-iproov.json rename to enrollment-server-onboarding-provider-iproov/src/main/resources/api/api-iproov.json diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/ZenidConst.java b/enrollment-server-onboarding-provider-iproov/src/test/java/com/wultra/app/onboardingserver/provider/iproov/EnrollmentServerTestApplication.java similarity index 64% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/ZenidConst.java rename to enrollment-server-onboarding-provider-iproov/src/test/java/com/wultra/app/onboardingserver/provider/iproov/EnrollmentServerTestApplication.java index 7f850d876..beb4c684c 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/ZenidConst.java +++ b/enrollment-server-onboarding-provider-iproov/src/test/java/com/wultra/app/onboardingserver/provider/iproov/EnrollmentServerTestApplication.java @@ -1,6 +1,6 @@ /* * PowerAuth Enrollment Server - * Copyright (C) 2022 Wultra s.r.o. + * 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 @@ -15,23 +15,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.onboardingserver.docverify.zenid; +package com.wultra.app.onboardingserver.provider.iproov; + +import org.springframework.boot.autoconfigure.SpringBootApplication; /** - * Constants for ZenID purposes - * * @author Lukas Lukovsky, lukas.lukovsky@wultra.com */ -public class ZenidConst { - - /** - * Verification SDK initialization response - */ - public static final String SDK_INIT_RESPONSE = "zenid-sdk-init-response"; - - /** - * Verification SDK initialization token - */ - public static final String SDK_INIT_TOKEN = "sdk-init-token"; +@SpringBootApplication +class EnrollmentServerTestApplication { } diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/iproov/provider/IProovPresenceCheckProviderTest.java b/enrollment-server-onboarding-provider-iproov/src/test/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProviderTest.java similarity index 84% rename from enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/iproov/provider/IProovPresenceCheckProviderTest.java rename to enrollment-server-onboarding-provider-iproov/src/test/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProviderTest.java index bf2714586..24c102d57 100644 --- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/iproov/provider/IProovPresenceCheckProviderTest.java +++ b/enrollment-server-onboarding-provider-iproov/src/test/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProviderTest.java @@ -15,15 +15,13 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.onboardingserver.presencecheck.iproov.provider; +package com.wultra.app.onboardingserver.provider.iproov; 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.onboardingserver.EnrollmentServerTestApplication; -import com.wultra.app.test.TestUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -33,6 +31,9 @@ import org.springframework.context.annotation.ComponentScan; import org.springframework.test.context.ActiveProfiles; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; @@ -128,8 +129,26 @@ private OwnerId createOwnerId() { } private void initPresenceCheck(OwnerId ownerId) throws Exception { - Image photo = TestUtil.loadPhoto("/images/specimen_photo.jpg"); + final Image photo = loadPhoto("/images/specimen_photo.jpg"); provider.initPresenceCheck(ownerId, photo); } + private static Image loadPhoto(final String path) throws IOException { + final File file = new File(path); + + return Image.builder() + .data(readImageData(path)) + .filename(file.getName()) + .build(); + } + + private static byte[] readImageData(final String path) throws IOException { + try (InputStream stream = IProovPresenceCheckProviderTest.class.getResourceAsStream(path)) { + if (stream == null) { + throw new IllegalStateException("Unable to get a stream for: " + path); + } + return stream.readAllBytes(); + } + } + } diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/iproov/service/IProovRestApiServiceTest.java b/enrollment-server-onboarding-provider-iproov/src/test/java/com/wultra/app/onboardingserver/provider/iproov/IProovRestApiServiceTest.java similarity index 81% rename from enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/iproov/service/IProovRestApiServiceTest.java rename to enrollment-server-onboarding-provider-iproov/src/test/java/com/wultra/app/onboardingserver/provider/iproov/IProovRestApiServiceTest.java index b19579636..f5eecaffa 100644 --- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/iproov/service/IProovRestApiServiceTest.java +++ b/enrollment-server-onboarding-provider-iproov/src/test/java/com/wultra/app/onboardingserver/provider/iproov/IProovRestApiServiceTest.java @@ -15,13 +15,10 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.onboardingserver.presencecheck.iproov.service; +package com.wultra.app.onboardingserver.provider.iproov; -import com.wultra.app.onboardingserver.EnrollmentServerTestApplication; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -29,8 +26,6 @@ /** * @author Lukas Lukovsky, lukas.lukovsky@wultra.com */ -@SpringBootTest(classes = EnrollmentServerTestApplication.class) -@ActiveProfiles("test") class IProovRestApiServiceTest { @Test diff --git a/enrollment-server-onboarding-provider-iproov/src/test/resources/application-external-service.properties b/enrollment-server-onboarding-provider-iproov/src/test/resources/application-external-service.properties new file mode 100644 index 000000000..e71d0317c --- /dev/null +++ b/enrollment-server-onboarding-provider-iproov/src/test/resources/application-external-service.properties @@ -0,0 +1,3 @@ +enrollment-server-onboarding.presence-check.provider=iproov + +logging.level.root=INFO diff --git a/enrollment-server-onboarding-provider-zenid/pom.xml b/enrollment-server-onboarding-provider-zenid/pom.xml new file mode 100644 index 000000000..e6b8b938a --- /dev/null +++ b/enrollment-server-onboarding-provider-zenid/pom.xml @@ -0,0 +1,93 @@ + + + + + 4.0.0 + + + com.wultra.security + enrollment-server-parent + 1.6.0 + + + com.wultra.security + enrollment-server-onboarding-provider-zenid + + + + com.wultra.security + enrollment-server-onboarding-api + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.squareup.okhttp3 + mockwebserver + test + + + + com.h2database + h2 + test + + + + + + + org.openapitools + openapi-generator-maven-plugin + ${openapi-generator-maven-plugin.version} + + + swagger-definitions-zenid + + generate + + + false + ${basedir}/src/main/resources/api/api-zenid.yaml + java + false + false + true + false + false + + native + true + com.wultra.app.onboardingserver.provider.zenid.model.api + true + + + + + + + + + \ No newline at end of file diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/model/deserializer/CustomOffsetDateTimeDeserializer.java b/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/CustomOffsetDateTimeDeserializer.java similarity index 91% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/model/deserializer/CustomOffsetDateTimeDeserializer.java rename to enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/CustomOffsetDateTimeDeserializer.java index fd9606064..ec50836fd 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/model/deserializer/CustomOffsetDateTimeDeserializer.java +++ b/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/CustomOffsetDateTimeDeserializer.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.onboardingserver.docverify.zenid.model.deserializer; +package com.wultra.app.onboardingserver.provider.zenid; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; @@ -35,7 +35,7 @@ * 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 { +class CustomOffsetDateTimeDeserializer extends JsonDeserializer { @Override public OffsetDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException { diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/config/ZenidConfig.java b/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidConfig.java similarity index 91% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/config/ZenidConfig.java rename to enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidConfig.java index efce6f861..53ff2da50 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/config/ZenidConfig.java +++ b/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidConfig.java @@ -15,15 +15,15 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.onboardingserver.docverify.zenid.config; +package com.wultra.app.onboardingserver.provider.zenid; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.wultra.app.onboardingserver.docverify.zenid.model.deserializer.CustomOffsetDateTimeDeserializer; 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 lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; @@ -40,9 +40,10 @@ * @author Lukas Lukovsky, lukas.lukovsky@wultra.com */ @ConditionalOnProperty(value = "enrollment-server-onboarding.document-verification.provider", havingValue = "zenid") -@ComponentScan(basePackages = {"com.wultra.app.onboardingserver.docverify"}) +@ComponentScan(basePackages = {"com.wultra.app.onboardingserver.provider.zenid"}) @Configuration -public class ZenidConfig { +@Slf4j +class ZenidConfig { /** * @param configProps Configuration properties @@ -75,13 +76,16 @@ private static JavaTimeModule createJavaTimeModule() { */ @Bean("restClientZenid") public RestClient restClientZenid(final ZenidConfigProps configProps) throws RestClientException { + final String serviceBaseUrl = configProps.getServiceBaseUrl(); + logger.info("Registering restClientZenid: {}", serviceBaseUrl); + final HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); headers.add(HttpHeaders.AUTHORIZATION, "api_key " + configProps.getApiKey()); headers.add(HttpHeaders.USER_AGENT, configProps.getServiceUserAgent()); final RestClientConfiguration restClientConfiguration = configProps.getRestClientConfig(); - restClientConfiguration.setBaseUrl(configProps.getServiceBaseUrl()); + restClientConfiguration.setBaseUrl(serviceBaseUrl); restClientConfiguration.setDefaultHttpHeaders(headers); return new DefaultRestClient(restClientConfiguration, createJavaTimeModule()); } diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/config/ZenidConfigProps.java b/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidConfigProps.java similarity index 93% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/config/ZenidConfigProps.java rename to enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidConfigProps.java index c607baf1c..ce479eb01 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/config/ZenidConfigProps.java +++ b/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidConfigProps.java @@ -15,9 +15,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.onboardingserver.docverify.zenid.config; +package com.wultra.app.onboardingserver.provider.zenid; -import com.wultra.app.onboardingserver.docverify.zenid.model.api.ZenidSharedMineAllResult; +import com.wultra.app.onboardingserver.provider.zenid.model.api.ZenidSharedMineAllResult; import com.wultra.core.rest.client.base.RestClientConfiguration; import lombok.Getter; import lombok.Setter; @@ -38,7 +38,7 @@ @Configuration @ConfigurationProperties(prefix = "enrollment-server-onboarding.document-verification.zenid") @Getter @Setter -public class ZenidConfigProps { +class ZenidConfigProps { /** * // TODO consider removing this config option diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/provider/ZenidDocumentVerificationProvider.java b/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidDocumentVerificationProvider.java similarity index 97% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/provider/ZenidDocumentVerificationProvider.java rename to enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidDocumentVerificationProvider.java index e1aa5d3e0..012d48715 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/provider/ZenidDocumentVerificationProvider.java +++ b/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidDocumentVerificationProvider.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.onboardingserver.docverify.zenid.provider; +package com.wultra.app.onboardingserver.provider.zenid; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; @@ -29,12 +29,9 @@ import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity; import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; -import com.wultra.app.onboardingserver.docverify.zenid.ZenidConst; -import com.wultra.app.onboardingserver.docverify.zenid.config.ZenidConfigProps; -import com.wultra.app.onboardingserver.docverify.zenid.model.api.*; -import com.wultra.app.onboardingserver.docverify.zenid.service.ZenidRestApiService; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; -import com.wultra.app.onboardingserver.provider.DocumentVerificationProvider; +import com.wultra.app.onboardingserver.provider.zenid.model.api.*; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.provider.DocumentVerificationProvider; import com.wultra.core.rest.client.base.RestClientException; import jakarta.annotation.Nullable; import lombok.extern.slf4j.Slf4j; @@ -59,6 +56,9 @@ @Slf4j public class ZenidDocumentVerificationProvider implements DocumentVerificationProvider { + private static final String SDK_INIT_RESPONSE = "zenid-sdk-init-response"; + private static final String SDK_INIT_TOKEN = "sdk-init-token"; + private static final String INTERNAL_SERVER_ERROR = "InternalServerError"; private static final String LICENSE_INVALID = "License invalid"; @@ -178,6 +178,11 @@ public DocumentsSubmitResult submitDocuments(OwnerId id, List return result; } + @Override + public boolean shouldStoreSelfie() { + return true; + } + @Override public DocumentsVerificationResult verifyDocuments(OwnerId id, List uploadIds) throws RemoteCommunicationException, DocumentVerificationException { ResponseEntity responseEntity; @@ -331,8 +336,8 @@ public List parseRejectionReasons(DocumentResultEntity docResult) throws @Override public VerificationSdkInfo initVerificationSdk(OwnerId id, Map initAttributes) throws RemoteCommunicationException, DocumentVerificationException { - Preconditions.checkArgument(initAttributes.containsKey(ZenidConst.SDK_INIT_TOKEN), "Missing initialization token for ZenID SDK"); - String token = initAttributes.get(ZenidConst.SDK_INIT_TOKEN); + Preconditions.checkArgument(initAttributes.containsKey(SDK_INIT_TOKEN), "Missing initialization token for ZenID SDK"); + String token = initAttributes.get(SDK_INIT_TOKEN); ResponseEntity responseEntity; try { @@ -357,7 +362,7 @@ public VerificationSdkInfo initVerificationSdk(OwnerId id, Map i } VerificationSdkInfo verificationSdkInfo = new VerificationSdkInfo(); - verificationSdkInfo.getAttributes().put(ZenidConst.SDK_INIT_RESPONSE, response.getResponse()); + verificationSdkInfo.getAttributes().put(SDK_INIT_RESPONSE, response.getResponse()); return verificationSdkInfo; } diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/service/ZenidRestApiService.java b/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidRestApiService.java similarity index 98% rename from enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/service/ZenidRestApiService.java rename to enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidRestApiService.java index be1810956..c98964a2e 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/zenid/service/ZenidRestApiService.java +++ b/enrollment-server-onboarding-provider-zenid/src/main/java/com/wultra/app/onboardingserver/provider/zenid/ZenidRestApiService.java @@ -15,15 +15,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.onboardingserver.docverify.zenid.service; +package com.wultra.app.onboardingserver.provider.zenid; import com.google.common.base.Preconditions; 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 com.wultra.app.onboardingserver.docverify.zenid.config.ZenidConfigProps; -import com.wultra.app.onboardingserver.docverify.zenid.model.api.*; +import com.wultra.app.onboardingserver.provider.zenid.model.api.*; import com.wultra.core.rest.client.base.RestClient; import com.wultra.core.rest.client.base.RestClientException; import jakarta.annotation.Nullable; @@ -51,7 +50,7 @@ @ConditionalOnProperty(value = "enrollment-server-onboarding.document-verification.provider", havingValue = "zenid") @Service @Slf4j -public class ZenidRestApiService { +class ZenidRestApiService { private static final MultiValueMap EMPTY_ADDITIONAL_HEADERS = new LinkedMultiValueMap<>(); diff --git a/enrollment-server-onboarding/src/main/resources/api/api-zenid.yaml b/enrollment-server-onboarding-provider-zenid/src/main/resources/api/api-zenid.yaml similarity index 100% rename from enrollment-server-onboarding/src/main/resources/api/api-zenid.yaml rename to enrollment-server-onboarding-provider-zenid/src/main/resources/api/api-zenid.yaml diff --git a/enrollment-server-onboarding-provider-zenid/src/test/java/com/wultra/app/onboardingserver/provider/zenid/EnrollmentServerTestApplication.java b/enrollment-server-onboarding-provider-zenid/src/test/java/com/wultra/app/onboardingserver/provider/zenid/EnrollmentServerTestApplication.java new file mode 100644 index 000000000..df3c048ad --- /dev/null +++ b/enrollment-server-onboarding-provider-zenid/src/test/java/com/wultra/app/onboardingserver/provider/zenid/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.onboardingserver.provider.zenid; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + */ +@SpringBootApplication +class EnrollmentServerTestApplication { + +} diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/zenid/provider/ZenidDocumentVerificationProviderTest.java b/enrollment-server-onboarding-provider-zenid/src/test/java/com/wultra/app/onboardingserver/provider/zenid/ZenidDocumentVerificationProviderTest.java similarity index 79% rename from enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/zenid/provider/ZenidDocumentVerificationProviderTest.java rename to enrollment-server-onboarding-provider-zenid/src/test/java/com/wultra/app/onboardingserver/provider/zenid/ZenidDocumentVerificationProviderTest.java index 23acc729d..937b9d1fa 100644 --- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/zenid/provider/ZenidDocumentVerificationProviderTest.java +++ b/enrollment-server-onboarding-provider-zenid/src/test/java/com/wultra/app/onboardingserver/provider/zenid/ZenidDocumentVerificationProviderTest.java @@ -15,18 +15,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.onboardingserver.docverify.zenid.provider; +package com.wultra.app.onboardingserver.provider.zenid; 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.onboardingserver.EnrollmentServerTestApplication; import com.wultra.app.onboardingserver.common.database.DocumentVerificationRepository; import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity; import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; -import com.wultra.app.onboardingserver.docverify.AbstractDocumentVerificationProviderTest; -import com.wultra.app.onboardingserver.docverify.zenid.ZenidConst; -import com.wultra.app.test.TestUtil; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; @@ -41,14 +37,16 @@ import org.springframework.context.annotation.ComponentScan; import org.springframework.test.context.ActiveProfiles; +import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertNull; /** * @author Lukas Lukovsky, lukas.lukovsky@wultra.com @@ -58,7 +56,7 @@ @ComponentScan(basePackages = {"com.wultra.app.onboardingserver.docverify.zenid"}) @EnableConfigurationProperties @Tag("external-service") -class ZenidDocumentVerificationProviderTest extends AbstractDocumentVerificationProviderTest { +class ZenidDocumentVerificationProviderTest { private static final Logger logger = LoggerFactory.getLogger(ZenidDocumentVerificationProviderTest.class); @@ -183,13 +181,13 @@ void parseRejectionReasonsTest() throws Exception { @Test void initVerificationSdkTest() throws Exception { - Map attributes = Map.of(ZenidConst.SDK_INIT_TOKEN, "sdk-init-token"); + Map attributes = Map.of("sdk-init-token", UUID.randomUUID().toString()); VerificationSdkInfo verificationSdkInfo = provider.initVerificationSdk(ownerId, attributes); - assertNotNull(verificationSdkInfo.getAttributes().get(ZenidConst.SDK_INIT_RESPONSE), "Missing SDK init response"); + assertNotNull(verificationSdkInfo.getAttributes().get("zenid-sdk-init-response"), "Missing SDK init response"); } private void cleanupDocuments(OwnerId ownerId) throws Exception { - if (uploadIds.size() > 0) { + if (!uploadIds.isEmpty()) { provider.cleanupDocuments(ownerId, uploadIds); } } @@ -215,7 +213,7 @@ private List createSubmittedDocuments() throws Exception { private SubmittedDocument createIdCardFrontDocument() throws IOException { SubmittedDocument idCardFront = new SubmittedDocument(); idCardFront.setDocumentId(DOC_ID_CARD_FRONT); - Image idCardFrontPhoto = TestUtil.loadPhoto("/images/specimen_id_front.jpg"); + Image idCardFrontPhoto = loadPhoto("/images/specimen_id_front.jpg"); idCardFront.setPhoto(idCardFrontPhoto); idCardFront.setSide(CardSide.FRONT); idCardFront.setType(DocumentType.ID_CARD); @@ -226,7 +224,7 @@ private SubmittedDocument createIdCardFrontDocument() throws IOException { private SubmittedDocument createIdCardBackDocument() throws IOException { SubmittedDocument idCardBack = new SubmittedDocument(); idCardBack.setDocumentId(DOC_ID_CARD_BACK); - Image idCardBackPhoto = TestUtil.loadPhoto("/images/specimen_id_back.jpg"); + Image idCardBackPhoto = loadPhoto("/images/specimen_id_back.jpg"); idCardBack.setPhoto(idCardBackPhoto); idCardBack.setSide(CardSide.BACK); idCardBack.setType(DocumentType.ID_CARD); @@ -241,4 +239,41 @@ private OwnerId createOwnerId() { return ownerId; } + private static Image loadPhoto(final String path) throws IOException { + final File file = new File(path); + + return Image.builder() + .data(readImageData(path)) + .filename(file.getName()) + .build(); + } + + private static byte[] readImageData(final String path) throws IOException { + try (InputStream stream = ZenidDocumentVerificationProviderTest.class.getResourceAsStream(path)) { + if (stream == null) { + throw new IllegalStateException("Unable to get a stream for: " + path); + } + return stream.readAllBytes(); + } + } + + private static void assertSubmittedDocuments(OwnerId ownerId, List documents, DocumentsSubmitResult result) { + assertEquals(documents.size(), result.getResults().size(), "Different size of submitted documents than expected"); + assertNotNull(result.getExtractedPhotoId(), "Missing extracted photoId"); + + final List submittedDocsIds = result.getResults().stream() + .map(DocumentSubmitResult::getDocumentId) + .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-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/zenid/service/ZenidRestApiServiceTest.java b/enrollment-server-onboarding-provider-zenid/src/test/java/com/wultra/app/onboardingserver/provider/zenid/ZenidRestApiServiceTest.java similarity index 98% rename from enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/zenid/service/ZenidRestApiServiceTest.java rename to enrollment-server-onboarding-provider-zenid/src/test/java/com/wultra/app/onboardingserver/provider/zenid/ZenidRestApiServiceTest.java index 781c4425a..e2ee8f064 100644 --- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/zenid/service/ZenidRestApiServiceTest.java +++ b/enrollment-server-onboarding-provider-zenid/src/test/java/com/wultra/app/onboardingserver/provider/zenid/ZenidRestApiServiceTest.java @@ -15,10 +15,10 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package com.wultra.app.onboardingserver.docverify.zenid.service; +package com.wultra.app.onboardingserver.provider.zenid; -import com.wultra.app.onboardingserver.EnrollmentServerTestApplication; -import com.wultra.app.onboardingserver.docverify.zenid.model.api.ZenidWebInvestigateResponse; +import com.wultra.app.onboardingserver.common.database.DocumentVerificationRepository; +import com.wultra.app.onboardingserver.provider.zenid.model.api.ZenidWebInvestigateResponse; import lombok.extern.slf4j.Slf4j; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -28,6 +28,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ActiveProfiles; @@ -51,6 +52,7 @@ }) @ActiveProfiles("test") @Slf4j +@MockBean(DocumentVerificationRepository.class) class ZenidRestApiServiceTest { // TODO (racansky, 2023-05-18) find the way how to set the same random port for mock server and property diff --git a/enrollment-server-onboarding-provider-zenid/src/test/resources/application-external-service.properties b/enrollment-server-onboarding-provider-zenid/src/test/resources/application-external-service.properties new file mode 100644 index 000000000..dee2e49f5 --- /dev/null +++ b/enrollment-server-onboarding-provider-zenid/src/test/resources/application-external-service.properties @@ -0,0 +1,7 @@ +enrollment-server-onboarding.document-verification.provider=zenid + +enrollment-server-onboarding.document-verification.zenid.restClientConfig.jacksonConfiguration.serialization.FAIL_ON_EMPTY_BEANS=false +enrollment-server-onboarding.document-verification.zenid.restClientConfig.jacksonConfiguration.serialization.WRITE_DATES_AS_TIMESTAMPS=false +enrollment-server-onboarding.document-verification.zenid.restClientConfig.jacksonConfiguration.serialization.WRITE_DATES_WITH_ZONE_ID=true +enrollment-server-onboarding.document-verification.zenid.restClientConfig.jacksonConfiguration.deserialization.ADJUST_DATES_TO_CONTEXT_TIME_ZONE=false +enrollment-server-onboarding.document-verification.zenid.restClientConfig.jacksonConfiguration.deserialization.FAIL_ON_UNKNOWN_PROPERTIES=false diff --git a/enrollment-server-onboarding-provider-zenid/src/test/resources/application-test.properties b/enrollment-server-onboarding-provider-zenid/src/test/resources/application-test.properties new file mode 100644 index 000000000..a9e613c15 --- /dev/null +++ b/enrollment-server-onboarding-provider-zenid/src/test/resources/application-test.properties @@ -0,0 +1,5 @@ +enrollment-server-onboarding.document-verification.zenid.restClientConfig.jacksonConfiguration.serialization.FAIL_ON_EMPTY_BEANS=false +enrollment-server-onboarding.document-verification.zenid.restClientConfig.jacksonConfiguration.serialization.WRITE_DATES_AS_TIMESTAMPS=false +enrollment-server-onboarding.document-verification.zenid.restClientConfig.jacksonConfiguration.serialization.WRITE_DATES_WITH_ZONE_ID=true +enrollment-server-onboarding.document-verification.zenid.restClientConfig.jacksonConfiguration.deserialization.ADJUST_DATES_TO_CONTEXT_TIME_ZONE=false +enrollment-server-onboarding.document-verification.zenid.restClientConfig.jacksonConfiguration.deserialization.FAIL_ON_UNKNOWN_PROPERTIES=false \ No newline at end of file diff --git a/enrollment-server-onboarding/pom.xml b/enrollment-server-onboarding/pom.xml index ee2ef4995..e3f495278 100644 --- a/enrollment-server-onboarding/pom.xml +++ b/enrollment-server-onboarding/pom.xml @@ -29,7 +29,7 @@ com.wultra.security enrollment-server-parent - 1.5.0 + 1.6.0 @@ -40,10 +40,25 @@ enrollment-server-onboarding-domain-model ${project.version} + com.wultra.security - enrollment-server-onboarding-common - ${project.version} + enrollment-server-onboarding-api + + + + com.wultra.security + enrollment-server-onboarding-provider-innovatrics + + + + com.wultra.security + enrollment-server-onboarding-provider-iproov + + + + com.wultra.security + enrollment-server-onboarding-provider-zenid @@ -85,10 +100,6 @@ org.springframework.boot spring-boot-starter-validation - - org.springframework.boot - spring-boot-starter-oauth2-client - jakarta.servlet @@ -131,6 +142,12 @@ logstash-logback-encoder + + + io.micrometer + micrometer-registry-prometheus + + org.springframework.boot @@ -183,67 +200,32 @@ + + + + jakarta.servlet + jakarta.servlet-api + + + org.projectlombok + lombok + + + org.apache.maven.plugins - maven-surefire-plugin - ${maven-surefire-plugin.version} + maven-war-plugin - external-service + + + src/main/webapp/WEB-INF + WEB-INF + true + + - - org.openapitools - openapi-generator-maven-plugin - ${openapi-generator-maven-plugin.version} - - - swagger-definitions-iproov - - generate - - - false - ${basedir}/src/main/resources/api/api-iproov.json - java - false - false - true - false - false - true - - native - true - com.wultra.app.onboardingserver.presencecheck.iproov.model.api - true - - - - - swagger-definitions-zenid - - generate - - - false - ${basedir}/src/main/resources/api/api-zenid.yaml - java - false - false - true - false - false - - native - true - com.wultra.app.onboardingserver.docverify.zenid.model.api - true - - - - - @@ -257,22 +239,6 @@ -Xdoclint:none - - standalone - - - org.springframework.boot - spring-boot-starter-tomcat - provided - - - - org.apache.tomcat.embed - tomcat-embed-el - provided - - - public-repository diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/configuration/OpenApiConfiguration.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/configuration/OpenApiConfiguration.java index ef8410989..4a4dce8d2 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/configuration/OpenApiConfiguration.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/configuration/OpenApiConfiguration.java @@ -53,7 +53,8 @@ public class OpenApiConfiguration { public GroupedOpenApi defaultApiGroup() { String[] packages = { "io.getlime.security.powerauth", - "com.wultra.app.onboardingserver.controller.api" + "com.wultra.app.onboardingserver.controller.api", + "com.wultra.app.onboardingserver.provider.innovatrics" }; return GroupedOpenApi.builder() diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/controller/api/IdentityVerificationController.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/controller/api/IdentityVerificationController.java index 094de983d..981c99bad 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/controller/api/IdentityVerificationController.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/controller/api/IdentityVerificationController.java @@ -21,8 +21,8 @@ import com.wultra.app.enrollmentserver.api.model.onboarding.response.*; import com.wultra.app.onboardingserver.common.errorhandling.*; import com.wultra.app.onboardingserver.errorhandling.DocumentSubmitException; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; -import com.wultra.app.onboardingserver.errorhandling.PresenceCheckException; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.PresenceCheckException; import com.wultra.app.onboardingserver.impl.service.IdentityVerificationRestService; import io.getlime.core.rest.model.base.request.ObjectRequest; import io.getlime.core.rest.model.base.response.ObjectResponse; @@ -229,7 +229,7 @@ public ObjectResponse initVerificationSdk( @PowerAuth(resourceId = "/api/identity/presence-check/init", signatureType = { PowerAuthSignatureTypes.POSSESSION }) - public ResponseEntity initPresenceCheck(@EncryptedRequestBody ObjectRequest request, + public ResponseEntity> initPresenceCheck(@EncryptedRequestBody ObjectRequest request, @Parameter(hidden = true) EncryptionContext encryptionContext, @Parameter(hidden = true) PowerAuthApiAuthentication apiAuthentication) throws IdentityVerificationException, PowerAuthAuthenticationException, PowerAuthEncryptionException, OnboardingProcessException { diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProvider.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProvider.java index 3cae75e39..692830985 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProvider.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProvider.java @@ -26,8 +26,8 @@ import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity; import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; import com.wultra.app.onboardingserver.docverify.mock.MockConst; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; -import com.wultra.app.onboardingserver.provider.DocumentVerificationProvider; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.provider.DocumentVerificationProvider; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -104,7 +104,11 @@ public DocumentsSubmitResult checkDocumentUpload(OwnerId id, DocumentVerificatio } @Override - public DocumentsSubmitResult submitDocuments(OwnerId id, List documents) { + public DocumentsSubmitResult submitDocuments(OwnerId id, List documents) throws DocumentVerificationException { + if (documents.stream().anyMatch(doc -> "throw.exception".equals(doc.getPhoto().getFilename()))) { + throw new DocumentVerificationException("Filename to throw an exception is present in documents."); + } + final List submitResults = documents.stream() .map(this::toDocumentSubmitResult) .toList(); @@ -122,6 +126,11 @@ public DocumentsSubmitResult submitDocuments(OwnerId id, List return result; } + @Override + public boolean shouldStoreSelfie() { + return true; + } + @Override public DocumentsVerificationResult verifyDocuments(OwnerId id, List uploadIds) { final String verificationId = UUID.randomUUID().toString(); diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/errorhandling/DefaultExceptionHandler.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/errorhandling/DefaultExceptionHandler.java index b34285375..94b07ebfd 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/errorhandling/DefaultExceptionHandler.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/errorhandling/DefaultExceptionHandler.java @@ -18,6 +18,8 @@ package com.wultra.app.onboardingserver.errorhandling; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.PresenceCheckException; import com.wultra.app.onboardingserver.common.errorhandling.*; import io.getlime.core.rest.model.base.response.ErrorResponse; import io.getlime.security.powerauth.rest.api.spring.exception.PowerAuthAuthenticationException; diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/DataExtractionService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/DataExtractionService.java index a40cdcb1b..843341e2c 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/DataExtractionService.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/DataExtractionService.java @@ -17,7 +17,7 @@ */ package com.wultra.app.onboardingserver.impl.service; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; import com.wultra.app.enrollmentserver.model.Document; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/IdentityVerificationRestService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/IdentityVerificationRestService.java index 3869683d0..ad18671cb 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/IdentityVerificationRestService.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/IdentityVerificationRestService.java @@ -24,14 +24,14 @@ import com.wultra.app.enrollmentserver.model.enumeration.OnboardingStatus; import com.wultra.app.enrollmentserver.model.integration.OwnerId; import com.wultra.app.enrollmentserver.model.integration.VerificationSdkInfo; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.PresenceCheckException; import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity; import com.wultra.app.onboardingserver.common.database.entity.OnboardingProcessEntity; import com.wultra.app.onboardingserver.common.errorhandling.*; import com.wultra.app.onboardingserver.configuration.IdentityVerificationConfig; import com.wultra.app.onboardingserver.configuration.OnboardingConfig; import com.wultra.app.onboardingserver.errorhandling.DocumentSubmitException; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; -import com.wultra.app.onboardingserver.errorhandling.PresenceCheckException; import com.wultra.app.onboardingserver.impl.service.document.DocumentProcessingService; import com.wultra.app.onboardingserver.impl.service.validation.OnboardingConsentApprovalRequestValidator; import com.wultra.app.onboardingserver.impl.service.validation.OnboardingConsentTextRequestValidator; @@ -41,7 +41,6 @@ import com.wultra.app.onboardingserver.statemachine.enums.OnboardingState; import com.wultra.app.onboardingserver.statemachine.service.StateMachineService; import io.getlime.core.rest.model.base.request.ObjectRequest; -import io.getlime.core.rest.model.base.response.ErrorResponse; import io.getlime.core.rest.model.base.response.ObjectResponse; import io.getlime.core.rest.model.base.response.Response; import io.getlime.security.powerauth.rest.api.spring.authentication.PowerAuthApiAuthentication; @@ -58,6 +57,7 @@ import org.springframework.statemachine.StateMachine; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; import java.util.Map; @@ -342,7 +342,7 @@ public ObjectResponse initVerificationSdk( * @throws OnboardingProcessException Thrown when onboarding process is invalid. */ @Transactional - public ResponseEntity initPresenceCheck(ObjectRequest request, + public ResponseEntity> initPresenceCheck(ObjectRequest request, EncryptionContext encryptionContext, PowerAuthApiAuthentication apiAuthentication) throws IdentityVerificationException, PowerAuthAuthenticationException, PowerAuthEncryptionException, OnboardingProcessException { @@ -359,7 +359,10 @@ public ResponseEntity initPresenceCheck(ObjectRequest stateMachine = stateMachineService.processStateMachineEvent(ownerId, processId, OnboardingEvent.PRESENCE_CHECK_INIT); - return createResponseEntity(stateMachine); + + @SuppressWarnings("unchecked") + final Class> presenceCheckInitResponseClass = (Class>) new ObjectResponse().getClass(); + return createResponseEntity(stateMachine, presenceCheckInitResponseClass); } /** @@ -622,14 +625,14 @@ private OwnerId extractOwnerId(final String activationId) throws OnboardingProce return ownerId; } - private ResponseEntity createResponseEntity(StateMachine stateMachine) { - Response response = stateMachine.getExtendedState().get(ExtendedStateVariable.RESPONSE_OBJECT, Response.class); - HttpStatus status = stateMachine.getExtendedState().get(ExtendedStateVariable.RESPONSE_STATUS, HttpStatus.class); - if (response == null || status == null) { - logger.warn("Missing one of important values to generate response entity, response={}, status={}", response, status); - response = new ErrorResponse("UNEXPECTED_ERROR", "Unexpected error occurred."); - status = HttpStatus.INTERNAL_SERVER_ERROR; - } + private ResponseEntity createResponseEntity(final StateMachine stateMachine) { + return createResponseEntity(stateMachine, Response.class); + } + + private ResponseEntity createResponseEntity(final StateMachine stateMachine, Class responseClass) { + final T response = stateMachine.getExtendedState().get(ExtendedStateVariable.RESPONSE_OBJECT, responseClass); + final HttpStatus status = stateMachine.getExtendedState().get(ExtendedStateVariable.RESPONSE_STATUS, HttpStatus.class); + Assert.state(response != null && status != null, "Missing one of important values to generate response entity, response=%s, status=%s".formatted(response, status)); return new ResponseEntity<>(response, status); } diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/IdentityVerificationService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/IdentityVerificationService.java index 7c02d6c1e..f4c8c2d8e 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/IdentityVerificationService.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/IdentityVerificationService.java @@ -38,11 +38,11 @@ import com.wultra.app.onboardingserver.common.service.OnboardingProcessLimitService; import com.wultra.app.onboardingserver.configuration.IdentityVerificationConfig; import com.wultra.app.onboardingserver.errorhandling.DocumentSubmitException; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; import com.wultra.app.onboardingserver.errorhandling.IdentityVerificationNotFoundException; import com.wultra.app.onboardingserver.impl.service.document.DocumentProcessingService; import com.wultra.app.onboardingserver.impl.service.verification.VerificationProcessingService; -import com.wultra.app.onboardingserver.provider.DocumentVerificationProvider; +import com.wultra.app.onboardingserver.api.provider.DocumentVerificationProvider; import com.wultra.app.onboardingserver.statemachine.guard.document.RequiredDocumentTypesCheck; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/ImageProcessor.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/ImageProcessor.java index 5b8252206..2b4db63ed 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/ImageProcessor.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/ImageProcessor.java @@ -20,7 +20,7 @@ import com.google.common.io.Files; import com.wultra.app.enrollmentserver.model.integration.Image; import com.wultra.app.enrollmentserver.model.integration.OwnerId; -import com.wultra.app.onboardingserver.errorhandling.PresenceCheckException; +import com.wultra.app.onboardingserver.api.errorhandling.PresenceCheckException; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckService.java index f72931cf6..12471f923 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckService.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckService.java @@ -21,6 +21,9 @@ import com.google.common.base.Preconditions; import com.wultra.app.enrollmentserver.model.enumeration.*; import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.PresenceCheckException; +import com.wultra.app.onboardingserver.api.provider.PresenceCheckProvider; import com.wultra.app.onboardingserver.common.database.DocumentVerificationRepository; import com.wultra.app.onboardingserver.common.database.ScaResultRepository; import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; @@ -31,24 +34,17 @@ import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; import com.wultra.app.onboardingserver.common.service.AuditService; import com.wultra.app.onboardingserver.configuration.IdentityVerificationConfig; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; -import com.wultra.app.onboardingserver.errorhandling.PresenceCheckException; import com.wultra.app.onboardingserver.errorhandling.PresenceCheckLimitException; import com.wultra.app.onboardingserver.impl.service.document.DocumentProcessingService; import com.wultra.app.onboardingserver.impl.service.internal.JsonSerializationService; -import com.wultra.app.onboardingserver.provider.PresenceCheckProvider; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase.PRESENCE_CHECK; import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus.*; @@ -60,13 +56,9 @@ */ @Service @AllArgsConstructor +@Slf4j public class PresenceCheckService { - private static final Logger logger = LoggerFactory.getLogger(PresenceCheckService.class); - - private static final String SESSION_ATTRIBUTE_TIMESTAMP_LAST_USED = "timestampLastUsed"; - private static final String SESSION_ATTRIBUTE_IMAGE_UPLOADED = "imageUploaded"; - private final IdentityVerificationConfig identityVerificationConfig; private final DocumentVerificationRepository documentVerificationRepository; private final DocumentProcessingService documentProcessingService; @@ -130,7 +122,7 @@ public void checkPresenceVerification( final OwnerId ownerId, final IdentityVerificationEntity idVerification) throws PresenceCheckException, RemoteCommunicationException { - final SessionInfo sessionInfo = updateSessionInfo(ownerId, idVerification, Map.of(SESSION_ATTRIBUTE_TIMESTAMP_LAST_USED, ownerId.getTimestamp())); + final SessionInfo sessionInfo = updateSessionInfo(ownerId, idVerification, Map.of(SessionInfo.ATTRIBUTE_TIMESTAMP_LAST_USED, ownerId.getTimestamp())); final PresenceCheckResult result = presenceCheckProvider.getResult(ownerId, sessionInfo); auditService.auditPresenceCheckProvider(idVerification, "Got presence check result: {} for user: {}", result.getStatus(), ownerId.getUserId()); @@ -141,13 +133,20 @@ public void checkPresenceVerification( logger.debug("Processing a result of an accepted presence check, {}", ownerId); } - // Process the photo irrespective of the result status - final Image photo = result.getPhoto(); - if (photo == null) { - evaluatePresenceCheckResult(ownerId, idVerification, result); - throw new PresenceCheckException("Missing person photo from presence verification, " + ownerId); + if (!documentProcessingService.shouldDocumentProviderStoreSelfie()) { + logger.debug("Selfie will not be submitted to document provider, {}", ownerId); + } else if (result.getPhoto() == null) { + logger.warn("Missing person photo from presence verification, {}", ownerId); + } else { + logger.debug("Obtained a person photo from the presence verification, {}", ownerId); + submitSelfiePhoto(ownerId, idVerification, result); } - logger.debug("Obtained a photo from the result, {}", ownerId); + + evaluatePresenceCheckResult(ownerId, idVerification, result); + } + + private void submitSelfiePhoto(final OwnerId ownerId, final IdentityVerificationEntity idVerification, final PresenceCheckResult result) { + final Image photo = result.getPhoto(); final SubmittedDocument submittedDoc = new SubmittedDocument(); // TODO use different random id approach @@ -157,22 +156,20 @@ public void checkPresenceVerification( submittedDoc.setPhoto(photo); submittedDoc.setType(DocumentType.SELFIE_PHOTO); - DocumentVerificationEntity docVerificationEntity = new DocumentVerificationEntity(); + final DocumentVerificationEntity docVerificationEntity = new DocumentVerificationEntity(); docVerificationEntity.setActivationId(ownerId.getActivationId()); docVerificationEntity.setIdentityVerification(idVerification); - docVerificationEntity.setFilename(result.getPhoto().getFilename()); + docVerificationEntity.setFilename(photo.getFilename()); docVerificationEntity.setTimestampCreated(ownerId.getTimestamp()); docVerificationEntity.setType(DocumentType.SELFIE_PHOTO); docVerificationEntity.setUsedForVerification(identityVerificationConfig.isVerifySelfieWithDocumentsEnabled()); - DocumentSubmitResult documentSubmitResult = + final DocumentSubmitResult documentSubmitResult = documentProcessingService.submitDocumentToProvider(ownerId, docVerificationEntity, submittedDoc); docVerificationEntity.setTimestampUploaded(ownerId.getTimestamp()); docVerificationEntity.setUploadId(documentSubmitResult.getUploadId()); documentVerificationRepository.save(docVerificationEntity); - - evaluatePresenceCheckResult(ownerId, idVerification, result); } /** @@ -184,9 +181,9 @@ public void checkPresenceVerification( */ public void cleanup(OwnerId ownerId) throws PresenceCheckException, RemoteCommunicationException { if (identityVerificationConfig.isPresenceCheckCleanupEnabled()) { - presenceCheckProvider.cleanupIdentityData(ownerId); final IdentityVerificationEntity identityVerification = identityVerificationService.findByOptional(ownerId).orElseThrow(() -> new PresenceCheckException("Unable to find identity verification for " + ownerId)); + presenceCheckProvider.cleanupIdentityData(ownerId, deserializeSessionInfo(identityVerification, ownerId)); auditService.auditPresenceCheckProvider(identityVerification, "Clean up presence check data for user: {}", ownerId.getUserId()); } else { logger.debug("Skipped cleanup of presence check data at the provider (not enabled), {}", ownerId); @@ -198,7 +195,7 @@ public void cleanup(OwnerId ownerId) throws PresenceCheckException, RemoteCommun * * @param ownerId Owner identification. * @param idVerification Verification identity. - * @throws DocumentVerificationException When not able to find documet image. + * @throws DocumentVerificationException When not able to find document image. * @throws PresenceCheckException In case of business logic error. * @throws RemoteCommunicationException In case of remote communication error. */ @@ -212,20 +209,41 @@ private void initPresentCheckWithImage(final OwnerId ownerId, final IdentityVeri return; } - final List docsWithPhoto = documentVerificationRepository.findAllWithPhoto(idVerification); - if (docsWithPhoto.isEmpty()) { - throw new PresenceCheckException("Unable to initialize presence check - missing person photo, " + ownerId); - } else { - final Image photo = selectPhotoForPresenceCheck(ownerId, docsWithPhoto); - final Image upscaledPhoto = imageProcessor.upscaleImage(ownerId, photo, identityVerificationConfig.getMinimalSelfieWidth()); - presenceCheckProvider.initPresenceCheck(ownerId, upscaledPhoto); - logger.info("Presence check initialized, {}", ownerId); - updateSessionInfo(ownerId, idVerification, Map.of(SESSION_ATTRIBUTE_IMAGE_UPLOADED, true)); - auditService.auditPresenceCheckProvider(idVerification, "Presence check initialized for user: {}", ownerId.getUserId()); - } + final Optional photo = fetchTrustedPhoto(ownerId, idVerification); + setIdentityDocumentReferences(ownerId, idVerification); + presenceCheckProvider.initPresenceCheck(ownerId, photo.orElse(null)); + logger.info("Presence check initialized, {}", ownerId); + updateSessionInfo(ownerId, idVerification, Map.of(SessionInfo.ATTRIBUTE_IMAGE_UPLOADED, true)); + auditService.auditPresenceCheckProvider(idVerification, "Presence check initialized for user: {}", ownerId.getUserId()); + } + } + + private Optional fetchTrustedPhoto(final OwnerId ownerId, final IdentityVerificationEntity idVerification) throws DocumentVerificationException, RemoteCommunicationException, PresenceCheckException { + if (PresenceCheckProvider.TrustedPhotoSource.IMAGE == presenceCheckProvider.trustedPhotoSource()) { + final Image photo = fetchTrustedPhotoFromDocumentVerifier(ownerId, idVerification); + final Image upscaledPhoto = imageProcessor.upscaleImage(ownerId, photo, identityVerificationConfig.getMinimalSelfieWidth()); + return Optional.of(upscaledPhoto); + } else { + return Optional.empty(); } } + private void setIdentityDocumentReferences(final OwnerId ownerId, final IdentityVerificationEntity idVerification) throws DocumentVerificationException, PresenceCheckException { + if (PresenceCheckProvider.TrustedPhotoSource.REFERENCE != presenceCheckProvider.trustedPhotoSource()) { + return; + } + + final List docsWithPhoto = getDocsWithPhoto(idVerification, ownerId); + final String primaryDocReference = getPreferredDocWithPhoto(docsWithPhoto, ownerId).getPhotoId(); + final List otherDocsReferences = docsWithPhoto.stream() + .map(DocumentVerificationEntity::getPhotoId) + .filter(id -> !Objects.equals(id, primaryDocReference)) + .distinct().toList(); + + updateSessionInfo(ownerId, idVerification, Map.of(SessionInfo.ATTRIBUTE_PRIMARY_DOCUMENT_REFERENCE, primaryDocReference, + SessionInfo.ATTRIBUTE_OTHER_DOCUMENTS_REFERENCES, otherDocsReferences)); + } + /** * Starts new presence check process. * @@ -249,33 +267,49 @@ private SessionInfo startPresenceCheck(OwnerId ownerId, IdentityVerificationEnti /** * Selects person photo for the presence check process * @param ownerId Owner identification. - * @param docsWithPhoto Documents with a mined person photography. + * @param idVerification Verification identity. * @return Image with a person photography * @throws RemoteCommunicationException In case of remote communication error. * @throws DocumentVerificationException In case of business logic error. */ - protected Image selectPhotoForPresenceCheck(OwnerId ownerId, List docsWithPhoto) throws DocumentVerificationException, RemoteCommunicationException { + protected Image fetchTrustedPhotoFromDocumentVerifier(final OwnerId ownerId, final IdentityVerificationEntity idVerification) + throws DocumentVerificationException, RemoteCommunicationException { + + final List docsWithPhoto = getDocsWithPhoto(idVerification, ownerId); + final DocumentVerificationEntity preferredDocWithPhoto = getPreferredDocWithPhoto(docsWithPhoto, ownerId); + logger.info("Selected {} as the source of person photo, {}", preferredDocWithPhoto, ownerId); + final String photoId = preferredDocWithPhoto.getPhotoId(); + return identityVerificationService.getPhotoById(photoId, ownerId); + } + + private List getDocsWithPhoto(final IdentityVerificationEntity idVerification, + final OwnerId ownerId) throws DocumentVerificationException { + final List docsWithPhoto = documentVerificationRepository.findAllWithPhoto(idVerification); + if (docsWithPhoto.isEmpty()) { + throw new DocumentVerificationException("Unable to initialize presence check - missing person photo, " + ownerId); + } + docsWithPhoto.forEach(docWithPhoto -> Preconditions.checkNotNull(docWithPhoto.getPhotoId(), "Expected photoId value in " + docWithPhoto) ); - DocumentVerificationEntity preferredDocWithPhoto = null; + return docsWithPhoto; + } + + private DocumentVerificationEntity getPreferredDocWithPhoto(final List docsWithPhoto, + final OwnerId ownerId) { + for (DocumentType documentType : DocumentType.PREFERRED_SOURCE_OF_PERSON_PHOTO) { Optional docEntity = docsWithPhoto.stream() - .filter(value -> documentType.equals(value.getType())) + .filter(value -> value.getType() == documentType) .findFirst(); if (docEntity.isPresent()) { - preferredDocWithPhoto = docEntity.get(); - break; + return docEntity.get(); } } - if (preferredDocWithPhoto == null) { - logger.warn("Unable to select a source of person photo to initialize presence check, {}", ownerId); - preferredDocWithPhoto = docsWithPhoto.get(0); - } - logger.info("Selected {} as the source of person photo, {}", preferredDocWithPhoto, ownerId); - String photoId = preferredDocWithPhoto.getPhotoId(); - return identityVerificationService.getPhotoById(photoId, ownerId); + + logger.warn("Unable to select a source of person photo to initialize presence check, {}", ownerId); + return docsWithPhoto.get(0); } private void evaluatePresenceCheckResult(OwnerId ownerId, @@ -352,13 +386,18 @@ private IdentityVerificationEntity fetchIdVerification(OwnerId ownerId) throws P } private SessionInfo updateSessionInfo(final OwnerId ownerId, final IdentityVerificationEntity identityVerification, final Map sessionAttributes) throws PresenceCheckException { + final SessionInfo sessionInfo = deserializeSessionInfo(identityVerification, ownerId); + sessionInfo.getSessionAttributes().putAll(sessionAttributes); + identityVerification.setSessionInfo(jsonSerializationService.serialize(sessionInfo)); + return sessionInfo; + } + + private SessionInfo deserializeSessionInfo(final IdentityVerificationEntity identityVerification, final OwnerId ownerId) throws PresenceCheckException { final String sessionInfoString = StringUtils.defaultIfEmpty(identityVerification.getSessionInfo(), "{}"); final SessionInfo sessionInfo = jsonSerializationService.deserialize(sessionInfoString, SessionInfo.class); if (sessionInfo == null) { throw new PresenceCheckException("Unable to parse SessionInfo, identity verification ID: %s, %s".formatted(identityVerification.getId(), ownerId)); } - sessionInfo.getSessionAttributes().putAll(sessionAttributes); - identityVerification.setSessionInfo(jsonSerializationService.serialize(sessionInfo)); return sessionInfo; } @@ -370,6 +409,6 @@ private boolean imageAlreadyUploaded(final IdentityVerificationEntity identityVe final SessionInfo sessionInfo = jsonSerializationService.deserialize(sessionInfoString, SessionInfo.class); return sessionInfo != null && !CollectionUtils.isEmpty(sessionInfo.getSessionAttributes()) - && Boolean.TRUE.equals(sessionInfo.getSessionAttributes().get(SESSION_ATTRIBUTE_IMAGE_UPLOADED)); + && Boolean.TRUE.equals(sessionInfo.getSessionAttributes().get(SessionInfo.ATTRIBUTE_IMAGE_UPLOADED)); } } diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingBatchService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingBatchService.java index cb3cda261..2ffb2e34a 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingBatchService.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingBatchService.java @@ -22,6 +22,8 @@ import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; import com.wultra.app.enrollmentserver.model.enumeration.DocumentStatus; import com.wultra.app.enrollmentserver.model.integration.OwnerId; +import com.wultra.app.onboardingserver.configuration.IdentityVerificationConfig; +import lombok.AllArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -37,6 +39,7 @@ * @author Lukas Lukovsky, lukas.lukovsky@wultra.com */ @Service +@AllArgsConstructor public class DocumentProcessingBatchService { private static final Logger logger = LoggerFactory.getLogger(DocumentProcessingBatchService.class); @@ -45,18 +48,7 @@ public class DocumentProcessingBatchService { 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; - } + private final IdentityVerificationConfig identityVerificationConfig; /** * Checks in progress document submits on current provider status and data result @@ -64,7 +56,7 @@ public DocumentProcessingBatchService( @Transactional public void checkInProgressDocumentSubmits() { AtomicInteger countFinished = new AtomicInteger(0); - try (Stream stream = documentResultRepository.streamAllInProgressDocumentSubmits()) { + try (Stream stream = documentResultRepository.streamAllInProgressDocumentSubmits(identityVerificationConfig.getDocumentVerificationProvider())) { stream.forEach(docResult -> { DocumentVerificationEntity docVerification = docResult.getDocumentVerification(); final OwnerId ownerId = new OwnerId(); diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingService.java index 4d0e2b736..7dae9452b 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingService.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingService.java @@ -17,6 +17,7 @@ */ package com.wultra.app.onboardingserver.impl.service.document; +import com.google.common.base.Strings; import com.wultra.app.enrollmentserver.api.model.onboarding.request.DocumentSubmitRequest; import com.wultra.app.enrollmentserver.model.Document; import com.wultra.app.enrollmentserver.model.DocumentMetadata; @@ -32,9 +33,9 @@ import com.wultra.app.onboardingserver.common.service.CommonOnboardingService; import com.wultra.app.onboardingserver.configuration.IdentityVerificationConfig; import com.wultra.app.onboardingserver.errorhandling.DocumentSubmitException; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; import com.wultra.app.onboardingserver.impl.service.DataExtractionService; -import com.wultra.app.onboardingserver.provider.DocumentVerificationProvider; +import com.wultra.app.onboardingserver.api.provider.DocumentVerificationProvider; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -43,10 +44,9 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Optional; +import java.util.*; + +import static java.util.stream.Collectors.groupingBy; /** * Service implementing document processing features. @@ -119,22 +119,44 @@ public List submitDocuments( DocumentSubmitRequest request, OwnerId ownerId) throws DocumentSubmitException { - List documents = getDocuments(ownerId, request); - - List docVerifications = new ArrayList<>(); - List docResults = new ArrayList<>(); + checkDocumentResubmit(ownerId, request); + final List documents = getDocuments(ownerId, request); + final var documentsByType = request.getDocuments().stream() + .collect(groupingBy(DocumentSubmitRequest.DocumentMetadata::getType)); - List docsMetadata = request.getDocuments(); - for (DocumentSubmitRequest.DocumentMetadata docMetadata : docsMetadata) { - DocumentVerificationEntity docVerification = createDocumentVerification(ownerId, idVerification, docMetadata); - docVerification.setIdentityVerification(idVerification); - docVerifications.add(docVerification); + final List docVerifications = new ArrayList<>(); + for (var docMetadataList : documentsByType.values()) { + docVerifications.addAll(submitDocument(docMetadataList, documents, idVerification, ownerId)); + } + return docVerifications; + } - checkDocumentResubmit(ownerId, request, docVerification); + /** + * Submit pages of a document to document verify provider. + * @param pagesMetadata Pages metadata from request. + * @param pagesData Pages data. + * @param idVerification Identity verification entity. + * @param ownerId Owner identification. + * @return + */ + private List submitDocument(final List pagesMetadata, + final List pagesData, + final IdentityVerificationEntity idVerification, + final OwnerId ownerId) { + + // Maps are used to associate DocumentsSubmitResult - DocumentVerificationEntities - DocumentMetadata + final Map docVerifications = new HashMap<>(); + final Map docMetadataMap = new HashMap<>(); + + final List submittedDocuments = new ArrayList<>(); + for (var metadata : pagesMetadata) { + final DocumentVerificationEntity docVerification = createDocumentVerification(ownerId, idVerification, metadata); + docVerifications.put(docVerification.getId(), docVerification); + docMetadataMap.put(docVerification.getId(), metadata); + handleResubmit(ownerId, metadata.getOriginalDocumentId(), docVerification); - SubmittedDocument submittedDoc; try { - submittedDoc = createSubmittedDocument(ownerId, docMetadata, documents); + submittedDocuments.add(createSubmittedDocument(ownerId, metadata, pagesData, docVerification)); } catch (DocumentSubmitException e) { logger.warn("Document verification ID: {}, failed: {}", docVerification.getId(), e.getMessage()); logger.debug("Document verification ID: {}, failed", docVerification.getId(), e); @@ -142,60 +164,98 @@ public List submitDocuments( docVerification.setErrorDetail(ErrorDetail.DOCUMENT_VERIFICATION_FAILED); docVerification.setErrorOrigin(ErrorOrigin.DOCUMENT_VERIFICATION); auditService.audit(docVerification, "Document verification failed for user: {}", ownerId.getUserId()); - return docVerifications; + return docVerifications.values().stream().toList(); } + } - DocumentSubmitResult docSubmitResult = submitDocumentToProvider(ownerId, docVerification, submittedDoc); + final List docVerificationsList = docVerifications.values().stream().toList(); + final DocumentsSubmitResult results = submitDocumentToProvider(submittedDocuments, docVerificationsList, idVerification, ownerId); + processSubmitResults(results, docVerifications, ownerId); - // TODO - after synchronous submission to document verification provider the document state should be - // set to VERIFICATION_PENDING, for asynchronous processing the UPLOAD_IN_PROGRESS state should remain + docVerificationsList.stream() + .filter(doc -> StringUtils.isNotBlank(doc.getUploadId())) + .map(doc -> docMetadataMap.get(doc.getId()).getUploadId()) + .filter(StringUtils::isNotBlank) + .forEach(fileUploadId -> { + documentDataRepository.deleteById(fileUploadId); + logger.info("Deleted stored document data with id={}, {}", fileUploadId, ownerId); + }); - DocumentResultEntity docResult = createDocumentResult(docVerification, docSubmitResult); - docResult.setTimestampCreated(ownerId.getTimestamp()); + return docVerificationsList; + } - docResults.add(docResult); + /** + * Process submit results. + * @param results Document submit result from provider. + * @param docVerificationsMap To pair results with corresponding DocumentVerificationEntity. + * @param ownerId Owner identification. + */ + private void processSubmitResults(final DocumentsSubmitResult results, + final Map docVerificationsMap, + final OwnerId ownerId) { - // Delete previously persisted document after a successful upload to the provider - if (docVerification.getUploadId() != null && docMetadata.getUploadId() != null) { - documentDataRepository.deleteById(docMetadata.getUploadId()); - } - } + final List docResults = new ArrayList<>(); + + for (final DocumentSubmitResult result : results.getResults()) { + final DocumentVerificationEntity docVerification = docVerificationsMap.get(result.getDocumentId()); + processDocsSubmitResults(ownerId, docVerification, results, result); - documentVerificationRepository.saveAll(docVerifications); + final DocumentResultEntity docResult = createDocumentResult(docVerification, result); + docResult.setTimestampCreated(ownerId.getTimestamp()); + docResult.setDocumentVerification(docVerification); - for (int i = 0; i < docVerifications.size(); i++) { - DocumentVerificationEntity docVerificationEntity = docVerifications.get(i); - docResults.get(i).setDocumentVerification(docVerificationEntity); + docResults.add(docResult); } - documentResultRepository.saveAll(docResults); - return docVerifications; + documentVerificationRepository.saveAll(docVerificationsMap.values()); + documentResultRepository.saveAll(docResults); + logger.debug("Processed submit result of documents {}, {}", docVerificationsMap.values(), ownerId); } - public void checkDocumentResubmit(OwnerId ownerId, - DocumentSubmitRequest request, - DocumentVerificationEntity docVerification) throws DocumentSubmitException { - if (request.isResubmit() && docVerification.getOriginalDocumentId() == null) { - throw new DocumentSubmitException( - String.format("Detected a resubmit request without specified originalDocumentId for %s, %s", docVerification, ownerId)); - } else if (request.isResubmit()) { - Optional originalDocOptional = - documentVerificationRepository.findById(docVerification.getOriginalDocumentId()); - if (originalDocOptional.isEmpty()) { - logger.warn("Missing previous DocumentVerificationEntity(originalDocumentId={}), {}", - docVerification.getOriginalDocumentId(), ownerId); - } else { - DocumentVerificationEntity originalDoc = originalDocOptional.get(); - originalDoc.setStatus(DocumentStatus.DISPOSED); - originalDoc.setUsedForVerification(false); - originalDoc.setTimestampDisposed(ownerId.getTimestamp()); - originalDoc.setTimestampLastUpdated(ownerId.getTimestamp()); - logger.info("Replaced previous {} with new {}, {}", originalDocOptional, docVerification, ownerId); - auditService.audit(docVerification, "Document replaced with new one for user: {}", ownerId.getUserId()); + /** + * Validates resubmit parameters of DocumentSubmitRequest. + * @param ownerId Owner identification. + * @param request Request body. + * @throws DocumentSubmitException If request is resubmit without original document ID, or is not resubmit with original document ID + */ + private void checkDocumentResubmit(final OwnerId ownerId, final DocumentSubmitRequest request) throws DocumentSubmitException { + final boolean isResubmit = request.isResubmit(); + for (var metadata : request.getDocuments()) { + final String originalDocumentId = metadata.getOriginalDocumentId(); + if (isResubmit && StringUtils.isBlank(originalDocumentId)) { + logger.debug("Request has resubmit flag but misses originalDocumentId {}, {}", metadata, ownerId); + throw new DocumentSubmitException("Detected a resubmit request without specified originalDocumentId, %s".formatted(ownerId)); + } else if (!isResubmit && StringUtils.isNotBlank(originalDocumentId)) { + logger.debug("Request has originalDocumentId but is not flagged as resubmit {}, {}", metadata, ownerId); + throw new DocumentSubmitException("Detected a submit request with specified originalDocumentId=%s, %s".formatted(originalDocumentId, ownerId)); } - } else if (!request.isResubmit() && docVerification.getOriginalDocumentId() != null) { - throw new DocumentSubmitException( - String.format("Detected a submit request with specified originalDocumentId=%s for %s, %s", docVerification.getOriginalDocumentId(), docVerification, ownerId)); + } + } + + /** + * Sets document with originalDocumentId as disposed. If passed originalDocumentId does not exist, no further action is taken. + * @param ownerId Owner identification. + * @param originalDocumentId Id of the original document. + * @param docVerification Resubmitted document. + */ + private void handleResubmit(final OwnerId ownerId, final String originalDocumentId, final DocumentVerificationEntity docVerification) { + if (Strings.isNullOrEmpty(originalDocumentId)) { + logger.debug("Document {} is not a resubmit {}", docVerification, ownerId); + return; + } + + logger.debug("Document {} is a resubmit, {}", docVerification, ownerId); + final Optional originalDocOptional = documentVerificationRepository.findById(originalDocumentId); + if (originalDocOptional.isEmpty()) { + logger.warn("Missing previous DocumentVerificationEntity(originalDocumentId={}), {}", originalDocumentId, ownerId); + } else { + final DocumentVerificationEntity originalDoc = originalDocOptional.get(); + originalDoc.setStatus(DocumentStatus.DISPOSED); + originalDoc.setUsedForVerification(false); + originalDoc.setTimestampDisposed(ownerId.getTimestamp()); + originalDoc.setTimestampLastUpdated(ownerId.getTimestamp()); + logger.info("Replaced previous {} with new {}, {}", originalDoc, docVerification, ownerId); + auditService.audit(docVerification, "Document replaced with new one for user: {}", ownerId.getUserId()); } } @@ -243,6 +303,41 @@ public void checkDocumentSubmitWithProvider(OwnerId ownerId, DocumentResultEntit processDocsSubmitResults(ownerId, docVerification, docsSubmitResults, docSubmitResult); } + /** + * Pass all pages of a document to document verification provider at a single call. + * @param submittedDocs Document pages to submit. + * @param docVerifications Entities associated with the document pages to submit. + * @param identityVerification Identity verification entity. + * @param ownerId Owner identification. + * @return document submit result + */ + private DocumentsSubmitResult submitDocumentToProvider(final List submittedDocs, + final List docVerifications, + final IdentityVerificationEntity identityVerification, + final OwnerId ownerId) { + + final List docVerificationIds = docVerifications.stream().map(DocumentVerificationEntity::getId).toList(); + + try { + final DocumentsSubmitResult results = documentVerificationProvider.submitDocuments(ownerId, submittedDocs); + logger.debug("Documents {} submitted to provider, {}", docVerifications, ownerId); + auditService.auditDocumentVerificationProvider(identityVerification, "Submit documents for user: {}, document IDs: {}", ownerId.getUserId(), docVerificationIds); + return results; + } catch (DocumentVerificationException | RemoteCommunicationException e) { + logger.warn("Document verification ID: {}, failed: {}", docVerificationIds, e.getMessage()); + logger.debug("Document verification ID: {}, failed", docVerificationIds, e); + final DocumentsSubmitResult results = new DocumentsSubmitResult(); + submittedDocs.forEach(doc -> { + final DocumentSubmitResult result = new DocumentSubmitResult(); + result.setDocumentId(doc.getDocumentId()); + result.setErrorDetail(e.getMessage()); + results.getResults().add(result); + }); + return results; + } + + } + public DocumentSubmitResult submitDocumentToProvider(OwnerId ownerId, DocumentVerificationEntity docVerification, SubmittedDocument submittedDoc) { DocumentsSubmitResult docsSubmitResults; DocumentSubmitResult docSubmitResult; @@ -263,6 +358,10 @@ public DocumentSubmitResult submitDocumentToProvider(OwnerId ownerId, DocumentVe return docSubmitResult; } + public boolean shouldDocumentProviderStoreSelfie() { + return documentVerificationProvider.shouldStoreSelfie(); + } + /** * Upload a single document related to identity verification. * @param idVerification Identity verification entity. @@ -289,8 +388,8 @@ public void pairTwoSidedDocuments(List documents) { continue; } documents.stream() - .filter(item -> item.getType().equals(document.getType())) - .filter(item -> !item.getSide().equals(document.getSide())) + .filter(item -> item.getType() == document.getType()) + .filter(item -> item.getSide() != document.getSide()) .forEach(item -> { logger.debug("Found other side {} for {}", item, document); item.setOtherSideId(document.getId()); @@ -349,6 +448,7 @@ private DocumentVerificationEntity createDocumentVerification(OwnerId ownerId, I entity.setTimestampCreated(ownerId.getTimestamp()); entity.setUsedForVerification(true); final DocumentVerificationEntity saveEntity = documentVerificationRepository.save(entity); + logger.debug("Created {} for {}", saveEntity, ownerId); auditService.auditDebug(entity, "Document created for user: {}", ownerId.getUserId()); return saveEntity; } @@ -356,13 +456,14 @@ private DocumentVerificationEntity createDocumentVerification(OwnerId ownerId, I private SubmittedDocument createSubmittedDocument( OwnerId ownerId, DocumentSubmitRequest.DocumentMetadata docMetadata, - List docs) throws DocumentSubmitException { + List docs, + DocumentVerificationEntity docVerification) throws DocumentSubmitException { final Image photo = Image.builder() .filename(docMetadata.getFilename()) .build(); SubmittedDocument submittedDoc = new SubmittedDocument(); - submittedDoc.setDocumentId(docMetadata.getUploadId()); + submittedDoc.setDocumentId(docVerification.getId()); submittedDoc.setPhoto(photo); submittedDoc.setSide(docMetadata.getSide()); submittedDoc.setType(docMetadata.getType()); diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentVerificationService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentVerificationService.java index a979d0476..7c4220432 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentVerificationService.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentVerificationService.java @@ -32,9 +32,9 @@ import com.wultra.app.onboardingserver.common.service.AuditService; import com.wultra.app.onboardingserver.common.service.CommonOnboardingService; import com.wultra.app.onboardingserver.common.service.OnboardingProcessLimitService; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; import com.wultra.app.onboardingserver.impl.service.IdentityVerificationService; -import com.wultra.app.onboardingserver.provider.DocumentVerificationProvider; +import com.wultra.app.onboardingserver.api.provider.DocumentVerificationProvider; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/verification/VerificationProcessingBatchService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/verification/VerificationProcessingBatchService.java index 71b5e2761..34672929b 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/verification/VerificationProcessingBatchService.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/verification/VerificationProcessingBatchService.java @@ -30,9 +30,11 @@ import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; import com.wultra.app.onboardingserver.common.service.AuditService; import com.wultra.app.onboardingserver.common.service.CommonOnboardingService; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.configuration.IdentityVerificationConfig; import com.wultra.app.onboardingserver.impl.service.IdentityVerificationService; -import com.wultra.app.onboardingserver.provider.DocumentVerificationProvider; +import com.wultra.app.onboardingserver.api.provider.DocumentVerificationProvider; +import lombok.AllArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -49,6 +51,7 @@ * @author Lukas Lukovsky, lukas.lukovsky@wultra.com */ @Service +@AllArgsConstructor public class VerificationProcessingBatchService { private static final Logger logger = LoggerFactory.getLogger(VerificationProcessingBatchService.class); @@ -67,34 +70,7 @@ public class VerificationProcessingBatchService { private final CommonOnboardingService commonOnboardingService; - /** - * Service constructor. - * @param documentResultRepository Document verification result repository. - * @param documentVerificationProvider Document verification provider. - * @param identityVerificationRepository Identity verification repository. - * @param identityVerificationService Identity verification service. - * @param verificationProcessingService Verification processing service. - * @param auditService Audit service. - * @param commonOnboardingService Onboarding process service (common). - */ - @Autowired - public VerificationProcessingBatchService( - final DocumentResultRepository documentResultRepository, - final DocumentVerificationProvider documentVerificationProvider, - final IdentityVerificationRepository identityVerificationRepository, - final IdentityVerificationService identityVerificationService, - final VerificationProcessingService verificationProcessingService, - final AuditService auditService, - final CommonOnboardingService commonOnboardingService) { - - this.documentResultRepository = documentResultRepository; - this.identityVerificationRepository = identityVerificationRepository; - this.documentVerificationProvider = documentVerificationProvider; - this.identityVerificationService = identityVerificationService; - this.verificationProcessingService = verificationProcessingService; - this.auditService = auditService; - this.commonOnboardingService = commonOnboardingService; - } + private final IdentityVerificationConfig identityVerificationConfig; /** * Checks document submit verifications @@ -102,7 +78,7 @@ public VerificationProcessingBatchService( @Transactional public void checkDocumentSubmitVerifications() { AtomicInteger countFinished = new AtomicInteger(0); - try (Stream stream = documentResultRepository.streamAllInProgressDocumentSubmitVerifications()) { + try (Stream stream = documentResultRepository.streamAllInProgressDocumentSubmitVerifications(identityVerificationConfig.getDocumentVerificationProvider())) { stream.forEach(docResult -> { DocumentVerificationEntity docVerification = docResult.getDocumentVerification(); diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/verification/VerificationProcessingService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/verification/VerificationProcessingService.java index d3455a432..233c558a9 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/verification/VerificationProcessingService.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/verification/VerificationProcessingService.java @@ -24,7 +24,7 @@ import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; import com.wultra.app.onboardingserver.common.database.entity.ErrorDetail; import com.wultra.app.onboardingserver.common.service.AuditService; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; import com.wultra.app.enrollmentserver.model.integration.DocumentVerificationResult; import com.wultra.app.enrollmentserver.model.integration.DocumentsVerificationResult; import com.wultra.app.enrollmentserver.model.integration.OwnerId; diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java index 5d69b3a62..4be8ac2f5 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java @@ -22,7 +22,7 @@ import com.wultra.app.enrollmentserver.model.integration.OwnerId; import com.wultra.app.enrollmentserver.model.integration.PresenceCheckResult; import com.wultra.app.enrollmentserver.model.integration.SessionInfo; -import com.wultra.app.onboardingserver.provider.PresenceCheckProvider; +import com.wultra.app.onboardingserver.api.provider.PresenceCheckProvider; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -61,6 +61,11 @@ public void initPresenceCheck(OwnerId id, Image photo) { logger.info("Mock - initialized presence check with a photo, {}", id); } + @Override + public TrustedPhotoSource trustedPhotoSource() { + return TrustedPhotoSource.IMAGE; + } + @Override public SessionInfo startPresenceCheck(OwnerId id) { String token = UUID.randomUUID().toString(); @@ -101,7 +106,7 @@ private static byte[] readImage() { } @Override - public void cleanupIdentityData(OwnerId id) { + public void cleanupIdentityData(final OwnerId id, final SessionInfo sessionInfo) { logger.info("Mock - cleaned up identity data, {}", id); } diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/presencecheck/PresenceCheckInitAction.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/presencecheck/PresenceCheckInitAction.java index c720f0116..e8ab93771 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/presencecheck/PresenceCheckInitAction.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/presencecheck/PresenceCheckInitAction.java @@ -19,6 +19,8 @@ import com.wultra.app.enrollmentserver.api.model.onboarding.response.PresenceCheckInitResponse; import com.wultra.app.enrollmentserver.model.integration.OwnerId; import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.PresenceCheckException; import com.wultra.app.onboardingserver.common.errorhandling.IdentityVerificationException; import com.wultra.app.onboardingserver.common.errorhandling.OnboardingProcessLimitException; import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/presencecheck/PresenceCheckVerificationAction.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/presencecheck/PresenceCheckVerificationAction.java index b2d59b197..9ace45692 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/presencecheck/PresenceCheckVerificationAction.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/presencecheck/PresenceCheckVerificationAction.java @@ -21,7 +21,7 @@ import com.wultra.app.enrollmentserver.model.integration.OwnerId; import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity; import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; -import com.wultra.app.onboardingserver.errorhandling.PresenceCheckException; +import com.wultra.app.onboardingserver.api.errorhandling.PresenceCheckException; import com.wultra.app.onboardingserver.impl.service.IdentityVerificationService; import com.wultra.app.onboardingserver.impl.service.PresenceCheckService; import com.wultra.app.onboardingserver.impl.service.internal.JsonSerializationService; diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/verification/DocumentVerificationFinalAction.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/verification/DocumentVerificationFinalAction.java index 3e23c0e0c..d0bc37b3d 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/verification/DocumentVerificationFinalAction.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/verification/DocumentVerificationFinalAction.java @@ -20,7 +20,7 @@ import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity; import com.wultra.app.onboardingserver.common.errorhandling.OnboardingProcessException; import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; import com.wultra.app.onboardingserver.impl.service.document.DocumentVerificationService; import com.wultra.app.onboardingserver.statemachine.consts.EventHeaderName; import com.wultra.app.onboardingserver.statemachine.consts.ExtendedStateVariable; diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/verification/VerificationDocumentStartAction.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/verification/VerificationDocumentStartAction.java index 5f4d2911a..a2ef6f2cf 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/verification/VerificationDocumentStartAction.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/action/verification/VerificationDocumentStartAction.java @@ -19,7 +19,7 @@ import com.wultra.app.enrollmentserver.model.integration.OwnerId; import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity; import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException; -import com.wultra.app.onboardingserver.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; import com.wultra.app.onboardingserver.impl.service.IdentityVerificationService; import com.wultra.app.onboardingserver.statemachine.consts.EventHeaderName; import com.wultra.app.onboardingserver.statemachine.consts.ExtendedStateVariable; diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/interceptor/CustomStateMachineInterceptor.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/interceptor/CustomStateMachineInterceptor.java index b302e2c42..5ee7fc3f0 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/interceptor/CustomStateMachineInterceptor.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/interceptor/CustomStateMachineInterceptor.java @@ -16,6 +16,8 @@ */ package com.wultra.app.onboardingserver.statemachine.interceptor; +import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException; +import com.wultra.app.onboardingserver.api.errorhandling.PresenceCheckException; import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity; import com.wultra.app.onboardingserver.common.errorhandling.IdentityVerificationException; import com.wultra.app.onboardingserver.common.errorhandling.OnboardingProcessException; diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/service/StateMachineService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/service/StateMachineService.java index b871dc4fe..5b1357537 100644 --- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/service/StateMachineService.java +++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/statemachine/service/StateMachineService.java @@ -19,6 +19,7 @@ import com.wultra.app.enrollmentserver.model.integration.OwnerId; import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity; import com.wultra.app.onboardingserver.common.errorhandling.IdentityVerificationException; +import com.wultra.app.onboardingserver.configuration.IdentityVerificationConfig; import com.wultra.app.onboardingserver.impl.service.IdentityVerificationService; import com.wultra.app.onboardingserver.statemachine.EnrollmentStateProvider; import com.wultra.app.onboardingserver.statemachine.consts.EventHeaderName; @@ -27,6 +28,7 @@ import com.wultra.app.onboardingserver.statemachine.enums.OnboardingState; import com.wultra.app.onboardingserver.statemachine.interceptor.CustomStateMachineInterceptor; import jakarta.annotation.Nullable; +import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.messaging.Message; @@ -53,6 +55,7 @@ */ @Service @Slf4j +@AllArgsConstructor @ConditionalOnProperty(value = "enrollment-server-onboarding.identity-verification.enabled", havingValue = "true") public class StateMachineService { @@ -66,27 +69,7 @@ public class StateMachineService { private final TransactionTemplate transactionTemplate; - /** - * Constructor. - * - * @param enrollmentStateProvider Enrollment state provider. - * @param stateMachineFactory State machine factory. - * @param stateMachineInterceptor State machine interceptor. - * @param identityVerificationService Identity verification service. - */ - public StateMachineService( - final EnrollmentStateProvider enrollmentStateProvider, - final StateMachineFactory stateMachineFactory, - final CustomStateMachineInterceptor stateMachineInterceptor, - final IdentityVerificationService identityVerificationService, - final TransactionTemplate transactionTemplate - ) { - this.enrollmentStateProvider = enrollmentStateProvider; - this.stateMachineFactory = stateMachineFactory; - this.stateMachineInterceptor = stateMachineInterceptor; - this.identityVerificationService = identityVerificationService; - this.transactionTemplate = transactionTemplate; - } + private final IdentityVerificationConfig identityVerificationConfig; @Transactional public StateMachine processStateMachineEvent(OwnerId ownerId, String processId, OnboardingEvent event) @@ -144,23 +127,25 @@ public Message createMessage(OwnerId ownerId, String processId, public void changeMachineStatesInBatch() { final AtomicInteger countFinished = new AtomicInteger(0); try (Stream stream = identityVerificationService.streamAllIdentityVerificationsToChangeState().parallel()) { - stream.forEach(identityVerification -> { - final String processId = identityVerification.getProcessId(); - final OwnerId ownerId = new OwnerId(); - ownerId.setActivationId(identityVerification.getActivationId()); - ownerId.setUserId(identityVerification.getUserId()); - logger.debug("Changing state of machine for process ID: {}", processId); - - transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); - transactionTemplate.executeWithoutResult(status -> { - try { - processStateMachineEvent(ownerId, processId, OnboardingEvent.EVENT_NEXT_STATE); - countFinished.incrementAndGet(); - } catch (IdentityVerificationException e) { - logger.warn("Unable to change state for process ID: {}", processId, e); - } - }); - }); + stream.filter(identityVerification -> identityVerification.getDocumentVerifications().stream() + .anyMatch(doc -> identityVerificationConfig.getDocumentVerificationProvider().equals(doc.getProviderName()))) + .forEach(identityVerification -> { + final String processId = identityVerification.getProcessId(); + final OwnerId ownerId = new OwnerId(); + ownerId.setActivationId(identityVerification.getActivationId()); + ownerId.setUserId(identityVerification.getUserId()); + logger.debug("Changing state of machine for process ID: {}", processId); + + transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + transactionTemplate.executeWithoutResult(status -> { + try { + processStateMachineEvent(ownerId, processId, OnboardingEvent.EVENT_NEXT_STATE); + countFinished.incrementAndGet(); + } catch (IdentityVerificationException e) { + logger.warn("Unable to change state for process ID: {}", processId, e); + } + }); + }); } if (countFinished.get() > 0) { logger.debug("Changed state of {} identity verifications", countFinished.get()); diff --git a/enrollment-server-onboarding/src/main/resources/api/api-zenid.json b/enrollment-server-onboarding/src/main/resources/api/api-zenid.json deleted file mode 100644 index d850bfefb..000000000 --- a/enrollment-server-onboarding/src/main/resources/api/api-zenid.json +++ /dev/null @@ -1,2961 +0,0 @@ -{ - "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-onboarding/src/main/resources/application.properties b/enrollment-server-onboarding/src/main/resources/application.properties index e24027d54..09f3c82f0 100644 --- a/enrollment-server-onboarding/src/main/resources/application.properties +++ b/enrollment-server-onboarding/src/main/resources/application.properties @@ -21,13 +21,15 @@ spring.profiles.active=ext spring.application.name=onboarding-server +banner.application.name=${spring.application.name} +banner.application.version=@project.version@ + # Database Configuration - PostgreSQL spring.datasource.url=jdbc:postgresql://localhost:5432/powerauth spring.datasource.username=powerauth spring.datasource.password= spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.hikari.auto-commit=false -spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect spring.jpa.properties.hibernate.connection.characterEncoding=utf8 spring.jpa.properties.hibernate.connection.useUnicode=true @@ -36,7 +38,6 @@ spring.jpa.properties.hibernate.connection.useUnicode=true #spring.datasource.username=powerauth #spring.datasource.password= #spring.datasource.driver-class-name=oracle.jdbc.driver.OracleDriver -#spring.jpa.database-platform=org.hibernate.dialect.OracleDialect # Hibernate Configuration spring.jpa.hibernate.ddl-auto=none @@ -99,6 +100,7 @@ enrollment-server-onboarding.identity-verification.max-failed-attempts-document- # Provider Configuration #enrollment-server-onboarding.document-verification.provider=zenid +#enrollment-server-onboarding.document-verification.provider=innovatrics enrollment-server-onboarding.document-verification.provider=mock enrollment-server-onboarding.document-verification.cleanupEnabled=false enrollment-server-onboarding.document-verification.checkInProgressDocumentSubmits.cron=- @@ -109,6 +111,7 @@ enrollment-server-onboarding.document-verification.required.count=2 enrollment-server-onboarding.presence-check.enabled=true #enrollment-server-onboarding.presence-check.provider=iproov +#enrollment-server-onboarding.presence-check.provider=innovatrics enrollment-server-onboarding.presence-check.provider=mock enrollment-server-onboarding.presence-check.cleanupEnabled=false # Enables/disabled verification of the presence check selfie photo with the documents @@ -164,6 +167,29 @@ enrollment-server-onboarding.presence-check.iproov.restClientConfig.connectionTi enrollment-server-onboarding.presence-check.iproov.restClientConfig.responseTimeout=60000 enrollment-server-onboarding.presence-check.iproov.restClientConfig.maxIdleTime=200s +# Innovatrics common configuration +enrollment-server-onboarding.provider.innovatrics.serviceBaseUrl=${INNOVATRICS_SERVICE_BASE_URL} +enrollment-server-onboarding.provider.innovatrics.serviceToken=${INNOVATRICS_SERVICE_TOKEN} +enrollment-server-onboarding.provider.innovatrics.serviceUserAgent=Wultra/OnboardingServer + +# Innovatrics presence-check configuration +enrollment-server-onboarding.provider.innovatrics.presenceCheckConfiguration.score=0.875 + +# Innovatrics document-verification configuration +enrollment-server-onboarding.provider.innovatrics.documentVerificationConfiguration.documentCountries=CZE + +# Innovatrics REST client configuration +enrollment-server-onboarding.provider.innovatrics.restClientConfig.acceptInvalidSslCertificate=false +enrollment-server-onboarding.provider.innovatrics.restClientConfig.maxInMemorySize=10485760 +enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyEnabled=false +enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyHost= +enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyPort=0 +enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyUsername= +enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyPassword= +enrollment-server-onboarding.provider.innovatrics.restClientConfig.connectionTimeout=10000 +enrollment-server-onboarding.provider.innovatrics.restClientConfig.responseTimeout=60000 +enrollment-server-onboarding.provider.innovatrics.restClientConfig.maxIdleTime=200s + spring.security.oauth2.client.provider.app.token-uri=http://localhost:6060/oauth/token enrollment-server-onboarding.state-machine.changeMachineState.cron=0/3 * * * * * @@ -180,4 +206,10 @@ powerauth.service.correlation-header.enabled=false powerauth.service.correlation-header.name=X-Correlation-ID powerauth.service.correlation-header.value.validation-regexp=[a-zA-Z0-9\\-]{8,1024} # For logging correlation HTTP headers enable the pattern and update correlation header name in the pattern -#logging.pattern.console=%clr(%d{${LOG_DATEFORMAT_PATTERN:yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) [%X{X-Correlation-ID}] %clr(%5p) %clr(${PID: }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx} \ No newline at end of file +#logging.pattern.console=%clr(%d{${LOG_DATEFORMAT_PATTERN:yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) [%X{X-Correlation-ID}] %clr(%5p) %clr(${PID: }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx} + +# Monitoring +#management.endpoint.metrics.enabled=true +#management.endpoints.web.exposure.include=health, prometheus +#management.endpoint.prometheus.enabled=true +#management.prometheus.metrics.export.enabled=true diff --git a/enrollment-server-onboarding/src/main/resources/banner.txt b/enrollment-server-onboarding/src/main/resources/banner.txt new file mode 100644 index 000000000..964cb7bd0 --- /dev/null +++ b/enrollment-server-onboarding/src/main/resources/banner.txt @@ -0,0 +1,9 @@ + ___ _ _ _ ____ + / _ \ _ __ | |__ ___ __ _ _ __ __| (_)_ __ __ _ / ___| ___ _ ____ _____ _ __ + | | | | '_ \| '_ \ / _ \ / _` | '__/ _` | | '_ \ / _` | \___ \ / _ \ '__\ \ / / _ \ '__| + | |_| | | | | |_) | (_) | (_| | | | (_| | | | | | (_| | ___) | __/ | \ V / __/ | + \___/|_| |_|_.__/ \___/ \__,_|_| \__,_|_|_| |_|\__, | |____/ \___|_| \_/ \___|_| + |___/ +${AnsiColor.GREEN} :: ${banner.application.name} (${banner.application.version}) :: ${AnsiColor.GREEN} +${AnsiColor.RED} :: Spring Boot${spring-boot.formatted-version} :: ${AnsiColor.RED} +${AnsiColor.DEFAULT} \ No newline at end of file diff --git a/enrollment-server-onboarding/src/main/webapp/WEB-INF/jboss-deployment-structure.xml b/enrollment-server-onboarding/src/main/webapp/WEB-INF/jboss-deployment-structure.xml index 2e3377e8d..ed6a86c0d 100644 --- a/enrollment-server-onboarding/src/main/webapp/WEB-INF/jboss-deployment-structure.xml +++ b/enrollment-server-onboarding/src/main/webapp/WEB-INF/jboss-deployment-structure.xml @@ -8,7 +8,7 @@ - + diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/AbstractDocumentVerificationProviderTest.java b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/AbstractDocumentVerificationProviderTest.java deleted file mode 100644 index f5c017202..000000000 --- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/AbstractDocumentVerificationProviderTest.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * 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.onboardingserver.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 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) { - assertEquals(documents.size(), result.getResults().size(), "Different size of submitted documents than expected"); - assertNotNull(result.getExtractedPhotoId(), "Missing extracted photoId"); - - final List submittedDocsIds = result.getResults().stream() - .map(DocumentSubmitResult::getDocumentId) - .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-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java index e2f649ca6..bad60c5bd 100644 --- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java +++ b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java @@ -24,7 +24,6 @@ import com.wultra.app.onboardingserver.EnrollmentServerTestApplication; import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity; import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; -import com.wultra.app.onboardingserver.docverify.AbstractDocumentVerificationProviderTest; import com.wultra.app.onboardingserver.docverify.mock.MockConst; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -49,7 +48,7 @@ @ActiveProfiles("test") @ComponentScan(basePackages = {"com.wultra.app.onboardingserver.docverify.mock"}) @EnableConfigurationProperties -class WultraMockDocumentVerificationProviderTest extends AbstractDocumentVerificationProviderTest { +class WultraMockDocumentVerificationProviderTest { private WultraMockDocumentVerificationProvider provider; @@ -66,7 +65,7 @@ public void setProvider(WultraMockDocumentVerificationProvider provider) { } @Test - void checkDocumentUploadTest() { + void checkDocumentUploadTest() throws Exception { SubmittedDocument document = createSubmittedDocument(); DocumentsSubmitResult submitResult = provider.submitDocuments(ownerId, List.of(document)); @@ -179,4 +178,23 @@ private SubmittedDocument createSubmittedDocument() { return document; } + private static void assertSubmittedDocuments(OwnerId ownerId, List documents, DocumentsSubmitResult result) { + assertEquals(documents.size(), result.getResults().size(), "Different size of submitted documents than expected"); + assertNotNull(result.getExtractedPhotoId(), "Missing extracted photoId"); + + final List submittedDocsIds = result.getResults().stream() + .map(DocumentSubmitResult::getDocumentId) + .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-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckServiceTest.java b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckServiceTest.java index 3bef254f5..93ea22fbe 100644 --- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckServiceTest.java +++ b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckServiceTest.java @@ -16,65 +16,137 @@ */ package com.wultra.app.onboardingserver.impl.service; -import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; +import com.wultra.app.enrollmentserver.model.enumeration.CardSide; import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; import com.wultra.app.enrollmentserver.model.integration.Image; import com.wultra.app.enrollmentserver.model.integration.OwnerId; -import org.junit.jupiter.api.BeforeEach; +import com.wultra.app.enrollmentserver.model.integration.SessionInfo; +import com.wultra.app.onboardingserver.EnrollmentServerTestApplication; +import com.wultra.app.onboardingserver.api.provider.PresenceCheckProvider; +import com.wultra.app.onboardingserver.common.database.DocumentVerificationRepository; +import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; +import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity; import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; import java.util.List; +import java.util.Optional; +import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase.PRESENCE_CHECK; +import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus.*; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.*; /** + * Test for {@link PresenceCheckService}. + * * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + * @author Lubos Racansky, lubos.racansky@wultra.com */ +@SpringBootTest(classes = EnrollmentServerTestApplication.class) +@ActiveProfiles("test") class PresenceCheckServiceTest { - @Mock + @MockBean private IdentityVerificationService identityVerificationService; - @InjectMocks - private PresenceCheckService service; + @MockBean + private DocumentVerificationRepository documentVerificationRepository; - @BeforeEach - void init() { - MockitoAnnotations.openMocks(this); - } + @MockBean + private PresenceCheckLimitService presenceCheckLimitService; + + @MockBean + private PresenceCheckProvider presenceCheckProvider; + + @Autowired + private PresenceCheckService tested; @Test - void selectPhotoForPresenceCheckTest() throws Exception { - OwnerId ownerId = new OwnerId(); + void testFetchTrustedPhotoFromDocumentVerifier_reverseOrder() throws Exception { + final OwnerId ownerId = new OwnerId(); + final IdentityVerificationEntity identityVerification = new IdentityVerificationEntity(); - // Two documents with person photo in reversed order of preference - DocumentVerificationEntity docPhotoDrivingLicense = new DocumentVerificationEntity(); + final DocumentVerificationEntity docPhotoDrivingLicense = new DocumentVerificationEntity(); docPhotoDrivingLicense.setPhotoId("drivingLicensePhotoId"); docPhotoDrivingLicense.setType(DocumentType.DRIVING_LICENSE); - DocumentVerificationEntity docPhotoIdCard = new DocumentVerificationEntity(); + final DocumentVerificationEntity docPhotoIdCard = new DocumentVerificationEntity(); docPhotoIdCard.setPhotoId("idCardPhotoId"); docPhotoIdCard.setType(DocumentType.ID_CARD); - List documentsReversedOrder = List.of(docPhotoDrivingLicense, docPhotoIdCard); + final List documentsReversedOrder = List.of(docPhotoDrivingLicense, docPhotoIdCard); + + when(documentVerificationRepository.findAllWithPhoto(identityVerification)) + .thenReturn(documentsReversedOrder); + when(identityVerificationService.getPhotoById(docPhotoIdCard.getPhotoId(), ownerId)) + .thenReturn(Image.builder().build()); + + tested.fetchTrustedPhotoFromDocumentVerifier(ownerId, identityVerification); - service.selectPhotoForPresenceCheck(ownerId, documentsReversedOrder); - when(identityVerificationService.getPhotoById(docPhotoIdCard.getPhotoId(), ownerId)).thenReturn(Image.builder().build()); verify(identityVerificationService, times(1)).getPhotoById(docPhotoIdCard.getPhotoId(), ownerId); + } + + @Test + void testFetchTrustedPhotoFromDocumentVerifier_unknownDocument() throws Exception { + final OwnerId ownerId = new OwnerId(); + final IdentityVerificationEntity identityVerification = new IdentityVerificationEntity(); - // Unknown document with a person photo - DocumentVerificationEntity docPhotoUnknown = new DocumentVerificationEntity(); + final DocumentVerificationEntity docPhotoUnknown = new DocumentVerificationEntity(); docPhotoUnknown.setPhotoId("unknownPhotoId"); docPhotoUnknown.setType(DocumentType.UNKNOWN); - List documentUnknown = List.of(docPhotoUnknown); + when(documentVerificationRepository.findAllWithPhoto(identityVerification)) + .thenReturn(List.of(docPhotoUnknown)); + when(identityVerificationService.getPhotoById(docPhotoUnknown.getPhotoId(), ownerId)) + .thenReturn(Image.builder().build()); + + tested.fetchTrustedPhotoFromDocumentVerifier(ownerId, identityVerification); - service.selectPhotoForPresenceCheck(ownerId, documentUnknown); - when(identityVerificationService.getPhotoById(docPhotoUnknown.getPhotoId(), ownerId)).thenReturn(Image.builder().build()); verify(identityVerificationService, times(1)).getPhotoById(docPhotoUnknown.getPhotoId(), ownerId); } + @Test + void initPresentCheckWithImage_withDocumentReferences() throws Exception { + final OwnerId ownerId = new OwnerId(); + ownerId.setActivationId("a1"); + + final DocumentVerificationEntity page1 = new DocumentVerificationEntity(); + page1.setId("1"); + page1.setType(DocumentType.ID_CARD); + page1.setSide(CardSide.FRONT); + page1.setPhotoId("id_card_portrait"); + + final DocumentVerificationEntity page2 = new DocumentVerificationEntity(); + page2.setId("2"); + page2.setType(DocumentType.ID_CARD); + page2.setSide(CardSide.BACK); + page2.setPhotoId("id_card_portrait"); + + final DocumentVerificationEntity page3 = new DocumentVerificationEntity(); + page3.setId("3"); + page3.setType(DocumentType.DRIVING_LICENSE); + page3.setSide(CardSide.FRONT); + page3.setPhotoId("driving_licence_portrait"); + + when(presenceCheckProvider.trustedPhotoSource()).thenReturn(PresenceCheckProvider.TrustedPhotoSource.REFERENCE); + + final IdentityVerificationEntity identityVerification = new IdentityVerificationEntity(); + identityVerification.setPhase(PRESENCE_CHECK); + identityVerification.setStatus(NOT_INITIALIZED); + + when(documentVerificationRepository.findAllWithPhoto(identityVerification)).thenReturn(List.of(page1, page2, page3)); + when(identityVerificationService.findByOptional(ownerId)).thenReturn(Optional.of(identityVerification)); + when(presenceCheckProvider.startPresenceCheck(ownerId)).thenReturn(new SessionInfo()); + + tested.init(ownerId, "p1"); + + assertTrue(identityVerification.getSessionInfo().contains("\"primaryDocumentReference\":\"id_card_portrait\"")); + assertTrue(identityVerification.getSessionInfo().contains("\"otherDocumentsReferences\":[\"driving_licence_portrait\"]")); + verify(presenceCheckProvider).initPresenceCheck(ownerId, null); + } + } diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.java b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.java index 92fd2aebf..37309ec05 100644 --- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.java +++ b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.java @@ -17,58 +17,274 @@ */ package com.wultra.app.onboardingserver.impl.service.document; +import com.wultra.app.enrollmentserver.api.model.onboarding.request.DocumentSubmitRequest; +import com.wultra.app.enrollmentserver.model.Document; +import com.wultra.app.enrollmentserver.model.enumeration.*; +import com.wultra.app.enrollmentserver.model.integration.*; +import com.wultra.app.onboardingserver.EnrollmentServerTestApplication; +import com.wultra.app.onboardingserver.common.database.DocumentResultRepository; import com.wultra.app.onboardingserver.common.database.DocumentVerificationRepository; +import com.wultra.app.onboardingserver.common.database.IdentityVerificationRepository; +import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity; import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity; -import com.wultra.app.enrollmentserver.model.enumeration.CardSide; -import com.wultra.app.enrollmentserver.model.enumeration.DocumentType; -import org.junit.jupiter.api.BeforeEach; +import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity; +import com.wultra.app.onboardingserver.errorhandling.DocumentSubmitException; +import com.wultra.app.onboardingserver.impl.service.DataExtractionService; import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; /** * @author Lukas Lukovsky, lukas.lukovsky@wultra.com + * @author Jan Pesek, jan.pesek@wultra.com */ +@SpringBootTest(classes = EnrollmentServerTestApplication.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@ActiveProfiles("test") +@Sql class DocumentProcessingServiceTest { - @InjectMocks - DocumentProcessingService service; + @MockBean + DataExtractionService dataExtractionService; - @Mock + @Autowired + DocumentProcessingService tested; + + @Autowired DocumentVerificationRepository documentVerificationRepository; - @BeforeEach - public void init() { - MockitoAnnotations.openMocks(this); + @Autowired + IdentityVerificationRepository identityVerificationRepository; + + @Autowired + DocumentResultRepository documentResultRepository; + + @Test + @Sql + void testPairTwoSidedDocuments() { + tested.pairTwoSidedDocuments(documentVerificationRepository.findAll()); + assertEquals("2", documentVerificationRepository.findById("1").map(DocumentVerificationEntity::getOtherSideId).get()); + assertEquals("1", documentVerificationRepository.findById("2").map(DocumentVerificationEntity::getOtherSideId).get()); + } + + @Test + void testSubmitDocuments() throws Exception { + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get(); + assertNotNull(identityVerification); + + final List metadata = createIdCardMetadata(); + final List data = createIdCardData(); + final OwnerId ownerId = createOwnerId(); + + final DocumentSubmitRequest request = new DocumentSubmitRequest(); + request.setProcessId("p1"); + request.setResubmit(false); + request.setData("files".getBytes()); + request.setDocuments(metadata); + when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data); + + tested.submitDocuments(identityVerification, request, ownerId); + + final List documents = documentVerificationRepository.findAll(); + assertEquals(2, documents.size()); + assertThat(documents) + .extracting(DocumentVerificationEntity::getSide) + .containsExactlyInAnyOrder(CardSide.FRONT, CardSide.BACK); + assertThat(documents) + .extracting(DocumentVerificationEntity::getStatus) + .containsOnly(DocumentStatus.VERIFICATION_PENDING); + + final List results = new ArrayList<>(); + documentResultRepository.findAll().forEach(results::add); + assertEquals(2, results.size()); + assertThat(results) + .extracting(DocumentResultEntity::getDocumentVerification) + .extracting(DocumentVerificationEntity::getId) + .containsExactlyInAnyOrder(documents.stream().map(DocumentVerificationEntity::getId).toArray(String[]::new)); + assertThat(results) + .extracting(DocumentResultEntity::getPhase) + .containsOnly(DocumentProcessingPhase.UPLOAD); + } + + @Test + void testSubmitDocuments_providerThrows() throws Exception { + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get(); + assertNotNull(identityVerification); + + final List metadata = createIdCardMetadata(); + metadata.get(1).setFilename("throw.exception"); + final List data = createIdCardData(); + data.get(1).setFilename("throw.exception"); + final OwnerId ownerId = createOwnerId(); + + final DocumentSubmitRequest request = new DocumentSubmitRequest(); + request.setProcessId("p1"); + request.setResubmit(false); + request.setData("files".getBytes()); + request.setDocuments(metadata); + when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data); + + tested.submitDocuments(identityVerification, request, ownerId); + + final List documents = documentVerificationRepository.findAll(); + assertEquals(2, documents.size()); + assertThat(documents) + .extracting(DocumentVerificationEntity::getSide) + .containsExactlyInAnyOrder(CardSide.FRONT, CardSide.BACK); + assertThat(documents) + .extracting(DocumentVerificationEntity::getStatus) + .containsOnly(DocumentStatus.FAILED); + + final List results = new ArrayList<>(); + documentResultRepository.findAll().forEach(results::add); + assertEquals(2, results.size()); + assertThat(results) + .extracting(DocumentResultEntity::getErrorDetail) + .containsOnly("documentVerificationFailed"); + assertThat(results) + .extracting(DocumentResultEntity::getErrorOrigin) + .containsOnly(ErrorOrigin.DOCUMENT_VERIFICATION); + } + + @Test + void testSubmitDocuments_missingData() throws Exception { + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get(); + assertNotNull(identityVerification); + + final List metadata = createIdCardMetadata(); + final List data = Collections.emptyList(); + final OwnerId ownerId = createOwnerId(); + + final DocumentSubmitRequest request = new DocumentSubmitRequest(); + request.setProcessId("p1"); + request.setResubmit(false); + request.setData("files".getBytes()); + request.setDocuments(metadata); + when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data); + + tested.submitDocuments(identityVerification, request, ownerId); + + List documents = documentVerificationRepository.findAll(); + assertEquals(1, documents.size()); + assertThat(documents) + .extracting(DocumentVerificationEntity::getStatus) + .containsExactlyInAnyOrder(DocumentStatus.FAILED); + } + + @Test + @Sql + void testResubmitDocuments() throws Exception { + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get(); + assertNotNull(identityVerification); + + final List metadata = createIdCardMetadata(); + metadata.get(0).setOriginalDocumentId("original1"); + metadata.get(1).setOriginalDocumentId("original2"); + final List data = createIdCardData(); + final OwnerId ownerId = createOwnerId(); + + final DocumentSubmitRequest request = new DocumentSubmitRequest(); + request.setProcessId("p1"); + request.setResubmit(true); + request.setData("files".getBytes()); + request.setDocuments(metadata); + when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data); + + tested.submitDocuments(identityVerification, request, ownerId); + List documents = documentVerificationRepository.findAll(); + assertEquals(4, documents.size()); + assertThat(documents) + .extracting(DocumentVerificationEntity::getStatus) + .containsOnly(DocumentStatus.VERIFICATION_PENDING, DocumentStatus.DISPOSED); + } + + @Test + void testResubmitDocuments_missingOriginalDocumentId() throws Exception { + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get(); + assertNotNull(identityVerification); + + final List metadata = createIdCardMetadata(); + final List data = createIdCardData(); + final OwnerId ownerId = createOwnerId(); + + final DocumentSubmitRequest request = new DocumentSubmitRequest(); + request.setProcessId("p1"); + request.setResubmit(true); + request.setData("files".getBytes()); + request.setDocuments(metadata); + when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data); + + final DocumentSubmitException exception = assertThrows(DocumentSubmitException.class, + () -> tested.submitDocuments(identityVerification, request, ownerId)); + assertEquals("Detected a resubmit request without specified originalDocumentId, %s".formatted(ownerId), exception.getMessage()); } @Test - void pairTwoSidedDocumentsTest() { - DocumentVerificationEntity docIdCardFront = new DocumentVerificationEntity(); - docIdCardFront.setId("1"); - docIdCardFront.setType(DocumentType.ID_CARD); - docIdCardFront.setSide(CardSide.FRONT); - - DocumentVerificationEntity docIdCardBack = new DocumentVerificationEntity(); - docIdCardBack.setId("2"); - docIdCardBack.setType(DocumentType.ID_CARD); - docIdCardBack.setSide(CardSide.BACK); - - List documents = List.of(docIdCardFront, docIdCardBack); - - service.pairTwoSidedDocuments(documents); - when(documentVerificationRepository.setOtherDocumentSide("1", "2")).thenReturn(1); - verify(documentVerificationRepository, times(1)).setOtherDocumentSide("1", "2"); - when(documentVerificationRepository.setOtherDocumentSide("2", "1")).thenReturn(1); - verify(documentVerificationRepository, times(1)).setOtherDocumentSide("2", "1"); - assertEquals(docIdCardBack.getId(), docIdCardFront.getOtherSideId()); - assertEquals(docIdCardFront.getId(), docIdCardBack.getOtherSideId()); + void testResubmitDocuments_missingResubmitFlag() throws Exception { + final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get(); + assertNotNull(identityVerification); + + final List metadata = createIdCardMetadata(); + metadata.get(0).setOriginalDocumentId("original1"); + metadata.get(1).setOriginalDocumentId("original2"); + final List data = createIdCardData(); + final OwnerId ownerId = createOwnerId(); + + final DocumentSubmitRequest request = new DocumentSubmitRequest(); + request.setProcessId("p1"); + request.setResubmit(false); + request.setData("files".getBytes()); + request.setDocuments(metadata); + when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data); + + final DocumentSubmitException exception = assertThrows(DocumentSubmitException.class, + () -> tested.submitDocuments(identityVerification, request, ownerId)); + assertEquals("Detected a submit request with specified originalDocumentId=original1, %s".formatted(ownerId), exception.getMessage()); + } + + private List createIdCardMetadata() { + final DocumentSubmitRequest.DocumentMetadata page1 = new DocumentSubmitRequest.DocumentMetadata(); + page1.setFilename("id_card_front.png"); + page1.setType(DocumentType.ID_CARD); + page1.setSide(CardSide.FRONT); + + final DocumentSubmitRequest.DocumentMetadata page2 = new DocumentSubmitRequest.DocumentMetadata(); + page2.setFilename("id_card_back.png"); + page2.setType(DocumentType.ID_CARD); + page2.setSide(CardSide.BACK); + + return List.of(page1, page2); + } + + private List createIdCardData() { + final Document documentPage1 = new Document(); + documentPage1.setData("img1".getBytes()); + documentPage1.setFilename("id_card_front.png"); + + final Document documentPage2 = new Document(); + documentPage2.setData("img2".getBytes()); + documentPage2.setFilename("id_card_back.png"); + + return List.of(documentPage1, documentPage2); + } + + private OwnerId createOwnerId() { + final OwnerId ownerId = new OwnerId(); + ownerId.setActivationId("a1"); + ownerId.setUserId("u1"); + return ownerId; } } diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProviderTest.java b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProviderTest.java index 6f3f19242..5361e51d0 100644 --- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProviderTest.java +++ b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProviderTest.java @@ -86,7 +86,7 @@ void getResultTest() { @Test void cleanupIdentityDataTest() { - provider.cleanupIdentityData(ownerId); + provider.cleanupIdentityData(ownerId, new SessionInfo()); } private OwnerId createOwnerId() { diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/statemachine/service/StateMachineServiceTest.java b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/statemachine/service/StateMachineServiceTest.java new file mode 100644 index 000000000..9972b40bd --- /dev/null +++ b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/statemachine/service/StateMachineServiceTest.java @@ -0,0 +1,70 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.onboardingserver.statemachine.service; + +import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus; +import com.wultra.app.onboardingserver.EnrollmentServerTestApplication; +import com.wultra.app.onboardingserver.common.database.IdentityVerificationRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Test for {@link StateMachineService} + * + * @author Jan Pesek, jan.pesek@wultra.com + */ +@SpringBootTest(classes = EnrollmentServerTestApplication.class) +@ActiveProfiles("test") +class StateMachineServiceTest { + + @Autowired + private StateMachineService tested; + + @Autowired + private IdentityVerificationRepository repository; + + @Test + @Sql + void testChangeMachineStatesInBatch() { + tested.changeMachineStatesInBatch(); + + assertEquals(IdentityVerificationStatus.VERIFICATION_PENDING, repository.findById("v1").get().getStatus()); + } + + @Test + @Sql + void testChangeMachineStatesInBatch_submitting() { + tested.changeMachineStatesInBatch(); + + assertEquals(IdentityVerificationStatus.IN_PROGRESS, repository.findById("v2").get().getStatus()); + } + + @Test + @Sql + void testChangeMachineStatesInBatch_noDocuments() { + tested.changeMachineStatesInBatch(); + + assertEquals(IdentityVerificationStatus.IN_PROGRESS, repository.findById("v3").get().getStatus()); + } + +} diff --git a/enrollment-server-onboarding/src/test/resources/application-external-service.properties b/enrollment-server-onboarding/src/test/resources/application-external-service.properties deleted file mode 100644 index 4b1b5379b..000000000 --- a/enrollment-server-onboarding/src/test/resources/application-external-service.properties +++ /dev/null @@ -1,9 +0,0 @@ -enrollment-server-onboarding.identity-verification.enabled=true - -enrollment-server-onboarding.document-verification.provider=zenid - -enrollment-server-onboarding.document-verification.zenid.asyncProcessingEnabled=false - -enrollment-server-onboarding.presence-check.provider=iproov - -logging.level.root=INFO diff --git a/enrollment-server-onboarding/src/test/resources/application-test.properties b/enrollment-server-onboarding/src/test/resources/application-test.properties index dce7de403..5dff6a04e 100644 --- a/enrollment-server-onboarding/src/test/resources/application-test.properties +++ b/enrollment-server-onboarding/src/test/resources/application-test.properties @@ -19,9 +19,10 @@ spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_ON_EXIT=FALSE spring.datasource.username=sa spring.datasource.password=password spring.datasource.driver-class-name=org.h2.Driver -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=create spring.liquibase.enabled=false enrollment-server-onboarding.identity-verification.enabled=true +enrollment-server-onboarding.document-verification.provider=mock + diff --git a/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.sql b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.sql new file mode 100644 index 000000000..a8d5f54f3 --- /dev/null +++ b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.sql @@ -0,0 +1,2 @@ +INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created) VALUES + ('v1', 'a1', 'u1', 'p1', 'IN_PROGRESS', 'DOCUMENT_UPLOAD', now()); diff --git a/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testPairTwoSidedDocuments.sql b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testPairTwoSidedDocuments.sql new file mode 100644 index 000000000..7f5ef45c6 --- /dev/null +++ b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testPairTwoSidedDocuments.sql @@ -0,0 +1,6 @@ +INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created) VALUES + ('v1', 'a1', 'u1', 'p1', 'VERIFICATION_PENDING', 'DOCUMENT_VERIFICATION', now()); + +INSERT INTO es_document_verification(id, activation_id, identity_verification_id, type, side, status, used_for_verification, filename, timestamp_created) VALUES + ('1', 'a1', 'v1', 'ID_CARD', 'FRONT', 'VERIFICATION_PENDING', true, 'id_front.png', now()), + ('2', 'a1', 'v1', 'ID_CARD', 'BACK', 'VERIFICATION_PENDING', true, 'id_back.png', now()); diff --git a/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testResubmitDocuments.sql b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testResubmitDocuments.sql new file mode 100644 index 000000000..ecaf09aa7 --- /dev/null +++ b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testResubmitDocuments.sql @@ -0,0 +1,6 @@ +INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created) VALUES + ('v1', 'a1', 'u1', 'p1', 'VERIFICATION_PENDING', 'DOCUMENT_VERIFICATION', now()); + +INSERT INTO es_document_verification(id, activation_id, identity_verification_id, type, side, status, used_for_verification, filename, timestamp_created) VALUES + ('original1', 'a1', 'v1', 'ID_CARD', 'FRONT', 'VERIFICATION_PENDING', true, 'original_id_front.png', now()), + ('original2', 'a1', 'v1', 'ID_CARD', 'BACK', 'VERIFICATION_PENDING', true, 'original_id_back.png', now()); \ No newline at end of file diff --git a/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/statemachine/service/StateMachineServiceTest.testChangeMachineStatesInBatch.sql b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/statemachine/service/StateMachineServiceTest.testChangeMachineStatesInBatch.sql new file mode 100644 index 000000000..740d005e4 --- /dev/null +++ b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/statemachine/service/StateMachineServiceTest.testChangeMachineStatesInBatch.sql @@ -0,0 +1,6 @@ +INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created, timestamp_last_updated) VALUES + ('v1', 'a1', 'u1', 'p1', 'IN_PROGRESS', 'DOCUMENT_UPLOAD', now(), now()); + +-- document already submitted to 'mock' provider +INSERT INTO es_document_verification(id, activation_id, identity_verification_id, type, provider_name, status, filename, used_for_verification, timestamp_created, timestamp_last_updated) VALUES + ('doc1', 'a1', 'v1', 'ID_CARD', 'mock', 'VERIFICATION_PENDING', 'f2', true, now(), now()); diff --git a/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/statemachine/service/StateMachineServiceTest.testChangeMachineStatesInBatch_noDocuments.sql b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/statemachine/service/StateMachineServiceTest.testChangeMachineStatesInBatch_noDocuments.sql new file mode 100644 index 000000000..fb63b4bdf --- /dev/null +++ b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/statemachine/service/StateMachineServiceTest.testChangeMachineStatesInBatch_noDocuments.sql @@ -0,0 +1,4 @@ +INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created, timestamp_last_updated) VALUES + ('v3', 'a3', 'u3', 'p3', 'IN_PROGRESS', 'DOCUMENT_UPLOAD', now(), now()); + +-- no documents submitted yet diff --git a/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/statemachine/service/StateMachineServiceTest.testChangeMachineStatesInBatch_submitting.sql b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/statemachine/service/StateMachineServiceTest.testChangeMachineStatesInBatch_submitting.sql new file mode 100644 index 000000000..5cf138318 --- /dev/null +++ b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/statemachine/service/StateMachineServiceTest.testChangeMachineStatesInBatch_submitting.sql @@ -0,0 +1,6 @@ +INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created, timestamp_last_updated) VALUES + ('v2', 'a2', 'u2', 'p2', 'IN_PROGRESS', 'DOCUMENT_UPLOAD', now(), now()); + +-- document is being submitted to a provider +INSERT INTO es_document_verification(id, activation_id, identity_verification_id, type, provider_name, status, filename, used_for_verification, timestamp_created, timestamp_last_updated) VALUES + ('doc2', 'a2', 'v2', 'ID_CARD', null, 'UPLOAD_IN_PROGRESS', 'f1', true, now(), now()); diff --git a/enrollment-server/pom.xml b/enrollment-server/pom.xml index 5cf75cc1a..bea22f846 100644 --- a/enrollment-server/pom.xml +++ b/enrollment-server/pom.xml @@ -30,7 +30,7 @@ com.wultra.security enrollment-server-parent - 1.5.0 + 1.6.0 @@ -111,19 +111,18 @@ postgresql - - - org.apache.commons - commons-text - 1.10.0 - - net.logstash.logback logstash-logback-encoder + + + io.micrometer + micrometer-registry-prometheus + + org.springframework.boot @@ -176,13 +175,30 @@ + + + + jakarta.servlet + jakarta.servlet-api + + + org.projectlombok + lombok + + + org.apache.maven.plugins - maven-surefire-plugin - ${maven-surefire-plugin.version} + maven-war-plugin - external-service + + + src/main/webapp/WEB-INF + WEB-INF + true + + @@ -198,22 +214,6 @@ -Xdoclint:none - - standalone - - - org.springframework.boot - spring-boot-starter-tomcat - provided - - - - org.apache.tomcat.embed - tomcat-embed-el - provided - - - public-repository 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 35358964a..1ef71aeb8 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 @@ -26,7 +26,9 @@ import com.wultra.core.http.common.request.RequestContext; import com.wultra.core.http.common.request.RequestContextConverter; import com.wultra.security.powerauth.client.model.error.PowerAuthClientException; +import com.wultra.security.powerauth.lib.mtoken.model.entity.Operation; import com.wultra.security.powerauth.lib.mtoken.model.request.OperationApproveRequest; +import com.wultra.security.powerauth.lib.mtoken.model.request.OperationDetailRequest; import com.wultra.security.powerauth.lib.mtoken.model.request.OperationRejectRequest; import com.wultra.security.powerauth.lib.mtoken.model.response.MobileTokenResponse; import com.wultra.security.powerauth.lib.mtoken.model.response.OperationListResponse; @@ -104,9 +106,9 @@ public ObjectResponse operationList(@Parameter(hidden = t if (auth != null) { final String userId = auth.getUserId(); final String applicationId = auth.getApplicationId(); - final List activationFlags = auth.getActivationContext().getActivationFlags(); + final String activationId = auth.getActivationContext().getActivationId(); final String language = locale.getLanguage(); - final OperationListResponse listResponse = mobileTokenService.operationListForUser(userId, applicationId, language, activationFlags, true); + final OperationListResponse listResponse = mobileTokenService.operationListForUser(userId, applicationId, language, activationId, true); final Date currentTimestamp = new Date(); return new MobileTokenResponse<>(listResponse, currentTimestamp); } else { @@ -118,6 +120,78 @@ public ObjectResponse operationList(@Parameter(hidden = t } } + /** + * Get the detail of an operation. + * + * @param auth Authentication object. + * @param locale Locale. + * @return List of pending operations. + * @throws MobileTokenException In the case error mobile token service occurs. + * @throws MobileTokenConfigurationException In the case of system misconfiguration. + */ + @PostMapping("/operation/detail") + @PowerAuthToken(signatureType = { + PowerAuthSignatureTypes.POSSESSION, + PowerAuthSignatureTypes.POSSESSION_BIOMETRY, + PowerAuthSignatureTypes.POSSESSION_KNOWLEDGE, + PowerAuthSignatureTypes.POSSESSION_KNOWLEDGE_BIOMETRY + }) + public ObjectResponse getOperationDetail(@RequestBody ObjectRequest request, + @Parameter(hidden = true) PowerAuthApiAuthentication auth, + @Parameter(hidden = true) Locale locale) throws MobileTokenException, MobileTokenConfigurationException { + try { + if (auth != null) { + final String operationId = request.getRequestObject().getId(); + final String language = locale.getLanguage(); + final String userId = auth.getUserId(); + final Operation response = mobileTokenService.getOperationDetail(operationId, language, userId); + final Date currentTimestamp = new Date(); + return new MobileTokenResponse<>(response, currentTimestamp); + } else { + throw new MobileTokenAuthException(); + } + } catch (PowerAuthClientException e) { + logger.error("Unable to call upstream service.", e); + throw new MobileTokenAuthException(); + } + } + + /** + * Claim operation for a user. + * + * @param auth Authentication object. + * @param locale Locale. + * @return List of pending operations. + * @throws MobileTokenException In the case error mobile token service occurs. + * @throws MobileTokenConfigurationException In the case of system misconfiguration. + */ + @PostMapping("/operation/detail/claim") + @PowerAuthToken(signatureType = { + PowerAuthSignatureTypes.POSSESSION, + PowerAuthSignatureTypes.POSSESSION_BIOMETRY, + PowerAuthSignatureTypes.POSSESSION_KNOWLEDGE, + PowerAuthSignatureTypes.POSSESSION_KNOWLEDGE_BIOMETRY + }) + public ObjectResponse claimOperation(@RequestBody ObjectRequest request, + @Parameter(hidden = true) PowerAuthApiAuthentication auth, + @Parameter(hidden = true) Locale locale) throws MobileTokenException, MobileTokenConfigurationException { + try { + if (auth != null) { + final String operationId = request.getRequestObject().getId(); + final String language = locale.getLanguage(); + final String userId = auth.getUserId(); + final Operation response = mobileTokenService.claimOperation(operationId, language, userId); + final Date currentTimestamp = new Date(); + return new MobileTokenResponse<>(response, currentTimestamp); + } else { + throw new MobileTokenAuthException(); + } + } catch (PowerAuthClientException e) { + logger.error("Unable to call upstream service.", e); + throw new MobileTokenAuthException(); + } + } + /** * Get the list of all operations. * @@ -137,9 +211,9 @@ public ObjectResponse operationListAll(@Parameter(hidden if (auth != null) { final String userId = auth.getUserId(); final String applicationId = auth.getApplicationId(); - final List activationFlags = auth.getActivationContext().getActivationFlags(); + final String activationId = auth.getActivationContext().getActivationId(); final String language = locale.getLanguage(); - final OperationListResponse listResponse = mobileTokenService.operationListForUser(userId, applicationId, language, activationFlags, false); + final OperationListResponse listResponse = mobileTokenService.operationListForUser(userId, applicationId, language, activationId, false); return new ObjectResponse<>(listResponse); } else { throw new MobileTokenAuthException(); @@ -226,7 +300,7 @@ private static String fetchProximityCheckOtp(OperationApproveRequest requestObje return null; } final var proximityCheck = requestObject.getProximityCheck().get(); - logger.info("Operation ID: {} using proximity check OTP, timestampRequested: {}, timestampSigned: {}", requestObject.getId(), proximityCheck.getTimestampRequested(), proximityCheck.getTimestampSigned()); + logger.info("Operation ID: {} using proximity check OTP, timestampReceived: {}, timestampSent: {}", requestObject.getId(), proximityCheck.getTimestampReceived(), proximityCheck.getTimestampSent()); return proximityCheck.getOtp(); } diff --git a/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 ea0b6a013..40c605567 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 @@ -41,4 +41,15 @@ public interface OperationTemplateRepository extends CrudRepository findFirstByLanguageAndPlaceholder(String language, String placeholder); + /** + * Find an operation template by the given operation type. + *

+ * Just a fallback method when no entry found by {@link #findFirstByLanguageAndPlaceholder(String, String)}. + * + * @param placeholder operation type + * @return operation template or empty + * @see #findFirstByLanguageAndPlaceholder(String, String) + */ + Optional findFirstByPlaceholder(String placeholder); + } 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 685fd7542..80ac42a4c 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 @@ -18,6 +18,7 @@ package com.wultra.app.enrollmentserver.errorhandling; +import com.wultra.security.powerauth.lib.mtoken.model.enumeration.ErrorCode; import io.getlime.core.rest.model.base.response.ErrorResponse; import io.getlime.security.powerauth.rest.api.spring.exception.PowerAuthAuthenticationException; import lombok.extern.slf4j.Slf4j; @@ -57,7 +58,7 @@ public class DefaultExceptionHandler { @ResponseStatus(HttpStatus.BAD_REQUEST) public @ResponseBody ErrorResponse handleInvalidRequestException(InvalidRequestObjectException ex) { logger.warn("Error occurred when processing request object.", ex); - return new ErrorResponse("INVALID_REQUEST", "Invalid request object."); + return new ErrorResponse(ErrorCode.INVALID_REQUEST, "Invalid request object."); } /** @@ -69,7 +70,7 @@ public class DefaultExceptionHandler { @ResponseStatus(HttpStatus.BAD_REQUEST) public @ResponseBody ErrorResponse handlePushRegistrationException(PushRegistrationFailedException ex) { logger.warn("Error occurred when registering to push server.", ex); - return new ErrorResponse("PUSH_REGISTRATION_FAILED", "Push registration failed in Mobile Token API component."); + return new ErrorResponse(ErrorCode.PUSH_REGISTRATION_FAILED, "Push registration failed in Mobile Token API component."); } /** @@ -81,7 +82,7 @@ public class DefaultExceptionHandler { @ResponseStatus(HttpStatus.UNAUTHORIZED) public @ResponseBody ErrorResponse handleUnauthorizedException(PowerAuthAuthenticationException ex) { logger.warn("Unable to verify device registration - authentication failed.", ex); - return new ErrorResponse("POWERAUTH_AUTH_FAIL", "Unable to verify device registration."); + return new ErrorResponse(ErrorCode.POWERAUTH_AUTH_FAIL, "Unable to verify device registration."); } /** diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/MobileTokenAuthException.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/MobileTokenAuthException.java index d2f5810a5..28fdf0ec2 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/MobileTokenAuthException.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/errorhandling/MobileTokenAuthException.java @@ -18,6 +18,8 @@ package com.wultra.app.enrollmentserver.errorhandling; +import com.wultra.security.powerauth.lib.mtoken.model.enumeration.ErrorCode; + import java.io.Serial; /** @@ -31,7 +33,10 @@ public class MobileTokenAuthException extends MobileTokenException { private static final long serialVersionUID = -4602362062047233809L; public MobileTokenAuthException() { - super("POWERAUTH_AUTH_FAIL", "Authentication failed"); + super(ErrorCode.POWERAUTH_AUTH_FAIL, "Authentication failed"); } + public MobileTokenAuthException(final String code, final String message) { + super(code, message); + } } 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 c5aaa297d..9b26f27cc 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 @@ -25,7 +25,6 @@ import com.wultra.app.enrollmentserver.impl.service.converter.MobileTokenConverter; import com.wultra.core.http.common.request.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; import com.wultra.security.powerauth.client.model.enumeration.UserActionResult; import com.wultra.security.powerauth.client.model.error.PowerAuthClientException; @@ -35,6 +34,7 @@ import com.wultra.security.powerauth.client.model.response.OperationDetailResponse; import com.wultra.security.powerauth.client.model.response.OperationUserActionResponse; import com.wultra.security.powerauth.lib.mtoken.model.entity.Operation; +import com.wultra.security.powerauth.lib.mtoken.model.enumeration.ErrorCode; import com.wultra.security.powerauth.lib.mtoken.model.response.OperationListResponse; import io.getlime.core.rest.model.base.response.Response; import io.getlime.security.powerauth.rest.api.spring.service.HttpCustomizationService; @@ -87,51 +87,49 @@ public MobileTokenService(PowerAuthClient powerAuthClient, MobileTokenConverter } /** - * Get the operation list with operations of a given users. The service either returns only pending - * operations or all operations, depending on the provided flag. + * Retrieves a list of operations for a specified user. This method can return + * either all operations or only those that are pending, based on the 'pendingOnly' flag. + * It processes each operation detail, converts them into a consistent format, and + * filters out operations without a corresponding template. * - * @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. - * @throws MobileTokenConfigurationException In the case of system misconfiguration. + * @param userId User ID for which the operation list is requested. + * @param applicationId Application ID associated with the operations. + * @param language Language for operation template localization. + * @param activationId Optional activation ID to filter operations. + * @param pendingOnly Flag indicating whether to fetch only pending operations or all. + * @return A consolidated list of operations formatted as 'OperationListResponse'. + * @throws PowerAuthClientException If there's an issue with the PowerAuth service call. + * @throws MobileTokenConfigurationException For any system configuration issues. */ public OperationListResponse operationListForUser( @NotNull String userId, @NotNull String applicationId, @NotNull String language, - List activationFlags, + String activationId, boolean pendingOnly) throws PowerAuthClientException, MobileTokenConfigurationException { final OperationListForUserRequest request = new OperationListForUserRequest(); request.setUserId(userId); request.setApplications(List.of(applicationId)); + request.setPageNumber(0); + request.setPageSize(OPERATION_LIST_LIMIT); + request.setActivationId(activationId); final MultiValueMap queryParams = httpCustomizationService.getQueryParams(); final MultiValueMap httpHeaders = httpCustomizationService.getHttpHeaders(); - final com.wultra.security.powerauth.client.model.response.OperationListResponse pendingList = + final com.wultra.security.powerauth.client.model.response.OperationListResponse operations = pendingOnly ? powerAuthClient.operationPendingList(request, queryParams, httpHeaders) : powerAuthClient.operationList(request, queryParams, httpHeaders); final OperationListResponse responseObject = new OperationListResponse(); - 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 Optional operationTemplate = operationTemplateService.findTemplate(operationDetail.getOperationType(), language); - if (operationTemplate.isEmpty()) { - logger.warn("No template found for operationType={}, skipping the entry.", operationDetail.getOperationType()); - continue; - } - final Operation operation = mobileTokenConverter.convert(operationDetail, operationTemplate.get()); - responseObject.add(operation); - if (responseObject.size() >= OPERATION_LIST_LIMIT) { - logger.info("Reached the limit of operation list ({}) for user ID: {}", OPERATION_LIST_LIMIT, userId); - break; - } + for (OperationDetailResponse operationDetail: operations) { + final Optional operationTemplate = operationTemplateService.findTemplate(operationDetail.getOperationType(), language); + if (operationTemplate.isEmpty()) { + logger.warn("No template found for operationType={}, skipping the entry.", operationDetail.getOperationType()); + continue; } + final Operation operation = mobileTokenConverter.convert(operationDetail, operationTemplate.get()); + responseObject.add(operation); } return responseObject; } @@ -146,7 +144,7 @@ public OperationListResponse operationListForUser( */ public Response operationApprove(@NotNull final OperationApproveParameterObject request) throws MobileTokenException, PowerAuthClientException { - final OperationDetailResponse operationDetail = getOperationDetail(request.getOperationId()); + final OperationDetailResponse operationDetail = claimOperationInternal(request.getOperationId(), null); final String activationFlag = operationDetail.getActivationFlag(); if (activationFlag != null && !request.getActivationFlags().contains(activationFlag)) { // allow approval if there is no flag, or if flag matches flags of activation @@ -181,8 +179,8 @@ public Response operationApprove(@NotNull final OperationApproveParameterObject return new Response(); } else { final OperationDetailResponse operation = approveResponse.getOperation(); - handleStatus(operation.getStatus()); - throw new MobileTokenAuthException(); + handleStatus(operation); + throw new MobileTokenAuthException(ErrorCode.OPERATION_FAILED, "PowerAuth server operation approval fails"); } } @@ -208,7 +206,7 @@ public void operationFailApprove(@NotNull String operationId, @NotNull RequestCo ); final OperationDetailResponse operation = failApprovalResponse.getOperation(); - handleStatus(operation.getStatus()); + handleStatus(operation); } /** @@ -233,7 +231,7 @@ public Response operationReject( @NotNull RequestContext requestContext, List activationFlags, String rejectReason) throws MobileTokenException, PowerAuthClientException { - final OperationDetailResponse operationDetail = getOperationDetail(operationId); + final OperationDetailResponse operationDetail = getOperationDetailInternal(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 @@ -262,11 +260,48 @@ public Response operationReject( return new Response(); } else { final OperationDetailResponse operation = rejectResponse.getOperation(); - handleStatus(operation.getStatus()); - throw new MobileTokenAuthException(); + handleStatus(operation); + throw new MobileTokenAuthException(ErrorCode.OPERATION_FAILED, "PowerAuth server operation rejection fails"); } } + /** + * Get operation detail. + * + * @param operationId Operation ID. + * @param language Language. + * @param userId User identifier. + * @return Operation detail. + * @throws PowerAuthClientException In case communication with PowerAuth Server fails. + * @throws MobileTokenException In case the operation is in incorrect state. + * @throws MobileTokenConfigurationException In case operation template is not configured correctly. + */ + public Operation getOperationDetail(String operationId, String language, String userId) throws MobileTokenException, PowerAuthClientException, MobileTokenConfigurationException { + final OperationDetailResponse operationDetail = getOperationDetailInternal(operationId); + // Check user ID against authenticated user, however skip the check in case operation is not claimed yet + if (operationDetail.getUserId() != null && !userId.equals(operationDetail.getUserId())) { + logger.warn("User ID from operation does not match authenticated user ID."); + throw new MobileTokenException(ErrorCode.INVALID_REQUEST, "Invalid request"); + } + return convertOperation(language, operationDetail); + } + + /** + * Claim operation. + * + * @param operationId Operation ID. + * @param language Language. + * @param userId User identifier. + * @return Operation detail. + * @throws PowerAuthClientException In case communication with PowerAuth Server fails. + * @throws MobileTokenException In case the operation is in incorrect state. + * @throws MobileTokenConfigurationException In case operation template is not configured correctly. + */ + public Operation claimOperation(String operationId, String language, String userId) throws MobileTokenException, PowerAuthClientException, MobileTokenConfigurationException { + final OperationDetailResponse operationDetail = claimOperationInternal(operationId, userId); + return convertOperation(language, operationDetail); + } + // Private methods /** @@ -277,18 +312,58 @@ public Response operationReject( * @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 { + private OperationDetailResponse getOperationDetailInternal(String operationId) throws PowerAuthClientException, MobileTokenException { + final OperationDetailRequest operationDetailRequest = new OperationDetailRequest(); + operationDetailRequest.setOperationId(operationId); + final OperationDetailResponse operationDetail = powerAuthClient.operationDetail( + operationDetailRequest, + httpCustomizationService.getQueryParams(), + httpCustomizationService.getHttpHeaders() + ); + handleStatus(operationDetail); + return operationDetail; + } + + /** + * Get operation detail by calling PowerAuth Server. + * + * @param operationId Operation ID. + * @param userId Optional user ID for operation claim. + * @return Operation detail. + * @throws PowerAuthClientException In case communication with PowerAuth Server fails. + * @throws MobileTokenException When the operation is in incorrect state. + */ + private OperationDetailResponse claimOperationInternal(String operationId, String userId) throws PowerAuthClientException, MobileTokenException { final OperationDetailRequest operationDetailRequest = new OperationDetailRequest(); operationDetailRequest.setOperationId(operationId); + operationDetailRequest.setUserId(userId); final OperationDetailResponse operationDetail = powerAuthClient.operationDetail( operationDetailRequest, httpCustomizationService.getQueryParams(), httpCustomizationService.getHttpHeaders() ); - handleStatus(operationDetail.getStatus()); + handleStatus(operationDetail); return operationDetail; } + /** + * Find operation template and convert the operation. + * + * @param language Language. + * @param operationDetail Operation detail. + * @return Converted operation. + * @throws MobileTokenException In case the operation is in incorrect state. + * @throws MobileTokenConfigurationException In case operation template is not configured correctly. + */ + private Operation convertOperation(String language, OperationDetailResponse operationDetail) throws MobileTokenException, MobileTokenConfigurationException { + final Optional operationTemplate = operationTemplateService.findTemplate(operationDetail.getOperationType(), language); + if (operationTemplate.isEmpty()) { + logger.warn("Template not found for operationType={}.", operationDetail.getOperationType()); + throw new MobileTokenException(ErrorCode.INVALID_REQUEST, "Template not found"); + } + return mobileTokenConverter.convert(operationDetail, operationTemplate.get()); + } + /** * Handle operation status. * @@ -297,22 +372,21 @@ private OperationDetailResponse getOperationDetail(String operationId) throws Po *

  • CANCELLED, APPROVED, REJECTED, or EXPIRED - throws exception with appropriate code and message.
  • * * - * @param status Operation status. + * @param operation Operation detail. * @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 -> { - // OK, this operation is still pending - } + private static void handleStatus(final OperationDetailResponse operation) throws MobileTokenException { + switch (operation.getStatus()) { + case PENDING -> + logger.debug("OK, operation ID: {} is still pending", operation.getId()); case CANCELED -> - throw new MobileTokenException("OPERATION_ALREADY_CANCELED", "Operation was already canceled"); + throw new MobileTokenException(ErrorCode.OPERATION_ALREADY_CANCELED, "Operation was already canceled"); case APPROVED, REJECTED -> - throw new MobileTokenException("OPERATION_ALREADY_FINISHED", "Operation was already completed"); + throw new MobileTokenException(ErrorCode.OPERATION_ALREADY_FINISHED, "Operation was already completed"); case FAILED -> - throw new MobileTokenException("OPERATION_ALREADY_FAILED", "Operation already failed"); + throw new MobileTokenException(ErrorCode.OPERATION_ALREADY_FAILED, "Operation already failed"); default -> - throw new MobileTokenException("OPERATION_EXPIRED", "Operation already expired"); + throw new MobileTokenException(ErrorCode.OPERATION_EXPIRED, "Operation already expired"); } } 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 9ff169942..9aafce226 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 @@ -36,6 +36,8 @@ @Slf4j public class OperationTemplateService { + private static final String DEFAULT_LANGUAGE = "en"; + private final OperationTemplateRepository operationTemplateRepository; @Autowired @@ -44,17 +46,36 @@ public OperationTemplateService(OperationTemplateRepository operationTemplateRep } /** - * Find the operation template for the given type and language. Falling back to EN locale. + * Find the operation template for the given type and language. + *

    + * Falling back to EN locale and later on to any found language. * * @param operationType Operation type. * @param language Template language. * @return Found operation template or empty. */ public Optional findTemplate(@NotNull String operationType, @NotNull String language) { - return operationTemplateRepository.findFirstByLanguageAndPlaceholder(language, operationType).or(() -> { + return operationTemplateRepository.findFirstByLanguageAndPlaceholder(language, operationType).or(() -> + findTemplateFallback(operationType, language)); + } + + private Optional findTemplateFallback(final String operationType, final String language) { + if (!DEFAULT_LANGUAGE.equals(language)) { logger.debug("Trying fallback to EN locale for operationType={}", operationType); - return operationTemplateRepository.findFirstByLanguageAndPlaceholder("en", operationType); - }); + return findDefaultTemplate(operationType); + } else { + return findAnyTemplate(operationType); + } + } + + private Optional findDefaultTemplate(final String operationType) { + return operationTemplateRepository.findFirstByLanguageAndPlaceholder(DEFAULT_LANGUAGE, operationType).or(() -> + findAnyTemplate(operationType)); + } + + private Optional findAnyTemplate(final String operationType) { + logger.debug("Trying fallback to any locale for operationType={}", operationType); + return operationTemplateRepository.findFirstByPlaceholder(operationType); } } 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 27baae4d7..2bd4b8cf7 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 @@ -285,29 +285,39 @@ private static Optional buildAmountAttribute(final OperationTemplateP if (currency.isEmpty()) { return Optional.empty(); } - final BigDecimal amountRaw; - try { - amountRaw = new BigDecimal(amount.get()); - } catch (NumberFormatException ex) { - logger.warn("Invalid number format: {}, skipping the AMOUNT attribute!", amount); - return Optional.empty(); - } + final Locale locale = LocaleContextHolder.getLocale(); final String currencyRaw = currency.get(); final String currencyFormatted = MonetaryConverter.formatCurrency(currencyRaw, locale); - final String amountFormatted = MonetaryConverter.formatAmount(amountRaw, currencyRaw, locale); - final String valueFormatted = MonetaryConverter.formatValue(amountRaw, currencyRaw, locale); + final AmountFormatted amountFormatted = createAmountFormatted(amount.get(), currencyRaw, "AMOUNT"); return Optional.of(AmountAttribute.builder() .id(id) .label(text) - .amount(amountRaw) - .amountFormatted(amountFormatted) + .amount(amountFormatted.amountRaw()) + .amountFormatted(amountFormatted.amountFormatted()) .currency(currencyRaw) .currencyFormatted(currencyFormatted) - .valueFormatted(valueFormatted) + .valueFormatted(amountFormatted.valueFormatted()) .build()); } + private static AmountFormatted createAmountFormatted(final String amount, final String currencyRaw, final String attribute) { + final Locale locale = LocaleContextHolder.getLocale(); + + try { + final BigDecimal amountRaw = new BigDecimal(amount); + final String amountFormatted = MonetaryConverter.formatAmount(amountRaw, currencyRaw, locale); + final String valueFormatted = MonetaryConverter.formatValue(amountRaw, currencyRaw, locale); + return new AmountFormatted(amountRaw, amountFormatted, valueFormatted); + } catch (NumberFormatException e) { + logger.warn("Invalid number format: {}, the raw value is not filled in into {} attribute!", amount, attribute); + logger.trace("Invalid number format: {}, the raw value is not filled in into {} attribute!", amount, attribute, e); + // fallback - pass 'not a number' directly to the formatted field + final String valueFormatted = amount + " " + currencyRaw; + return new AmountFormatted(null, amount, valueFormatted); + } + } + private static Optional buildAmountConversionAttribute(final OperationTemplateParam templateParam, final Map params) { final String id = templateParam.getId(); final String text = templateParam.getText(); @@ -326,39 +336,29 @@ private static Optional buildAmountConversionAttribute(final Operatio .map(Boolean::parseBoolean) .orElse(false); - final BigDecimal sourceAmountRaw; - final BigDecimal targetAmountRaw; - try { - sourceAmountRaw = new BigDecimal(sourceAmount.get()); - targetAmountRaw = new BigDecimal(targetAmount.get()); - } catch (NumberFormatException ex) { - logger.warn("Invalid number format: {}, skipping the AMOUNT_CONVERSION attribute!", sourceAmount); - return Optional.empty(); - } - final Locale locale = LocaleContextHolder.getLocale(); final String sourceCurrencyRaw = sourceCurrency.get(); - final String sourceCurrencyFormatted = MonetaryConverter.formatCurrency(sourceCurrencyRaw, locale); - final String sourceAmountFormatted = MonetaryConverter.formatAmount(sourceAmountRaw, sourceCurrencyRaw, locale); - final String sourceValueFormatted = MonetaryConverter.formatValue(sourceAmountRaw, sourceCurrencyRaw, locale); + final AmountFormatted sourceAmountFormatted = createAmountFormatted(sourceAmount.get(), sourceCurrencyRaw, "AMOUNT_CONVERSION"); + final String targetCurrencyRaw = targetCurrency.get(); + final AmountFormatted targetAmountFormatted = createAmountFormatted(targetAmount.get(), targetCurrencyRaw, "AMOUNT_CONVERSION"); + + final String sourceCurrencyFormatted = MonetaryConverter.formatCurrency(sourceCurrencyRaw, locale); final String targetCurrencyFormatted = MonetaryConverter.formatCurrency(targetCurrencyRaw, locale); - final String targetAmountFormatted = MonetaryConverter.formatAmount(targetAmountRaw, targetCurrencyRaw, locale); - final String targetValueFormatted = MonetaryConverter.formatValue(targetAmountRaw, targetCurrencyRaw, locale); return Optional.of(AmountConversionAttribute.builder() .id(id) .label(text) .dynamic(dynamic) - .sourceAmount(sourceAmountRaw) - .sourceAmountFormatted(sourceAmountFormatted) + .sourceAmount(sourceAmountFormatted.amountRaw()) + .sourceAmountFormatted(sourceAmountFormatted.amountFormatted()) .sourceCurrency(sourceCurrencyRaw) .sourceCurrencyFormatted(sourceCurrencyFormatted) - .sourceValueFormatted(sourceValueFormatted) - .targetAmount(targetAmountRaw) - .targetAmountFormatted(targetAmountFormatted) + .sourceValueFormatted(sourceAmountFormatted.valueFormatted()) + .targetAmount(targetAmountFormatted.amountRaw()) + .targetAmountFormatted(targetAmountFormatted.amountFormatted()) .targetCurrency(targetCurrencyRaw) .targetCurrencyFormatted(targetCurrencyFormatted) - .targetValueFormatted(targetValueFormatted) + .targetValueFormatted(targetAmountFormatted.valueFormatted()) .build()); } @@ -397,7 +397,7 @@ private static Optional fetchTemplateParamValue(final OperationTemplateP return Optional.empty(); } if (params == null) { - logger.warn("Params of OperationDetailResponse is null"); + logger.warn("Params of OperationTemplateParam is null"); return Optional.empty(); } return Optional.ofNullable(templateParams.get(key)) @@ -408,4 +408,6 @@ private static String fetchTemplateParamValueNullable(final OperationTemplatePar return fetchTemplateParamValue(templateParam, params, key) .orElse(null); } + + private record AmountFormatted(BigDecimal amountRaw, String amountFormatted, String valueFormatted) {} } diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverter.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverter.java index 35022cb83..3b9321ba2 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverter.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverter.java @@ -21,7 +21,7 @@ import javax.money.CurrencyUnit; import javax.money.Monetary; -import javax.money.UnknownCurrencyException; +import javax.money.MonetaryException; import java.math.RoundingMode; import java.text.NumberFormat; import java.util.Currency; @@ -113,7 +113,7 @@ private static int getFractionDigits(String code) { try { final CurrencyUnit currencyUnit = Monetary.getCurrency(code); return currencyUnit.getDefaultFractionDigits(); - } catch (UnknownCurrencyException e) { + } catch (MonetaryException e) { logger.debug("No currency mapping for code={}, most probably not FIAT", code); logger.trace("No currency mapping for code={}", code, e); return DEFAULT_MINIMAL_FRACTION_DIGITS; diff --git a/enrollment-server/src/main/resources/application.properties b/enrollment-server/src/main/resources/application.properties index 4ba2ee175..a0da944e3 100644 --- a/enrollment-server/src/main/resources/application.properties +++ b/enrollment-server/src/main/resources/application.properties @@ -21,13 +21,15 @@ spring.profiles.active=ext spring.application.name=enrollment-server +banner.application.name=${spring.application.name} +banner.application.version=@project.version@ + # Database Configuration - PostgreSQL spring.datasource.url=jdbc:postgresql://localhost:5432/powerauth spring.datasource.username=powerauth spring.datasource.password= spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.hikari.auto-commit=false -spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect spring.jpa.properties.hibernate.connection.characterEncoding=utf8 spring.jpa.properties.hibernate.connection.useUnicode=true @@ -36,7 +38,6 @@ spring.jpa.properties.hibernate.connection.useUnicode=true #spring.datasource.username=powerauth #spring.datasource.password= #spring.datasource.driver-class-name=oracle.jdbc.driver.OracleDriver -#spring.jpa.database-platform=org.hibernate.dialect.OracleDialect # Hibernate Configuration spring.jpa.hibernate.ddl-auto=none @@ -86,4 +87,10 @@ powerauth.service.correlation-header.enabled=false powerauth.service.correlation-header.name=X-Correlation-ID powerauth.service.correlation-header.value.validation-regexp=[a-zA-Z0-9\\-]{8,1024} # For logging correlation HTTP headers enable the pattern and update correlation header name in the pattern -#logging.pattern.console=%clr(%d{${LOG_DATEFORMAT_PATTERN:yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) [%X{X-Correlation-ID}] %clr(%5p) %clr(${PID: }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx} \ No newline at end of file +#logging.pattern.console=%clr(%d{${LOG_DATEFORMAT_PATTERN:yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) [%X{X-Correlation-ID}] %clr(%5p) %clr(${PID: }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx} + +# Monitoring +#management.endpoint.metrics.enabled=true +#management.endpoints.web.exposure.include=health, prometheus +#management.endpoint.prometheus.enabled=true +#management.prometheus.metrics.export.enabled=true diff --git a/enrollment-server/src/main/resources/banner.txt b/enrollment-server/src/main/resources/banner.txt new file mode 100644 index 000000000..b1696bc76 --- /dev/null +++ b/enrollment-server/src/main/resources/banner.txt @@ -0,0 +1,9 @@ + _____ _ _ _ ____ + | ____|_ __ _ __ ___ | | |_ __ ___ ___ _ __ | |_ / ___| ___ _ ____ _____ _ __ + | _| | '_ \| '__/ _ \| | | '_ ` _ \ / _ \ '_ \| __| \___ \ / _ \ '__\ \ / / _ \ '__| + | |___| | | | | | (_) | | | | | | | | __/ | | | |_ ___) | __/ | \ V / __/ | + |_____|_| |_|_| \___/|_|_|_| |_| |_|\___|_| |_|\__| |____/ \___|_| \_/ \___|_| + +${AnsiColor.GREEN} :: ${banner.application.name} (${banner.application.version}) :: ${AnsiColor.GREEN} +${AnsiColor.RED} :: Spring Boot${spring-boot.formatted-version} :: ${AnsiColor.RED} +${AnsiColor.DEFAULT} \ No newline at end of file diff --git a/enrollment-server/src/main/webapp/WEB-INF/jboss-deployment-structure.xml b/enrollment-server/src/main/webapp/WEB-INF/jboss-deployment-structure.xml index f0b646611..fe707a2a8 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 @@ - + diff --git a/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/MobileTokenServiceTest.java b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/MobileTokenServiceTest.java new file mode 100644 index 000000000..e125f7d38 --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/MobileTokenServiceTest.java @@ -0,0 +1,203 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.enrollmentserver.impl.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.wultra.app.enrollmentserver.database.OperationTemplateRepository; +import com.wultra.app.enrollmentserver.database.entity.OperationTemplateEntity; +import com.wultra.app.enrollmentserver.impl.service.converter.MobileTokenConverter; +import com.wultra.security.powerauth.client.PowerAuthClient; +import com.wultra.security.powerauth.client.model.request.OperationListForUserRequest; +import com.wultra.security.powerauth.client.model.response.OperationDetailResponse; +import com.wultra.security.powerauth.lib.mtoken.model.entity.Operation; +import com.wultra.security.powerauth.lib.mtoken.model.response.OperationListResponse; +import io.getlime.security.powerauth.rest.api.spring.service.HttpCustomizationService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; + +import java.util.HashMap; +import java.util.List; +import java.util.Optional; + +/** + * Test for {@link MobileTokenService}. + * + * @author Jan Dusil, jan.dusil@wultra.com + */ +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +class MobileTokenServiceTest { + + @Mock + private PowerAuthClient powerAuthClient; + + @Mock + private MobileTokenConverter mobileTokenConverter; + + @Mock + private OperationTemplateService operationTemplateService; + + @Mock + private HttpCustomizationService httpCustomizationService; + + @InjectMocks + private MobileTokenService tested; + + @Mock + private OperationTemplateRepository templateRepository; + + private static final int OPERATION_LIST_LIMIT = 100; + + @Test + void testOperationListForUser() throws Exception { + final String userId = "test-user"; + final String applicationId = "21"; + final String language = "CZ"; + final String activationId = "test-activation"; + final String operationType = "login"; + + final OperationListForUserRequest request = new OperationListForUserRequest(); + request.setUserId(userId); + request.setApplications(List.of(applicationId)); + request.setPageNumber(0); + request.setPageSize(OPERATION_LIST_LIMIT); + request.setActivationId(activationId); + + final OperationDetailResponse operationDetailResponse = new OperationDetailResponse(); + operationDetailResponse.setUserId(userId); + operationDetailResponse.setApplications(List.of(applicationId)); + operationDetailResponse.setOperationType(operationType); + operationDetailResponse.setParameters(new HashMap<>()); + + final com.wultra.security.powerauth.client.model.response.OperationListResponse response + = new com.wultra.security.powerauth.client.model.response.OperationListResponse(); + response.add(operationDetailResponse); + + when(powerAuthClient.operationList(request, null, null)).thenReturn(response); + + final OperationTemplateEntity operationTemplate = new OperationTemplateEntity(); + operationTemplate.setLanguage(language); + operationTemplate.setPlaceholder(operationType); + operationTemplate.setId(1L); + when(operationTemplateService.findTemplate(operationType, language)).thenReturn(Optional.of(operationTemplate)); + + final Operation operation = new Operation(); + operation.setName(operationType); + when(mobileTokenConverter.convert(operationDetailResponse, operationTemplate)).thenReturn(operation); + + final OperationListResponse operationListResponse = tested.operationListForUser(userId, applicationId, language, activationId, false); + + assertNotNull(operationListResponse); + assertEquals(1, operationListResponse.size()); + assertNotNull(operationListResponse.get(0)); + assertEquals(operationType, operationListResponse.get(0).getName()); + } + + @Test + void testPendingOperationListForUser() throws Exception { + final String userId = "test-user"; + final String applicationId = "21"; + final String language = "CZ"; + final String activationId = "test-activation"; + final String operationType = "login"; + + final OperationListForUserRequest request = new OperationListForUserRequest(); + request.setUserId(userId); + request.setApplications(List.of(applicationId)); + request.setPageNumber(0); + request.setPageSize(OPERATION_LIST_LIMIT); + request.setActivationId(activationId); + + final OperationDetailResponse operationDetailResponse = new OperationDetailResponse(); + operationDetailResponse.setUserId(userId); + operationDetailResponse.setApplications(List.of(applicationId)); + operationDetailResponse.setOperationType(operationType); + operationDetailResponse.setParameters(new HashMap<>()); + + final com.wultra.security.powerauth.client.model.response.OperationListResponse response + = new com.wultra.security.powerauth.client.model.response.OperationListResponse(); + response.add(operationDetailResponse); + + when(powerAuthClient.operationPendingList(request, null, null)).thenReturn(response); + + final OperationTemplateEntity operationTemplate = new OperationTemplateEntity(); + operationTemplate.setLanguage(language); + operationTemplate.setPlaceholder(operationType); + operationTemplate.setId(1L); + when(operationTemplateService.findTemplate(operationType, language)).thenReturn(Optional.of(operationTemplate)); + + final Operation operation = new Operation(); + operation.setName(operationType); + when(mobileTokenConverter.convert(operationDetailResponse, operationTemplate)).thenReturn(operation); + + final OperationListResponse operationListResponse = tested.operationListForUser(userId, applicationId, language, activationId, true); + + assertNotNull(operationListResponse); + assertEquals(1, operationListResponse.size()); + assertNotNull(operationListResponse.get(0)); + assertEquals(operationType, operationListResponse.get(0).getName()); + } + + @Test + void testOperationListForUserEmptyOperationTemplate() throws Exception { + final String userId = "test-user"; + final String applicationId = "21"; + final String language = "CZ"; + final String activationId = "test-activation"; + final String operationType = "login"; + + final OperationListForUserRequest request = new OperationListForUserRequest(); + request.setUserId(userId); + request.setApplications(List.of(applicationId)); + request.setPageNumber(0); + request.setPageSize(OPERATION_LIST_LIMIT); + request.setActivationId(activationId); + + final OperationDetailResponse operationDetailResponse = new OperationDetailResponse(); + operationDetailResponse.setUserId(userId); + operationDetailResponse.setApplications(List.of(applicationId)); + operationDetailResponse.setOperationType(operationType); + operationDetailResponse.setParameters(new HashMap<>()); + + final com.wultra.security.powerauth.client.model.response.OperationListResponse response + = new com.wultra.security.powerauth.client.model.response.OperationListResponse(); + response.add(operationDetailResponse); + + when(powerAuthClient.operationList(request, null, null)).thenReturn(response); + + final OperationTemplateEntity operationTemplate = new OperationTemplateEntity(); + operationTemplate.setLanguage(language); + operationTemplate.setPlaceholder(operationType); + operationTemplate.setId(1L); + + when(operationTemplateService.findTemplate(operationType, language)).thenReturn(Optional.empty()); + + + final OperationListResponse operationListResponse = tested.operationListForUser(userId, applicationId, language, activationId, false); + + assertNotNull(operationListResponse); + assertEquals(0, operationListResponse.size()); + } + +} diff --git a/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/OperationTemplateServiceTest.java b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/OperationTemplateServiceTest.java new file mode 100644 index 000000000..dd6a2fc32 --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/OperationTemplateServiceTest.java @@ -0,0 +1,118 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.enrollmentserver.impl.service; + +import com.wultra.app.enrollmentserver.database.OperationTemplateRepository; +import com.wultra.app.enrollmentserver.database.entity.OperationTemplateEntity; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Test for {@link OperationTemplateService}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@ExtendWith(MockitoExtension.class) +class OperationTemplateServiceTest { + + @Mock + private OperationTemplateRepository dao; + + @InjectMocks + private OperationTemplateService tested; + + @Test + void testFindTemplate_givenLanguage() { + final OperationTemplateEntity entity = new OperationTemplateEntity(); + when(dao.findFirstByLanguageAndPlaceholder("cs", "myTemplate")) + .thenReturn(Optional.of(entity)); + + final Optional result = tested.findTemplate("myTemplate", "cs"); + + assertTrue(result.isPresent()); + assertEquals(entity, result.get()); + } + + @Test + void testFindTemplate_fallbackToEnglish() { + final OperationTemplateEntity entity = new OperationTemplateEntity(); + when(dao.findFirstByLanguageAndPlaceholder("cs", "myTemplate")) + .thenReturn(Optional.empty()); + when(dao.findFirstByLanguageAndPlaceholder("en", "myTemplate")) + .thenReturn(Optional.of(entity)); + + final Optional result = tested.findTemplate("myTemplate", "cs"); + + assertTrue(result.isPresent()); + assertEquals(entity, result.get()); + } + + @Test + void testFindTemplate_fallbackToAnyLanguage() { + final OperationTemplateEntity entity = new OperationTemplateEntity(); + when(dao.findFirstByLanguageAndPlaceholder("cs", "myTemplate")) + .thenReturn(Optional.empty()); + when(dao.findFirstByLanguageAndPlaceholder("en", "myTemplate")) + .thenReturn(Optional.empty()); + when(dao.findFirstByPlaceholder("myTemplate")) + .thenReturn(Optional.of(entity)); + + final Optional result = tested.findTemplate("myTemplate", "cs"); + + assertTrue(result.isPresent()); + assertEquals(entity, result.get()); + } + + @Test + void testFindTemplate_fallbackToAnyLanguage_optimizationOfEnglishLocale() { + final OperationTemplateEntity entity = new OperationTemplateEntity(); + when(dao.findFirstByLanguageAndPlaceholder("en", "myTemplate")) + .thenReturn(Optional.empty()); + when(dao.findFirstByPlaceholder("myTemplate")) + .thenReturn(Optional.of(entity)); + + final Optional result = tested.findTemplate("myTemplate", "en"); + + assertTrue(result.isPresent()); + assertEquals(entity, result.get()); + + verify(dao, times(1)).findFirstByLanguageAndPlaceholder("en", "myTemplate"); + } + + @Test + void testFindTemplate_notFound() { + when(dao.findFirstByLanguageAndPlaceholder("cs", "myTemplate")) + .thenReturn(Optional.empty()); + when(dao.findFirstByLanguageAndPlaceholder("en", "myTemplate")) + .thenReturn(Optional.empty()); + when(dao.findFirstByPlaceholder("myTemplate")) + .thenReturn(Optional.empty()); + + final Optional result = tested.findTemplate("myTemplate", "cs"); + + assertFalse(result.isPresent()); + } +} diff --git a/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverterTest.java b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverterTest.java index b65d2f311..c17ce6611 100644 --- a/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverterTest.java +++ b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverterTest.java @@ -515,6 +515,150 @@ void testConvertAttributes() throws Exception { .build()), atributesIterator.next()); } + @Test + void testConvertAmount_notANumber() throws Exception { + final OperationDetailResponse operationDetail = createOperationDetailResponse(); + operationDetail.setParameters(ImmutableMap.builder() + .put("amount", "not a number") + .put("currency", "CZK") + .build()); + + final OperationTemplateEntity operationTemplate = new OperationTemplateEntity(); + operationTemplate.setAttributes(""" + [ + { + "id": "operation.amount", + "type": "AMOUNT", + "text": "Amount", + "params": { + "amount": "amount", + "currency": "currency" + } + } + ]"""); + + LocaleContextHolder.setLocale(new Locale("en")); + final Operation result = tested.convert(operationDetail, operationTemplate); + + final List attributes = result.getFormData().getAttributes(); + + assertEquals(1, attributes.size()); + final var atributesIterator = attributes.iterator(); + assertEquals(AmountAttribute.builder() + .id("operation.amount") + .label("Amount") + .amount(null) + .amountFormatted("not a number") + .currency("CZK") + .currencyFormatted("CZK") + .valueFormatted("not a number CZK") + .build(), atributesIterator.next()); + } + + @Test + void testConvertAmountConversion_sourceNotANumber() throws Exception { + final OperationDetailResponse operationDetail = createOperationDetailResponse(); + operationDetail.setParameters(ImmutableMap.builder() + .put("sourceAmount", "source not a number") + .put("sourceCurrency", "EUR") + .put("targetAmount", "1710.98") + .put("targetCurrency", "USD") + .put("dynamic", "true") + .build()); + + final OperationTemplateEntity operationTemplate = new OperationTemplateEntity(); + operationTemplate.setAttributes(""" + [ + { + "id": "operation.amountConversion", + "type": "AMOUNT_CONVERSION", + "text": "Amount Conversion", + "params": { + "dynamic": "dynamic", + "sourceAmount": "sourceAmount", + "sourceCurrency": "sourceCurrency", + "targetAmount": "targetAmount", + "targetCurrency": "targetCurrency" + } + } + ]"""); + + LocaleContextHolder.setLocale(new Locale("en")); + final Operation result = tested.convert(operationDetail, operationTemplate); + + final List attributes = result.getFormData().getAttributes(); + + assertEquals(1, attributes.size()); + final var atributesIterator = attributes.iterator(); + assertEquals(AmountConversionAttribute.builder() + .id("operation.amountConversion") + .label("Amount Conversion") + .dynamic(true) + .sourceAmount(null) + .sourceAmountFormatted("source not a number") + .sourceCurrency("EUR") + .sourceCurrencyFormatted("€") + .sourceValueFormatted("source not a number EUR") + .targetAmount(new BigDecimal("1710.98")) + .targetAmountFormatted("1,710.98") + .targetCurrency("USD") + .targetCurrencyFormatted("$") + .targetValueFormatted("$1,710.98") + .build(), atributesIterator.next()); + } + + @Test + void testConvertAmountConversion_targetNotANumber() throws Exception { + final OperationDetailResponse operationDetail = createOperationDetailResponse(); + operationDetail.setParameters(ImmutableMap.builder() + .put("sourceAmount", "1710.98") + .put("sourceCurrency", "USD") + .put("targetAmount", "target not a number") + .put("targetCurrency", "EUR") + .put("dynamic", "true") + .build()); + + final OperationTemplateEntity operationTemplate = new OperationTemplateEntity(); + operationTemplate.setAttributes(""" + [ + { + "id": "operation.amountConversion", + "type": "AMOUNT_CONVERSION", + "text": "Amount Conversion", + "params": { + "dynamic": "dynamic", + "sourceAmount": "sourceAmount", + "sourceCurrency": "sourceCurrency", + "targetAmount": "targetAmount", + "targetCurrency": "targetCurrency" + } + } + ]"""); + + LocaleContextHolder.setLocale(new Locale("en")); + final Operation result = tested.convert(operationDetail, operationTemplate); + + final List attributes = result.getFormData().getAttributes(); + + assertEquals(1, attributes.size()); + final var atributesIterator = attributes.iterator(); + assertEquals(AmountConversionAttribute.builder() + .id("operation.amountConversion") + .label("Amount Conversion") + .dynamic(true) + .sourceAmount(new BigDecimal("1710.98")) + .sourceAmountFormatted("1,710.98") + .sourceCurrency("USD") + .sourceCurrencyFormatted("$") + .sourceValueFormatted("$1,710.98") + .targetAmount(null) + .targetAmountFormatted("target not a number") + .targetCurrency("EUR") + .targetCurrencyFormatted("€") + .targetValueFormatted("target not a number EUR") + .build(), atributesIterator.next()); + } + @Test void testConvertImageAttributeWithoutOriginalUrl() throws Exception { final OperationDetailResponse operationDetail = createOperationDetailResponse(); diff --git a/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverterTest.java b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverterTest.java index 4760d9bec..38e245b9d 100644 --- a/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverterTest.java +++ b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverterTest.java @@ -47,7 +47,8 @@ class MonetaryConverterTest { "NZD, en, NZ$", "NZD, cs, NZ$", "NZD, nz, NZ$", - "BTC, cs, BTC" + "BTC, cs, BTC", + "..., cs, ..." }) void testFormatCurrency(final String source, final String locale, final String expected) { final String result = MonetaryConverter.formatCurrency(source, new Locale(locale)); @@ -68,7 +69,8 @@ void testFormatCurrency(final String source, final String locale, final String e "1, BTC, en, '1'", "1.1, BTC, en, '1.1'", "0.123456789, BTC, en, '0.123456789'", - "0.567567567567567567567, BTC, en, '0.567567567567567567'" + "0.567567567567567567567, BTC, en, '0.567567567567567567'", + "1, ..., en, '1'" }) void testFormatAmount(final String amount, final String code, final String locale, final String expected) { final String result = MonetaryConverter.formatAmount(new BigDecimal(amount), code, new Locale(locale)); @@ -92,7 +94,8 @@ void testFormatAmount(final String amount, final String code, final String local "1, BTC, en, '1 BTC'", "1.1, BTC, en, '1.1 BTC'", "0.123456789, BTC, en, '0.123456789 BTC'", - "0.567567567567567567567, BTC, en, '0.567567567567567567 BTC'" + "0.567567567567567567567, BTC, en, '0.567567567567567567 BTC'", + "1, ..., en, '1 ...'" }) void testFormatValue(final String amount, final String code, final String locale, final String expected) { final String result = MonetaryConverter.formatValue(new BigDecimal(amount), code, new Locale(locale)); diff --git a/mtoken-model/pom.xml b/mtoken-model/pom.xml index 930bfc8d1..880febd13 100644 --- a/mtoken-model/pom.xml +++ b/mtoken-model/pom.xml @@ -26,7 +26,7 @@ com.wultra.security enrollment-server-parent - 1.5.0 + 1.6.0 diff --git a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/enumeration/ErrorCode.java b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/enumeration/ErrorCode.java index e14e922af..a6ef3225a 100644 --- a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/enumeration/ErrorCode.java +++ b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/enumeration/ErrorCode.java @@ -36,16 +36,6 @@ public class ErrorCode { */ public static final String INVALID_REQUEST = "INVALID_REQUEST"; - /** - * Error code for situation when an activation is not active. - */ - public static final String ACTIVATION_NOT_ACTIVE = "ACTIVATION_NOT_ACTIVE"; - - /** - * Error code for situation when an activation is not configured. - */ - public static final String ACTIVATION_NOT_CONFIGURED = "ACTIVATION_NOT_CONFIGURED"; - /** * Error code for situation when an invalid activation / device is * attempted for operation manipulation. @@ -75,6 +65,11 @@ public class ErrorCode { */ public static final String OPERATION_ALREADY_CANCELED = "OPERATION_ALREADY_CANCELED"; + /** + * Error code for situation when PowerAuth server operation approval fails. + */ + public static final String OPERATION_FAILED = "OPERATION_FAILED"; + /** * Error code for situation when an operation expired and yet, some further * action was requested with that operation. diff --git a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationApproveRequest.java b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationApproveRequest.java index 08a2c6868..4a633f862 100644 --- a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationApproveRequest.java +++ b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationApproveRequest.java @@ -19,6 +19,7 @@ import com.wultra.security.powerauth.lib.mtoken.model.entity.PreApprovalScreen; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -33,7 +34,7 @@ @Data public class OperationApproveRequest { - @NotNull + @NotEmpty private String id; @NotNull private String data; @@ -59,16 +60,16 @@ public static class ProximityCheck { private Type type; /** - * When OTP obtained by the client. An optional hint for possible better estimation of the time shift correction. + * When OTP received by the client. An optional hint for possible better estimation of the time shift correction. */ - @Schema(description = "When OTP requested by the client. An optional hint for possible better estimation of the time shift correction.") - private Instant timestampRequested; + @Schema(description = "When OTP received by the client. An optional hint for possible better estimation of the time shift correction.") + private Instant timestampReceived; /** - * When OTP signed by the client. An optional hint for possible better estimation of the time shift correction. + * When OTP is used by the client as part of a signed message. An optional hint for possible better estimation of the time shift correction. */ - @Schema(description = "When OTP signed by the client. An optional hint for possible better estimation of the time shift correction.") - private Instant timestampSigned; + @Schema(description = "When OTP is used by the client as part of a signed message. An optional hint for possible better estimation of the time shift correction.") + private Instant timestampSent; public enum Type { QR_CODE, diff --git a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationClaimRequest.java b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationClaimRequest.java new file mode 100644 index 000000000..44a55ff7e --- /dev/null +++ b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationClaimRequest.java @@ -0,0 +1,37 @@ +/* + * PowerAuth Mobile Token Model + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.security.powerauth.lib.mtoken.model.request; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +/** + * Request for an operation detail. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class OperationClaimRequest { + + @NotEmpty + private String id; + + @NotEmpty + private String userId; + +} diff --git a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationDetailRequest.java b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationDetailRequest.java new file mode 100644 index 000000000..406b73364 --- /dev/null +++ b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationDetailRequest.java @@ -0,0 +1,34 @@ +/* + * PowerAuth Mobile Token Model + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.security.powerauth.lib.mtoken.model.request; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +/** + * Request for an operation detail. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class OperationDetailRequest { + + @NotEmpty + private String id; + +} diff --git a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationRejectRequest.java b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationRejectRequest.java index e6aa6c2ee..6e657401f 100644 --- a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationRejectRequest.java +++ b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/OperationRejectRequest.java @@ -17,7 +17,7 @@ */ package com.wultra.security.powerauth.lib.mtoken.model.request; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotEmpty; import lombok.Data; /** @@ -28,7 +28,7 @@ @Data public class OperationRejectRequest { - @NotNull + @NotEmpty private String id; private String reason; diff --git a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/PushRegisterRequest.java b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/PushRegisterRequest.java index aab7b5b8e..3c27b258d 100644 --- a/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/PushRegisterRequest.java +++ b/mtoken-model/src/main/java/com/wultra/security/powerauth/lib/mtoken/model/request/PushRegisterRequest.java @@ -17,7 +17,7 @@ */ package com.wultra.security.powerauth.lib.mtoken.model.request; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotEmpty; import lombok.Data; /** @@ -28,9 +28,9 @@ @Data public class PushRegisterRequest { - @NotNull + @NotEmpty private String platform; - @NotNull + @NotEmpty private String token; } diff --git a/pom.xml b/pom.xml index e7385f150..43206742c 100644 --- a/pom.xml +++ b/pom.xml @@ -26,13 +26,13 @@ com.wultra.security enrollment-server-parent - 1.5.0 + 1.6.0 pom org.springframework.boot spring-boot-starter-parent - 3.1.3 + 3.1.6 @@ -77,28 +77,37 @@ enrollment-server-api-model enrollment-server enrollment-server-onboarding - enrollment-server-onboarding-adapter-mock - enrollment-server-onboarding-domain-model + enrollment-server-onboarding-api enrollment-server-onboarding-api-model + enrollment-server-onboarding-adapter-mock enrollment-server-onboarding-common + enrollment-server-onboarding-domain-model + enrollment-server-onboarding-provider-innovatrics + enrollment-server-onboarding-provider-iproov + enrollment-server-onboarding-provider-zenid - 6.5.0 + 7.2.0 - 5.8.0 - 3.2.1 - 2.2.15 - 2.2.0 + 5.10.2 + 4.0.0 + 2.2.20 + 2.3.0 1.4.2 - 1.7.0 - 1.5.1 - 1.5.0 - 1.5.0 + + 3.13.0 + + 1.8.0 + 1.6.0 + 1.6.0 + 1.6.0 - 1.76 + 1.77 7.4 + + 1.4.14 @@ -111,7 +120,31 @@ com.wultra.security - enrollment-server-common + enrollment-server-onboarding-common + ${project.version} + + + + com.wultra.security + enrollment-server-onboarding-api + ${project.version} + + + + com.wultra.security + enrollment-server-onboarding-provider-innovatrics + ${project.version} + + + + com.wultra.security + enrollment-server-onboarding-provider-iproov + ${project.version} + + + + com.wultra.security + enrollment-server-onboarding-provider-zenid ${project.version} @@ -161,12 +194,6 @@ io.getlime.security powerauth-restful-security-spring-annotation ${powerauth-restful-integration.version} - - - org.springframework.boot - spring-boot-starter-tomcat - - @@ -184,8 +211,13 @@ org.apache.tomcat.embed tomcat-embed-el - ${tomcat.version} - test + provided + + + + org.springframework.boot + spring-boot-starter-tomcat + provided @@ -242,6 +274,14 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + external-service + + + org.apache.maven.plugins maven-source-plugin