diff --git a/docs/PowerAuth-Server-1.6.0.md b/docs/PowerAuth-Server-1.6.0.md index 30041fe1e..f88c2668a 100644 --- a/docs/PowerAuth-Server-1.6.0.md +++ b/docs/PowerAuth-Server-1.6.0.md @@ -21,3 +21,13 @@ and `pa_application` or `pa_operation`. Make sure that `pa_operation_application existing `pa_application.id` and `pa_operation_application.operation_id` contains references to existing `pa_operation.id`. If necessary, manually remove orphaned records in `pa_operation_application`. Consider creating a backup before this operation. + +### Add activation_id Column + +Add a new column `activation_id` to the `pa_operation` table. This column is a foreign key that references +the `activation_id` column in the `pa_activation` table. Storing the `activation_id` in the `pa_operation` table +provides several enhancements: + +* It allows the creation of a new operation tied to a specific mobile device, identified by its activation ID. +* It ensures that the operation can only be approved on that specific mobile device, again identified by its activation ID. + diff --git a/docs/WebServices-Methods.md b/docs/WebServices-Methods.md index ef556b7c3..81811610b 100644 --- a/docs/WebServices-Methods.md +++ b/docs/WebServices-Methods.md @@ -2017,12 +2017,14 @@ REST endpoint: `POST /rest/v3/operation/create` | Type | Name | Description | |-----------------------|-------------------------|--------------------------------------------------------------------------------------------------| | `String` | `userId` | The identifier of the user | -| `String` | `applicationId` | An identifier of an application | +| `List` | `applications` | List of associated applications | +| `String` | `activationFlag` | Activation flag associated with the operation | | `String` | `templateName` | Name of the template used for creating the operation | | `Date` | `timestampExpires` | Timestamp of when the operation will expire, overrides expiration period from operation template | | `String` | `externalId` | External identifier of the operation, i.e., ID from transaction system | | `Map` | `parameters` | Parameters of the operation, will be filled to the operation data | -| `Boolean` | `proximityCheckEnabled` | Whether proximity check should be used. Overrides configuration from operation template. | +| `Boolean` | `proximityCheckEnabled` | Whether proximity check should be used, overrides configuration from operation template | +| `String` | `activationId` | Activation Id of a specific device | #### Response diff --git a/docs/db/changelog/changesets/powerauth-java-server/1.6.x/20231112-add-activation-id.xml b/docs/db/changelog/changesets/powerauth-java-server/1.6.x/20231112-add-activation-id.xml new file mode 100644 index 000000000..d2c27854b --- /dev/null +++ b/docs/db/changelog/changesets/powerauth-java-server/1.6.x/20231112-add-activation-id.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + Add activation_id column to pa_operation with foreign key constraint + + + + + + + diff --git a/docs/db/changelog/changesets/powerauth-java-server/1.6.x/db.changelog-version.xml b/docs/db/changelog/changesets/powerauth-java-server/1.6.x/db.changelog-version.xml index 799d25f90..2733cc380 100644 --- a/docs/db/changelog/changesets/powerauth-java-server/1.6.x/db.changelog-version.xml +++ b/docs/db/changelog/changesets/powerauth-java-server/1.6.x/db.changelog-version.xml @@ -6,5 +6,6 @@ + diff --git a/docs/images/arch_db_structure.png b/docs/images/arch_db_structure.png index 9d7ab18a4..26c1bff15 100644 Binary files a/docs/images/arch_db_structure.png and b/docs/images/arch_db_structure.png differ diff --git a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/OperationCreateRequest.java b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/OperationCreateRequest.java index f322290c1..64afb84ac 100644 --- a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/OperationCreateRequest.java +++ b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/OperationCreateRequest.java @@ -18,6 +18,7 @@ package com.wultra.security.powerauth.client.model.request; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.*; @@ -30,13 +31,31 @@ @Data public class OperationCreateRequest { + @Schema(description = "The identifier of the user", requiredMode = Schema.RequiredMode.REQUIRED) private String userId; + + @Schema(description = "List of associated applications", requiredMode = Schema.RequiredMode.REQUIRED) private List applications = new ArrayList<>(); + + @Schema(description = "Activation flag associated with the operation", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String activationFlag; + + @Schema(description = "Name of the template used for creating the operation", requiredMode = Schema.RequiredMode.REQUIRED) private String templateName; + + @Schema(description = "Timestamp of when the operation will expire, overrides expiration period from operation template", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Date timestampExpires; + + @Schema(description = "External identifier of the operation, i.e., ID from transaction system", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String externalId; + + @Schema(description = "Parameters of the operation, will be filled to the operation data", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private final Map parameters = new LinkedHashMap<>(); + + @Schema(description = "Whether proximity check should be used, overrides configuration from operation template", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Boolean proximityCheckEnabled; + @Schema(description = "Activation Id of a specific device", requiredMode = Schema.RequiredMode.NOT_REQUIRED, maxLength = 37) + private String activationId; + } diff --git a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/validator/OperationCreateRequestValidator.java b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/validator/OperationCreateRequestValidator.java index 120320804..dc0889b84 100644 --- a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/validator/OperationCreateRequestValidator.java +++ b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/validator/OperationCreateRequestValidator.java @@ -27,11 +27,13 @@ */ public class OperationCreateRequestValidator { + private static final int MAX_ACTIVATION_ID_LENGTH = 37; + public static String validate(OperationCreateRequest source) { if (source == null) { - return "Operation create request must not be null"; + return "Operation create request must not be null when creating operation"; } - if (source.getApplications() == null || source.getApplications().size() == 0) { + if (source.getApplications() == null || source.getApplications().isEmpty()) { return "Application ID list must not be null or empty when creating operation"; } if (source.getUserId() == null) { @@ -46,6 +48,9 @@ public static String validate(OperationCreateRequest source) { if (source.getTemplateName().isEmpty()) { return "Template name must not be empty when creating operation"; } + if (source.getActivationId() != null && source.getActivationId().length() > MAX_ACTIVATION_ID_LENGTH) { + return "Activation ID must not exceed 37 characters when creating operation"; + } return null; } diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/entity/OperationEntity.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/entity/OperationEntity.java index a8fb7217c..09e2278d2 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/entity/OperationEntity.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/entity/OperationEntity.java @@ -111,6 +111,12 @@ public class OperationEntity implements Serializable { @Column(name = "totp_seed") private String totpSeed; + /** + * Optional activationId of a device. + */ + @Column(name = "activation_id") + private String activationId; + /** * Get operation ID. * @return Operation ID. @@ -419,6 +425,24 @@ public void setTotpSeed(String totpSeed) { this.totpSeed = totpSeed; } + /** + * Get the activation ID. + * + * @return Activation ID. + */ + public String getActivationId() { + return activationId; + } + + /** + * Set the activation ID. + * + * @param activationId Activation ID to set. + */ + public void setActivationId(String activationId) { + this.activationId = activationId; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/OperationServiceBehavior.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/OperationServiceBehavior.java index 87ffd97bb..b2dccd14e 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/OperationServiceBehavior.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/OperationServiceBehavior.java @@ -114,6 +114,7 @@ public OperationDetailResponse createOperation(OperationCreateRequest request) t final Date timestampExpiresRequest = request.getTimestampExpires(); final Map parameters = request.getParameters() != null ? request.getParameters() : new LinkedHashMap<>(); final String externalId = request.getExternalId(); + final String activationId = request.getActivationId(); // Prepare current timestamp in advance final Date currentTimestamp = new Date(); @@ -188,6 +189,7 @@ public OperationDetailResponse createOperation(OperationCreateRequest request) t operationEntity.setTimestampFinalized(null); // empty initially operationEntity.setRiskFlags(templateEntity.getRiskFlags()); operationEntity.setTotpSeed(generateTotpSeed(request, templateEntity)); + operationEntity.setActivationId(activationId); final AuditDetail auditDetail = AuditDetail.builder() .type(AuditType.OPERATION.getCode()) @@ -205,6 +207,7 @@ public OperationDetailResponse createOperation(OperationCreateRequest request) t .param("maxFailureCount", operationEntity.getMaxFailureCount()) .param("timestampExpires", timestampExpires) .param("proximityCheckEnabled", operationEntity.getTotpSeed() != null) + .param("activationId", activationId) .build(); audit.log(AuditLevel.INFO, "Operation created with ID: {}", auditDetail, operationId); @@ -250,13 +253,15 @@ public OperationUserActionResponse attemptApproveOperation(OperationApproveReque // Check the operation properties match the request final PowerAuthSignatureTypes factorEnum = PowerAuthSignatureTypes.getEnumFromString(signatureType.toString()); final ProximityCheckResult proximityCheckResult = fetchProximityCheckResult(operationEntity, request, currentInstant); + final boolean activationIdMatches = activationIdMatches(request, operationEntity.getActivationId()); if (operationEntity.getUserId().equals(userId) // correct user approved the operation && operationEntity.getApplications().contains(application.get()) // operation is approved by the expected application && isDataEqual(operationEntity, data) // operation data matched the expected value && factorsAcceptable(operationEntity, factorEnum) // auth factors are acceptable && operationEntity.getMaxFailureCount() > operationEntity.getFailureCount() // operation has sufficient attempts left (redundant check) - && proximityCheckPassed(proximityCheckResult)){ + && proximityCheckPassed(proximityCheckResult) + && activationIdMatches){ // either Operation does not have assigned activationId or it has one, and it matches activationId from request // Approve the operation operationEntity.setStatus(OperationStatusDo.APPROVED); @@ -277,6 +282,7 @@ && proximityCheckPassed(proximityCheckResult)){ .param("failureCount", operationEntity.getFailureCount()) .param("proximityCheckResult", proximityCheckResult) .param("currentTimestamp", currentTimestamp) + .param("activationIdOperation", operationEntity.getActivationId()) .build(); audit.log(AuditLevel.INFO, "Operation approved with ID: {}", auditDetail, operationId); @@ -310,6 +316,9 @@ && proximityCheckPassed(proximityCheckResult)){ .param("failureCount", operationEntity.getFailureCount()) .param("proximityCheckResult", proximityCheckResult) .param("currentTimestamp", currentTimestamp) + .param("activationIdMatches", activationIdMatches) + .param("activationIdOperation", operationEntity.getActivationId()) + .param("activationIdRequest", additionalData.get("activationId")) .build(); audit.log(AuditLevel.INFO, "Operation approval failed with ID: {}, failed attempts count: {}", auditDetail, operationId, operationEntity.getFailureCount()); @@ -340,6 +349,9 @@ && proximityCheckPassed(proximityCheckResult)){ .param("maxFailureCount", operationEntity.getMaxFailureCount()) .param("proximityCheckResult", proximityCheckResult) .param("currentTimestamp", currentTimestamp) + .param("activationIdMatches", activationIdMatches) + .param("activationIdOperation", operationEntity.getActivationId()) + .param("activationIdRequest", additionalData.get("activationId")) .build(); audit.log(AuditLevel.INFO, "Operation failed with ID: {}", auditDetail, operationId); @@ -791,6 +803,10 @@ private static boolean proximityCheckPassed(final ProximityCheckResult proximity return proximityCheckResult == ProximityCheckResult.SUCCESS || proximityCheckResult == ProximityCheckResult.DISABLED; } + private static boolean activationIdMatches(final OperationApproveRequest operationApproveRequest, String activationId) { + return activationId == null || activationId.equals(operationApproveRequest.getAdditionalData().get("activationId")); + } + private ProximityCheckResult fetchProximityCheckResult(final OperationEntity operation, final OperationApproveRequest request, final Instant now) { final String seed = operation.getTotpSeed(); if (seed == null) { diff --git a/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/OperationServiceBehaviorTest.java b/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/OperationServiceBehaviorTest.java new file mode 100644 index 000000000..6b4998363 --- /dev/null +++ b/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/OperationServiceBehaviorTest.java @@ -0,0 +1,212 @@ +/* + * PowerAuth Server and related software components + * 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 io.getlime.security.powerauth.app.server.service.behavior.tasks; + +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.request.OperationApproveRequest; +import com.wultra.security.powerauth.client.model.request.OperationCreateRequest; +import com.wultra.security.powerauth.client.model.response.OperationDetailResponse; +import com.wultra.security.powerauth.client.model.response.OperationUserActionResponse; +import io.getlime.security.powerauth.app.server.database.model.entity.OperationEntity; +import io.getlime.security.powerauth.app.server.database.repository.OperationRepository; +import io.getlime.security.powerauth.app.server.service.exceptions.GenericServiceException; +import jakarta.transaction.Transactional; +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.jdbc.Sql; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test for {@link OperationServiceBehavior}. + * + * @author Jan Dusil, jan.dusil@wultra.com + */ +@SpringBootTest +@Sql +@Transactional +class OperationServiceBehaviorTest { + + @Autowired + private OperationServiceBehavior tested; + + @Autowired + private OperationRepository operationRepository; + + /** + * Tests the creation of an operation with a specified activation ID. + * Verifies that the operation is correctly created and stored with the provided activation ID. + */ + @Test + void testCreateOperationWithActivationId() throws GenericServiceException { + final OperationCreateRequest request = new OperationCreateRequest(); + request.setActivationId("testActivationId"); + request.setTemplateName("test-template"); + request.setUserId("test-user"); + + final OperationDetailResponse operationDetailResponse = tested.createOperation(request); + final OperationEntity savedEntity = operationRepository.findOperation(operationDetailResponse.getId()).get(); + assertTrue(operationRepository.findOperation(operationDetailResponse.getId()).isPresent()); + assertEquals("testActivationId", savedEntity.getActivationId()); + } + + /** + * Tests the creation of an operation without specifying an activation ID. + * Verifies that the operation is correctly created and stored without an activation ID. + */ + @Test + void testCreateOperationWithoutActivationId() throws GenericServiceException { + final OperationCreateRequest request = new OperationCreateRequest(); + request.setTemplateName("test-template"); + request.setUserId("test-user"); + + final OperationDetailResponse operationDetailResponse = tested.createOperation(request); + assertTrue(operationRepository.findOperation(operationDetailResponse.getId()).isPresent()); + final OperationEntity savedEntity = operationRepository.findOperation(operationDetailResponse.getId()).get(); + assertNull(savedEntity.getActivationId()); + } + + /** + * Tests the approval of an operation with a matching activation ID. + * Verifies that the operation is successfully approved when the provided activation ID matches the stored one. + */ + @Test + void testApproveOperationWithMatchingActivationIdSuccess() throws GenericServiceException { + final OperationCreateRequest request = new OperationCreateRequest(); + request.setActivationId("testActivationId"); + request.setTemplateName("test-template"); + request.setUserId("test-user"); + request.setApplications(Collections.singletonList("PA_Tests")); + + final OperationDetailResponse operationDetailResponse = tested.createOperation(request); + assertTrue(operationRepository.findOperation(operationDetailResponse.getId()).isPresent()); + final OperationEntity savedEntity = operationRepository.findOperation(operationDetailResponse.getId()).get(); + assertEquals("testActivationId", savedEntity.getActivationId()); + + OperationApproveRequest operationApproveRequest = new OperationApproveRequest(); + operationApproveRequest.setOperationId(savedEntity.getId()); + operationApproveRequest.getAdditionalData().put("activationId", savedEntity.getActivationId()); + operationApproveRequest.setApplicationId("PA_Tests"); + operationApproveRequest.setUserId(savedEntity.getUserId()); + operationApproveRequest.setSignatureType(SignatureType.POSSESSION_KNOWLEDGE); + operationApproveRequest.setData("A2"); + + final OperationUserActionResponse operationUserActionResponse = tested.attemptApproveOperation(operationApproveRequest); + assertNotNull(operationUserActionResponse); + assertEquals(UserActionResult.APPROVED, operationUserActionResponse.getResult()); + } + + /** + * Tests the approval of an operation without an activation ID in the OperationEntity. + * Verifies that the operation is successfully approved even without an activation ID. + */ + @Test + void testApproveOperationEntityWithoutActivationIdSuccess() throws GenericServiceException { + final OperationCreateRequest request = new OperationCreateRequest(); + request.setTemplateName("test-template"); + request.setUserId("test-user"); + request.setApplications(Collections.singletonList("PA_Tests")); + + final OperationDetailResponse operationDetailResponse = tested.createOperation(request); + assertTrue(operationRepository.findOperation(operationDetailResponse.getId()).isPresent()); + final OperationEntity savedEntity = operationRepository.findOperation(operationDetailResponse.getId()).get(); + assertNull(savedEntity.getActivationId()); + + OperationApproveRequest operationApproveRequest = new OperationApproveRequest(); + operationApproveRequest.setOperationId(savedEntity.getId()); + operationApproveRequest.getAdditionalData().put("activationId", savedEntity.getActivationId()); + operationApproveRequest.setApplicationId("PA_Tests"); + operationApproveRequest.setUserId(savedEntity.getUserId()); + operationApproveRequest.setSignatureType(SignatureType.POSSESSION_KNOWLEDGE); + operationApproveRequest.setData("A2"); + + final OperationUserActionResponse operationUserActionResponse = tested.attemptApproveOperation(operationApproveRequest); + assertNotNull(operationUserActionResponse); + assertEquals(UserActionResult.APPROVED, operationUserActionResponse.getResult()); + } + + /** + * Tests the failure of operation approval due to a non-matching activation ID. + * Verifies that the operation approval fails when the provided activation ID does not match the stored one. + */ + @Test + void testApproveOperationWithoutMatchingActivationIdFailure() throws GenericServiceException { + final OperationCreateRequest request = new OperationCreateRequest(); + request.setActivationId("testActivationId"); + request.setTemplateName("test-template"); + request.setUserId("test-user"); + request.setApplications(Collections.singletonList("PA_Tests")); + + final OperationDetailResponse operationDetailResponse = tested.createOperation(request); + assertTrue(operationRepository.findOperation(operationDetailResponse.getId()).isPresent()); + final OperationEntity savedEntity = operationRepository.findOperation(operationDetailResponse.getId()).get(); + assertEquals("testActivationId", savedEntity.getActivationId()); + + final OperationApproveRequest operationApproveRequest = new OperationApproveRequest(); + operationApproveRequest.setOperationId(savedEntity.getId()); + operationApproveRequest.getAdditionalData().put("activationId2", savedEntity.getActivationId()); + operationApproveRequest.setApplicationId("PA_Tests"); + operationApproveRequest.setUserId(savedEntity.getUserId()); + operationApproveRequest.setSignatureType(SignatureType.POSSESSION_KNOWLEDGE); + operationApproveRequest.setData("A2"); + + final OperationUserActionResponse operationUserActionResponse = tested.attemptApproveOperation(operationApproveRequest); + final OperationEntity updatedEntity = operationRepository.findOperation(operationDetailResponse.getId()).get(); + assertEquals("testActivationId", savedEntity.getActivationId()); + assertNotNull(operationUserActionResponse); + assertEquals(UserActionResult.APPROVAL_FAILED, operationUserActionResponse.getResult()); + assertEquals(1, updatedEntity.getFailureCount()); + } + + /** + * Tests the failure of operation approval due to a non-matching activation ID, with maximum failure count reached. + * Verifies that the operation fails completely when the provided activation ID does not match and maximum failure attempts are reached. + */ + @Test + void testApproveOperationWithoutMatchingActivationIdFailureMax() throws GenericServiceException { + final OperationCreateRequest request = new OperationCreateRequest(); + request.setActivationId("testActivationId"); + request.setTemplateName("test-template"); + request.setUserId("test-user"); + request.setApplications(Collections.singletonList("PA_Tests")); + + final OperationDetailResponse operationDetailResponse = tested.createOperation(request); + assertTrue(operationRepository.findOperation(operationDetailResponse.getId()).isPresent()); + final OperationEntity entity = operationRepository.findOperation(operationDetailResponse.getId()).get(); + assertEquals("testActivationId", entity.getActivationId()); + entity.setFailureCount(4L); + + + final OperationApproveRequest operationApproveRequest = new OperationApproveRequest(); + operationApproveRequest.setOperationId(entity.getId()); + operationApproveRequest.getAdditionalData().put("activationId2", entity.getActivationId()); + operationApproveRequest.setApplicationId("PA_Tests"); + operationApproveRequest.setUserId(entity.getUserId()); + operationApproveRequest.setSignatureType(SignatureType.POSSESSION_KNOWLEDGE); + operationApproveRequest.setData("A2"); + + final OperationUserActionResponse operationUserActionResponse = tested.attemptApproveOperation(operationApproveRequest); + assertNotNull(operationUserActionResponse); + assertEquals(UserActionResult.OPERATION_FAILED, operationUserActionResponse.getResult()); + } + +} diff --git a/powerauth-java-server/src/test/resources/io/getlime/security/powerauth/app/server/service/behavior/tasks/OperationServiceBehaviorTest.sql b/powerauth-java-server/src/test/resources/io/getlime/security/powerauth/app/server/service/behavior/tasks/OperationServiceBehaviorTest.sql new file mode 100644 index 000000000..199eea684 --- /dev/null +++ b/powerauth-java-server/src/test/resources/io/getlime/security/powerauth/app/server/service/behavior/tasks/OperationServiceBehaviorTest.sql @@ -0,0 +1,5 @@ +INSERT INTO pa_operation_template (id, template_name, operation_type, data_template, signature_type, max_failure_count, expiration, proximity_check_enabled) +VALUES (100, 'test-template', 'test-template', 'A2', 'POSSESSION_KNOWLEDGE', 5, 300, false); + +INSERT INTO pa_application (id, name) VALUES + (21, 'PA_Tests'); \ No newline at end of file