diff --git a/docs/WebServices-Methods.md b/docs/WebServices-Methods.md index ac57aefab..b26fab9cd 100644 --- a/docs/WebServices-Methods.md +++ b/docs/WebServices-Methods.md @@ -88,6 +88,7 @@ The following `v3` methods are published using the service: - Operations - [createOperation](#method-createoperation) - [operationDetail](#method-operationdetail) + - [operationClaim](#method-operationclaim) - [findPendingOperationsForUser](#method-findpendingoperationsforuser) - [findAllOperationsForUser](#method-findalloperationsforuser) - [findAllOperationsByExternalId](#method-findalloperationsbyexternalid) @@ -2232,10 +2233,49 @@ REST endpoint: `POST /rest/v3/operation/detail` `OperationDetailRequest` -| Type | Name | Description | -|------|------|-------------| +| Type | Name | Description | +|----------|---------------|---------------------------------| | `String` | `operationId` | The identifier of the operation | -| `String` | `userId` | Optional user identifier of the user, used for operation claim. | + +#### Response + +`OperationDetailResponse` + +| Type | Name | Description | +|-----------------------|----------------------|----------------------------------------------------------------------------------------------------------------------------------| +| `String` | `id` | The operation ID | +| `String` | `userId` | The identifier of the user | +| `String` | `applicationId` | The identifier of the application | +| `String` | `externalId` | External identifier of the operation, i.e., ID from transaction system | +| `String` | `operationType` | Type of the operation created based on the template | +| `String` | `data` | Operation data | +| `Map` | `parameters` | Parameters of the operation, will be filled to the operation data | +| `OperationStatus` | `status` | Status of the operation | +| `String` | `statusReason` | Optional details why the status changed. The value should be sent in the form of a computer-readable code, not a free-form text. | +| `List` | `signatureType` | Allowed types of signature | +| `Long` | `failureCount` | The current number of the failed approval attempts | +| `Long` | `maxFailureCount` | The maximum allowed number of the failed approval attempts | +| `Date` | `timestampCreated` | Timestamp of when the operation was created | +| `Date` | `timestampExpires` | Timestamp of when the operation will expires / expired | +| `Date` | `timestampFinalized` | Timestamp of when the operation was switched to a terminating status | +| `String` | `riskFlags` | Risk flags for offline QR code. Uppercase letters without separator, e.g. `XFC`. | +| `String` | `proximityOtp` | TOTP for proximity check (if enabled) valid for the current time step. | +| `String` | `activationId` | Activation Id of the activation scoped for the operation | + +### Method 'operationClaim' + +Claim the operation for a user. + +#### Request + +REST endpoint: `POST /rest/v3/operation/claim` + +`OperationClaimRequest` + +| Type | Name | Description | +|----------|---------------|--------------------------------------------------------| +| `String` | `operationId` | The identifier of the operation | +| `String` | `userId` | User identifier of the user, used for operation claim. | #### Response diff --git a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/PowerAuthClient.java b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/PowerAuthClient.java index dadc5e49e..e26c4facd 100644 --- a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/PowerAuthClient.java +++ b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/PowerAuthClient.java @@ -1976,6 +1976,24 @@ RecoveryCodeActivationResponse createActivationUsingRecoveryCode(String recovery */ OperationDetailResponse operationDetail(OperationDetailRequest request, MultiValueMap queryParams, MultiValueMap httpHeaders) throws PowerAuthClientException; + /** + * Claim operation for a user. + * @param request Operation detail request. + * @return Operation detail response. + * @throws PowerAuthClientException In case REST API call fails. + */ + OperationDetailResponse operationClaim(OperationClaimRequest request) throws PowerAuthClientException; + + /** + * Claim operation for a user. + * @param request Operation detail request. + * @param queryParams HTTP query parameters. + * @param httpHeaders HTTP headers. + * @return Operation detail response. + * @throws PowerAuthClientException In case REST API call fails. + */ + OperationDetailResponse operationClaim(OperationClaimRequest request, MultiValueMap queryParams, MultiValueMap httpHeaders) throws PowerAuthClientException; + /** * Get list with all operations for provided user. * @param request Get operation list request. diff --git a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/OperationClaimRequest.java b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/OperationClaimRequest.java new file mode 100644 index 000000000..b7a910391 --- /dev/null +++ b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/OperationClaimRequest.java @@ -0,0 +1,41 @@ +/* + * PowerAuth Server and related software components + * Copyright (C) 2024 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.client.model.request; + +import lombok.Data; + +/** + * Request for operation claim. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +@Data +public class OperationClaimRequest { + + /** + * Operation identifier. + */ + private String operationId; + + /** + * Optional user identifier of the user who is claiming the operation. + */ + private String userId; + +} diff --git a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/OperationDetailRequest.java b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/OperationDetailRequest.java index 8499ab0d4..a59fffd2c 100644 --- a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/OperationDetailRequest.java +++ b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/OperationDetailRequest.java @@ -30,8 +30,4 @@ public class OperationDetailRequest { private String operationId; - /** - * Optional user identifier of the user who is requesting the operation. - */ - private String userId; } diff --git a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/validator/OperationClaimRequestValidator.java b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/validator/OperationClaimRequestValidator.java new file mode 100644 index 000000000..9fb7e7031 --- /dev/null +++ b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/validator/OperationClaimRequestValidator.java @@ -0,0 +1,46 @@ +/* + * PowerAuth Server and related software components + * Copyright (C) 2024 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.client.model.validator; + +import com.wultra.security.powerauth.client.model.request.OperationClaimRequest; + +/** + * Validator for OperationClaimRequest class. + * + * @author Roman Strobl, roman.strobl@wultra.com + */ +public class OperationClaimRequestValidator { + + public static String validate(OperationClaimRequest source) { + if (source == null) { + return "Operation claim request must not be null"; + } + if (source.getOperationId() == null) { + return "Operation ID must not be null when requesting operation claim"; + } + if (source.getOperationId().isEmpty()) { + return "Operation ID must not be empty when requesting operation claim"; + } + if (source.getUserId() == null || source.getUserId().isEmpty()) { + return "User ID must be specified when requesting operation claim"; + } + return null; + } + +} diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/controller/api/OperationsController.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/controller/api/OperationsController.java index 646d273f7..25cfe9a7a 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/controller/api/OperationsController.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/controller/api/OperationsController.java @@ -81,6 +81,21 @@ public ObjectResponse operationDetail(@RequestBody Obje return response; } + /** + * Claim operation for a user. + * + * @param request Claim operation for a user. + * @return Get operation response. + * @throws Exception In case the service throws exception. + */ + @PostMapping("/claim") + public ObjectResponse operationClaim(@RequestBody ObjectRequest request) throws Exception { + logger.info("OperationClaimRequest received: {}", request); + final ObjectResponse response = new ObjectResponse<>(service.operationClaim(request.getRequestObject())); + logger.info("OperationClaimRequest succeeded: {}", response); + return response; + } + /** * Find all operations for given user. * 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 08506b256..2ac63646d 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 @@ -369,13 +369,18 @@ public OperationUserActionResponse attemptApproveOperation(OperationApproveReque throw localizationProvider.buildExceptionForCode(ServiceError.OPERATION_APPROVE_FAILURE); } + final String expectedUserId = operationEntity.getUserId(); + if (expectedUserId == null) { + logger.warn("Operation cannot be approved, because user ID is not set: {}.", operationId); + throw localizationProvider.buildExceptionForCode(ServiceError.OPERATION_APPROVE_FAILURE); + } + // Check the operation properties match the request final PowerAuthSignatureTypes factorEnum = PowerAuthSignatureTypes.getEnumFromString(signatureType.toString()); final ProximityCheckResult proximityCheckResult = fetchProximityCheckResult(operationEntity, request, currentInstant); - final String expectedUserId = operationEntity.getUserId(); final boolean activationIdMatches = activationIdMatches(request, operationEntity.getActivationId()); final boolean operationShouldFail = operationApprovalCustomizer.operationShouldFail(operationEntity, request); - if ((expectedUserId == null || expectedUserId.equals(userId)) // correct user approved the operation + if (expectedUserId.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 @@ -763,6 +768,38 @@ public OperationDetailResponse operationDetail(OperationDetailRequest request) t return localizationProvider.buildExceptionForCode(ServiceError.OPERATION_NOT_FOUND); }); + final OperationEntity operationEntity = expireOperation(operation, currentTimestamp); + final OperationDetailResponse operationDetailResponse = convertFromEntityAndFillOtp(operationEntity); + extendAndSetOperationDetailData(operationDetailResponse); + return operationDetailResponse; + } catch (GenericServiceException ex) { + // already logged + throw ex; + } catch (RuntimeException ex) { + logger.error("Runtime exception or error occurred, transaction will be rolled back", ex); + throw ex; + } catch (Exception ex) { + logger.error("Unknown error occurred", ex); + throw new GenericServiceException(ServiceError.UNKNOWN_ERROR, ex.getMessage()); + } + } + + @Transactional // operation is modified when expiration happens + public OperationDetailResponse operationClaim(OperationClaimRequest request) throws GenericServiceException { + try { + final String error = OperationClaimRequestValidator.validate(request); + if (error != null) { + throw new GenericServiceException(ServiceError.INVALID_REQUEST, error); + } + + final Date currentTimestamp = new Date(); + final String operationId = request.getOperationId(); + + final OperationEntity operation = operationQueryService.findOperationForUpdate(operationId).orElseThrow(() -> { + logger.warn("Operation was not found for ID: {}", operationId); + return localizationProvider.buildExceptionForCode(ServiceError.OPERATION_NOT_FOUND); + }); + final String userId = request.getUserId(); final OperationEntity operationEntity = expireOperation( claimOperation(operation, userId, currentTimestamp), diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/fido2/PowerAuthAssertionProvider.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/fido2/PowerAuthAssertionProvider.java index 7661b5eaa..017146bef 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/fido2/PowerAuthAssertionProvider.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/fido2/PowerAuthAssertionProvider.java @@ -31,10 +31,7 @@ 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.request.OperationApproveRequest; -import com.wultra.security.powerauth.client.model.request.OperationCreateRequest; -import com.wultra.security.powerauth.client.model.request.OperationDetailRequest; -import com.wultra.security.powerauth.client.model.request.OperationFailApprovalRequest; +import com.wultra.security.powerauth.client.model.request.*; import com.wultra.security.powerauth.client.model.response.OperationDetailResponse; import com.wultra.security.powerauth.client.model.response.OperationUserActionResponse; import com.wultra.security.powerauth.fido2.model.entity.AuthenticatorDetail; @@ -133,6 +130,12 @@ public AssertionChallenge approveAssertion(String challengeValue, AuthenticatorD final String operationId = split[0]; final String operationData = split[1]; + // Claim operation to set user ID for this operation before approval + final OperationClaimRequest claimRequest = new OperationClaimRequest(); + claimRequest.setOperationId(operationId); + claimRequest.setUserId(authenticatorDetail.getUserId()); + operationServiceBehavior.operationClaim(claimRequest); + final OperationApproveRequest operationApproveRequest = new OperationApproveRequest(); operationApproveRequest.setOperationId(operationId); operationApproveRequest.setData(operationData); diff --git a/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/controller/api/PowerAuthControllerTest.java b/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/controller/api/PowerAuthControllerTest.java index 13791d7df..ad9ec6b93 100644 --- a/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/controller/api/PowerAuthControllerTest.java +++ b/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/controller/api/PowerAuthControllerTest.java @@ -454,7 +454,6 @@ void testGetOperationDetail() throws Exception { final OperationDetailRequest detailRequest = new OperationDetailRequest(); final String operationId = operation.getId(); detailRequest.setOperationId(operationId); - detailRequest.setUserId(PowerAuthControllerTestConfig.USER_ID); final OperationDetailResponse detailResponse = powerAuthClient.operationDetail(detailRequest); assertEquals(OperationStatus.PENDING, detailResponse.getStatus()); 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 index 98425b76d..e8731edd6 100644 --- 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 @@ -610,11 +610,11 @@ void testOperationClaim() throws Exception { final String operationId = createLoginOperation(); final String userId = "user_" + UUID.randomUUID(); - final OperationDetailRequest detailRequest = new OperationDetailRequest(); - detailRequest.setOperationId(operationId); - detailRequest.setUserId(userId); + final OperationClaimRequest claimRequest = new OperationClaimRequest(); + claimRequest.setOperationId(operationId); + claimRequest.setUserId(userId); // Check operation claim - assertEquals(userId, operationService.operationDetail(detailRequest).getUserId()); + assertEquals(userId, operationService.operationClaim(claimRequest).getUserId()); } @Test @@ -700,7 +700,7 @@ void testParsingDeviceOperationCancelDetail() throws Exception { } @Test - void testAnonymousOperationApprovedUserChanged() throws GenericServiceException { + void testAnonymousOperationApprovalNotAllowed() throws GenericServiceException { final OperationCreateRequest operationCreateRequest = new OperationCreateRequest(); operationCreateRequest.setApplications(List.of("PA_Tests")); operationCreateRequest.setTemplateName("test-template"); @@ -711,58 +711,7 @@ void testAnonymousOperationApprovedUserChanged() throws GenericServiceException approveRequest.setData("A2"); approveRequest.setApplicationId("PA_Tests"); approveRequest.setSignatureType(SignatureType.POSSESSION_KNOWLEDGE); - final OperationUserActionResponse response = operationService.attemptApproveOperation(approveRequest); - assertEquals(UserActionResult.APPROVED, response.getResult()); - final OperationDetailRequest detailRequest = new OperationDetailRequest(); - detailRequest.setOperationId(operation.getId()); - final OperationDetailResponse operationDetail = operationService.operationDetail(detailRequest); - assertEquals("test_user", operationDetail.getUserId()); - } - - @Test - void testAnonymousOperationFailedApproveUserNotChanged() throws GenericServiceException { - final OperationCreateRequest operationCreateRequest = new OperationCreateRequest(); - operationCreateRequest.setApplications(List.of("PA_Tests")); - operationCreateRequest.setTemplateName("test-template"); - final OperationDetailResponse operation = operationService.createOperation(operationCreateRequest); - final OperationApproveRequest approveRequest = new OperationApproveRequest(); - approveRequest.setOperationId(operation.getId()); - approveRequest.setUserId("invalid_user"); - approveRequest.setData("invalid_data"); - approveRequest.setApplicationId("PA_Tests"); - approveRequest.setSignatureType(SignatureType.POSSESSION_KNOWLEDGE); - final OperationUserActionResponse response = operationService.attemptApproveOperation(approveRequest); - assertEquals(UserActionResult.APPROVAL_FAILED, response.getResult()); - final OperationDetailRequest detailRequest = new OperationDetailRequest(); - detailRequest.setOperationId(operation.getId()); - final OperationDetailResponse operationDetail = operationService.operationDetail(detailRequest); - assertNull(operationDetail.getUserId()); - } - - @Test - void testAnonymousOperationFailedOperationUserNotChanged() throws GenericServiceException { - final OperationCreateRequest operationCreateRequest = new OperationCreateRequest(); - operationCreateRequest.setApplications(List.of("PA_Tests")); - operationCreateRequest.setTemplateName("test-template"); - final OperationDetailResponse operation = operationService.createOperation(operationCreateRequest); - for (int i = 0; i < 5; i++) { - final OperationApproveRequest approveRequest = new OperationApproveRequest(); - approveRequest.setOperationId(operation.getId()); - approveRequest.setUserId("invalid_user"); - approveRequest.setData("invalid_data"); - approveRequest.setApplicationId("PA_Tests"); - approveRequest.setSignatureType(SignatureType.POSSESSION_KNOWLEDGE); - final OperationUserActionResponse response = operationService.attemptApproveOperation(approveRequest); - if (i == 4) { - assertEquals(UserActionResult.OPERATION_FAILED, response.getResult()); - } else { - assertEquals(UserActionResult.APPROVAL_FAILED, response.getResult()); - } - } - final OperationDetailRequest detailRequest = new OperationDetailRequest(); - detailRequest.setOperationId(operation.getId()); - final OperationDetailResponse operationDetail = operationService.operationDetail(detailRequest); - assertNull(operationDetail.getUserId()); + assertThrows(GenericServiceException.class, () -> operationService.attemptApproveOperation(approveRequest)); } @Test diff --git a/powerauth-rest-client-spring/src/main/java/com/wultra/security/powerauth/rest/client/PowerAuthRestClient.java b/powerauth-rest-client-spring/src/main/java/com/wultra/security/powerauth/rest/client/PowerAuthRestClient.java index 9a70fbc4d..c6879029c 100644 --- a/powerauth-rest-client-spring/src/main/java/com/wultra/security/powerauth/rest/client/PowerAuthRestClient.java +++ b/powerauth-rest-client-spring/src/main/java/com/wultra/security/powerauth/rest/client/PowerAuthRestClient.java @@ -42,7 +42,6 @@ import org.springframework.http.HttpStatus; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; -import org.springframework.util.StringUtils; import java.io.IOException; import java.time.Duration; @@ -1388,6 +1387,16 @@ public OperationDetailResponse operationDetail(OperationDetailRequest request, M return callV3RestApi("/operation/detail", request, queryParams, httpHeaders, OperationDetailResponse.class); } + @Override + public OperationDetailResponse operationClaim(OperationClaimRequest request) throws PowerAuthClientException { + return operationClaim(request, EMPTY_MULTI_MAP, EMPTY_MULTI_MAP); + } + + @Override + public OperationDetailResponse operationClaim(OperationClaimRequest request, MultiValueMap queryParams, MultiValueMap httpHeaders) throws PowerAuthClientException { + return callV3RestApi("/operation/claim", request, queryParams, httpHeaders, OperationDetailResponse.class); + } + @Override public OperationListResponse operationList(OperationListForUserRequest request) throws PowerAuthClientException { return operationList(request, EMPTY_MULTI_MAP, EMPTY_MULTI_MAP);