diff --git a/docs/WebServices-Methods.md b/docs/WebServices-Methods.md index d82741fc0..ef556b7c3 100644 --- a/docs/WebServices-Methods.md +++ b/docs/WebServices-Methods.md @@ -25,6 +25,7 @@ The following `v3` methods are published using the service: - [prepareActivation](#method-prepareactivation) - [createActivation](#method-createactivation) - [updateActivationOtp](#method-updateactivationotp) + - [updateActivationName](#method-updateactivationname) - [commitActivation](#method-commitactivation) - [getActivationStatus](#method-getactivationstatus) - [removeActivation](#method-removeactivation) @@ -519,6 +520,35 @@ REST endpoint: `POST /rest/v3/activation/otp/update` | `String` | `activationId` | An identifier of an activation | | `boolean` | `updated` | Flag indicating that OTP has been updated | +### Method 'updateActivationName' + +Update activation name for activation with given ID. +No allowed for activation in status `CREATED`, `REMOVED`, or `BLOCKED`. + +After successful, activation name is updated. + +#### Request + +REST endpoint: `POST /rest/v3/activation/name/update` + +`UpdateActivationNameRequest` + +| Type | Name | Description | +|----------|------------------|---------------------------------------------------------------------------------------------------| +| `String` | `activationId` | An identifier of an activation. | +| `String` | `externalUserId` | User ID of user who changes the activation. Use null value if activation owner caused the change. | +| `String` | `activationName` | A new value of activation name. | + +#### Response + +`UpdateActivationNameResponse` + +| Type | Name | Description | +|--------------------|--------------------|---------------------------------| +| `String` | `activationId` | An identifier of an activation | +| `String` | `activationName` | A new value of activation name. | +| `ActivationStatus` | `activationStatus` | An activation status. | + ### Method 'commitActivation' Commit activation with given ID. Only non-expired activations in PENDING_COMMIT state can be committed. @@ -1166,14 +1196,15 @@ REST endpoint: `POST /rest/v3/activation/history` `ActivationHistoryResponse.Item` -| Type | Name | Description | -|------|------|-------------| -| `Long` | `id` | Change ID | -| `String` | `activationId` | An identifier of an activation | -| `ActivationStatus` | `activationStatus` | An activation status at the moment of a signature verification | -| `String` | `eventReason` | Reason why this activation history record was created (default: null) | -| `String` | `externalUserId` | User ID of user who modified the activation. Null value is used if activation owner caused the change. | -| `DateTime` | `timestampCreated` | Timestamp when the record was created | +| Type | Name | Description | +|--------------------|--------------------|--------------------------------------------------------------------------------------------------------| +| `Long` | `id` | Change ID | +| `String` | `activationId` | An identifier of an activation | +| `ActivationStatus` | `activationStatus` | An activation status at the moment of a signature verification | +| `String` | `eventReason` | Reason why this activation history record was created (default: null) | +| `String` | `externalUserId` | User ID of user who modified the activation. Null value is used if activation owner caused the change. | +| `String` | `activationName` | Activation name. | +| `DateTime` | `timestampCreated` | Timestamp when the record was created | ## Integration management diff --git a/docs/db/changelog/changesets/powerauth-java-server/1.6.x/20231103-add-activation-name-history.xml b/docs/db/changelog/changesets/powerauth-java-server/1.6.x/20231103-add-activation-name-history.xml new file mode 100644 index 000000000..984806c2c --- /dev/null +++ b/docs/db/changelog/changesets/powerauth-java-server/1.6.x/20231103-add-activation-name-history.xml @@ -0,0 +1,17 @@ + + + + + + + + + + Add activation_name column to pa_activation_history + + + + + 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 new file mode 100644 index 000000000..a9d46c614 --- /dev/null +++ b/docs/db/changelog/changesets/powerauth-java-server/1.6.x/db.changelog-version.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/docs/images/arch_db_structure.png b/docs/images/arch_db_structure.png index de65c78dc..9d7ab18a4 100644 Binary files a/docs/images/arch_db_structure.png and b/docs/images/arch_db_structure.png differ diff --git a/pom.xml b/pom.xml index a69e56bfa..7fab884c4 100644 --- a/pom.xml +++ b/pom.xml @@ -101,6 +101,7 @@ 1.10.0 7.4 + 3.15.3 @@ -158,6 +159,13 @@ pom import + + + nl.jqno.equalsverifier + equalsverifier + ${equalsverifier.version} + test + 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 8b8f0820b..77e26fe12 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 @@ -213,6 +213,26 @@ PrepareActivationResponse prepareActivation(String activationCode, String applic */ CreateActivationResponse createActivation(CreateActivationRequest request, MultiValueMap queryParams, MultiValueMap httpHeaders) throws PowerAuthClientException; + /** + * Update the activation name directly, using the updateActivationName method of the PowerAuth Server interface. + * + * @param request Update activation name request. + * @return Update activation name response. + * @throws PowerAuthClientException In case REST API call fails. + */ + UpdateActivationNameResponse updateActivationName(UpdateActivationNameRequest request) throws PowerAuthClientException; + + /** + * Update the activation name directly, using the updateActivationName method of the PowerAuth Server interface. + * + * @param request Update activation request. + * @param queryParams HTTP query parameters. + * @param httpHeaders HTTP headers. + * @return Update activation name response. + * @throws PowerAuthClientException In case REST API call fails. + */ + UpdateActivationNameResponse updateActivationName(UpdateActivationNameRequest request, MultiValueMap queryParams, MultiValueMap httpHeaders) throws PowerAuthClientException; + /** * Call the createActivation method of the PowerAuth 3.0 Server interface. * diff --git a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/entity/ActivationHistoryItem.java b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/entity/ActivationHistoryItem.java index b8250433e..8502ebeb8 100644 --- a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/entity/ActivationHistoryItem.java +++ b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/entity/ActivationHistoryItem.java @@ -35,6 +35,7 @@ public class ActivationHistoryItem { private ActivationStatus activationStatus; private String eventReason; private String externalUserId; + private String activationName; private Date timestampCreated; private Long version; diff --git a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/UpdateActivationNameRequest.java b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/UpdateActivationNameRequest.java new file mode 100644 index 000000000..b42ebbc65 --- /dev/null +++ b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/UpdateActivationNameRequest.java @@ -0,0 +1,41 @@ +/* + * 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 com.wultra.security.powerauth.client.model.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * Model class representing request for updating activation. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Data +public class UpdateActivationNameRequest { + + @NotBlank + private String activationId; + + @NotBlank + private String activationName; + + @NotBlank + private String externalUserId; + +} diff --git a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/response/UpdateActivationNameResponse.java b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/response/UpdateActivationNameResponse.java new file mode 100644 index 000000000..8cc45b029 --- /dev/null +++ b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/response/UpdateActivationNameResponse.java @@ -0,0 +1,43 @@ +/* + * 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 com.wultra.security.powerauth.client.model.response; + +import com.wultra.security.powerauth.client.model.enumeration.ActivationStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * Model class representing response with updated activation. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Data +public class UpdateActivationNameResponse { + + @NotBlank + private String activationId; + + @NotBlank + private String activationName; + + @NotNull + private ActivationStatus activationStatus; + +} diff --git a/powerauth-java-server/pom.xml b/powerauth-java-server/pom.xml index 0a5317482..32938d8c8 100644 --- a/powerauth-java-server/pom.xml +++ b/powerauth-java-server/pom.xml @@ -185,6 +185,11 @@ h2 test + + + nl.jqno.equalsverifier + equalsverifier + diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/controller/RESTControllerAdvice.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/controller/RESTControllerAdvice.java index 066e68313..edc2038e3 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/controller/RESTControllerAdvice.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/controller/RESTControllerAdvice.java @@ -23,15 +23,21 @@ import io.getlime.security.powerauth.app.server.service.exceptions.ActivationRecoveryException; import io.getlime.security.powerauth.app.server.service.exceptions.GenericServiceException; import io.getlime.security.powerauth.app.server.service.exceptions.TelemetryReportException; +import io.getlime.security.powerauth.app.server.service.model.ServiceError; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; +import java.util.Comparator; +import java.util.stream.Collectors; + /** * Class used for handling RESTful service errors. * @@ -95,6 +101,30 @@ public class RESTControllerAdvice { return new ObjectResponse<>("ERROR", error); } + /** + * Resolver for validation xception. + * + * @param ex Exception. + * @return Activation recovery error. + */ + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler + public @ResponseBody ObjectResponse returnActivationRecoveryError(final MethodArgumentNotValidException ex) { + logger.error("Error occurred while processing the request: {}", ex.getMessage()); + logger.debug("Exception details:", ex); + + final String message = ex.getBindingResult().getFieldErrors().stream() + .sorted(Comparator.comparing(FieldError::getField)) + .map(it -> String.join(" - ", it.getField(), it.getDefaultMessage())) + .collect(Collectors.joining(", ")); + + final PowerAuthError error = new PowerAuthError(); + error.setCode(ServiceError.INVALID_REQUEST); + error.setMessage(message); + error.setLocalizedMessage(message); + return new ObjectResponse<>("ERROR", error); + } + /** * Resolver for HTTP request message errors. * @param ex Exception for HTTP message not readable. diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/controller/api/PowerAuthController.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/controller/api/PowerAuthController.java index 52b020d09..40ac6143b 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/controller/api/PowerAuthController.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/controller/api/PowerAuthController.java @@ -24,8 +24,12 @@ import io.getlime.core.rest.model.base.response.Response; import io.getlime.security.powerauth.app.server.service.PowerAuthService; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; /** * Class implementing the RESTful controller for PowerAuth service. @@ -171,6 +175,17 @@ public ObjectResponse getActivationListForUser return new ObjectResponse<>("OK", powerAuthService.getActivationListForUser(request.getRequestObject())); } + /** + * Update the activation name. + * + * @param request This is an {@link ObjectRequest} that contains a {@link UpdateActivationNameRequest}. + * @return This endpoint returns an {@link ObjectResponse} that contains a {@link UpdateActivationNameResponse}. + * @throws Exception In case the service throws an exception, it will be propagated and should be handled by the caller. + */ + @PostMapping("/activation/name/update") + public ObjectResponse updateActivation(@Valid @RequestBody ObjectRequest request) throws Exception { + return new ObjectResponse<>(powerAuthService.updateActivationName(request.getRequestObject())); + } /** * Call {@link PowerAuthService#lookupActivations(LookupActivationsRequest)} method and diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/AdditionalInformation.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/AdditionalInformation.java index f5f58cfd2..05e3bc21d 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/AdditionalInformation.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/AdditionalInformation.java @@ -80,6 +80,11 @@ public static class Reason { */ public static final String ACTIVATION_VERSION_CHANGED = "ACTIVATION_VERSION_CHANGED"; + /** + * Logged when the activation name has been updated. + */ + public static final String ACTIVATION_NAME_UPDATED = "ACTIVATION_NAME_UPDATED"; + } private AdditionalInformation() { diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/entity/ActivationHistoryEntity.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/entity/ActivationHistoryEntity.java index 65e72ec5b..8afc5a091 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/entity/ActivationHistoryEntity.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/entity/ActivationHistoryEntity.java @@ -20,6 +20,8 @@ import io.getlime.security.powerauth.app.server.database.model.converter.ActivationStatusConverter; import io.getlime.security.powerauth.app.server.database.model.enumeration.ActivationStatus; import jakarta.persistence.*; +import lombok.ToString; +import org.springframework.data.util.ProxyUtils; import java.io.Serial; import java.io.Serializable; @@ -33,6 +35,7 @@ */ @Entity @Table(name = "pa_activation_history") +@ToString public class ActivationHistoryEntity implements Serializable { @Serial @@ -67,34 +70,8 @@ public class ActivationHistoryEntity implements Serializable { @Column(name = "activation_version") private Integer activationVersion; - /** - * No-arg constructor. - */ - public ActivationHistoryEntity() { - } - - /** - * Constructor with all properties. - * - * @param id Signature audit item record ID. - * @param activation Associated activation, or null of no related activation was found. - * @param activationStatus Activation status at the time of signature computation attempt. - * @param timestampCreated Created timestamp. - * @param activationVersion Activation version. - */ - public ActivationHistoryEntity( - final Long id, - final ActivationRecordEntity activation, - final ActivationStatus activationStatus, - final Date timestampCreated, - final Integer activationVersion) { - - this.id = id; - this.activation = activation; - this.activationStatus = activationStatus; - this.timestampCreated = timestampCreated; - this.activationVersion = activationVersion; - } + @Column(name = "activation_name") + private String activationName; /** * Get record ID. @@ -216,54 +193,36 @@ public void setActivationVersion(Integer version) { this.activationVersion = version; } - @Override - public int hashCode() { - int hash = 7; - hash = 23 * hash + Objects.hashCode(this.activation); - hash = 23 * hash + Objects.hashCode(this.activationStatus); - hash = 23 * hash + Objects.hashCode(this.eventReason); - hash = 23 * hash + Objects.hashCode(this.externalUserId); - hash = 23 * hash + Objects.hashCode(this.timestampCreated); - hash = 23 * hash + Objects.hashCode(this.activationVersion); - return hash; + public String getActivationName() { + return activationName; + } + + public void setActivationName(final String activationName) { + this.activationName = activationName; } @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { + public boolean equals(final Object o) { + if (null == o) { return false; - } - if (getClass() != obj.getClass()) { - return false; - } - final ActivationHistoryEntity other = (ActivationHistoryEntity) obj; - if (!Objects.equals(this.activation, other.activation)) { - return false; - } - if (!Objects.equals(this.activationStatus, other.activationStatus)) { - return false; - } - if (!Objects.equals(this.eventReason, other.eventReason)) { - return false; - } - if (!Objects.equals(this.externalUserId, other.externalUserId)) { - return false; - } - if (!Objects.equals(this.timestampCreated, other.timestampCreated)) { + } else if (this == o) { + return true; + } else if (!this.getClass().equals(ProxyUtils.getUserClass(o))) { return false; + } else { + final ActivationHistoryEntity that = (ActivationHistoryEntity) o; + return Objects.equals(getActivationId(), that.getActivationId()) && Objects.equals(getTimestampCreated(), that.getTimestampCreated()); } - return Objects.equals(this.activationVersion, other.activationVersion); } @Override - public String toString() { - return "ActivationHistoryEntity{" + - "id=" + id + ", activation=" + activation + ", activationStatus=" + activationStatus + - ", eventReason=" + eventReason + ", externalUserId=" + externalUserId + ", timestampCreated=" + timestampCreated + - ", version=" + activationVersion +'}'; + public int hashCode() { + return Objects.hash(getActivationId(), timestampCreated); + } + + // TODO (racansky, 2023-11-08) remove when activation equals and hashCode implemented correctly + private String getActivationId() { + return getActivation() == null ? null : getActivation().getActivationId(); } } diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/PowerAuthService.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/PowerAuthService.java index 2cfc963ca..b8926d979 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/PowerAuthService.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/PowerAuthService.java @@ -1981,4 +1981,22 @@ public void removeOperationTemplate(OperationTemplateDeleteRequest request) thro throw new GenericServiceException(ServiceError.UNKNOWN_ERROR, ex.getMessage(), ex.getLocalizedMessage()); } } + + @Transactional + public UpdateActivationNameResponse updateActivationName(final UpdateActivationNameRequest request) throws GenericServiceException { + try { + final String activationId = request.getActivationId(); + logger.info("UpdateActivationRequest call received, activation ID: {}", activationId); + logger.debug("Updating activation: {}", request); + final UpdateActivationNameResponse response = behavior.getActivationServiceBehavior().updateActivationName(request); + logger.info("UpdateActivationRequest succeeded, activation ID: {}", activationId); + return response; + } 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(), ex.getLocalizedMessage()); + } + } } diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/ActivationHistoryServiceBehavior.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/ActivationHistoryServiceBehavior.java index a109af41e..3f09eaa63 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/ActivationHistoryServiceBehavior.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/ActivationHistoryServiceBehavior.java @@ -22,6 +22,7 @@ import com.wultra.security.powerauth.client.model.entity.ActivationHistoryItem; import com.wultra.security.powerauth.client.model.response.ActivationHistoryResponse; import io.getlime.security.powerauth.app.server.converter.ActivationStatusConverter; +import io.getlime.security.powerauth.app.server.database.model.AdditionalInformation; import io.getlime.security.powerauth.app.server.database.model.enumeration.ActivationStatus; import io.getlime.security.powerauth.app.server.database.model.entity.ActivationHistoryEntity; import io.getlime.security.powerauth.app.server.database.model.entity.ActivationRecordEntity; @@ -96,6 +97,8 @@ public void saveActivationAndLogChange(ActivationRecordEntity activation, String activationHistoryEntity.setExternalUserId(externalUserId); activationHistoryEntity.setTimestampCreated(changeTimestamp); activationHistoryEntity.setActivationVersion(activation.getVersion()); + activationHistoryEntity.setActivationName(activation.getActivationName()); + activation.getActivationHistory().add(activationHistoryEntity); // ActivationHistoryEntity is persisted together with activation using Cascade.ALL on ActivationEntity activationRepository.save(activation); @@ -129,6 +132,7 @@ public ActivationHistoryResponse getActivationHistory(String activationId, Date item.setVersion(Long.valueOf(activationVersion)); } item.setExternalUserId(activationHistoryEntity.getExternalUserId()); + item.setActivationName(activationHistoryEntity.getActivationName()); item.setTimestampCreated(activationHistoryEntity.getTimestampCreated()); response.getItems().add(item); @@ -169,15 +173,15 @@ private void logAuditItem(ActivationRecordEntity activation, String externalUser // Build audit log message final AuditDetail auditDetail = auditDetailBuilder.build(); - switch (activation.getActivationStatus()) { - case CREATED -> - audit.log(AuditLevel.INFO, "Created activation with ID: {}", auditDetail, activation.getActivationId()); - case PENDING_COMMIT, BLOCKED, ACTIVE -> - audit.log(AuditLevel.INFO, "Activation ID: {} is now {}", auditDetail, activation.getActivationId(), activation.getActivationStatus()); - default -> - audit.log(AuditLevel.INFO, "Removing activation with ID: {}", auditDetail, activation.getActivationId()); + if (AdditionalInformation.Reason.ACTIVATION_NAME_UPDATED.equals(historyEventReason)) { + audit.log(AuditLevel.INFO, "Updated activation with ID: {}", auditDetail, activation.getActivationId()); + } else { + switch (activation.getActivationStatus()) { + case CREATED -> audit.log(AuditLevel.INFO, "Created activation with ID: {}", auditDetail, activation.getActivationId()); + case PENDING_COMMIT, BLOCKED, ACTIVE -> audit.log(AuditLevel.INFO, "Activation ID: {} is now {}", auditDetail, activation.getActivationId(), activation.getActivationStatus()); + default -> audit.log(AuditLevel.INFO, "Removing activation with ID: {}", auditDetail, activation.getActivationId()); + } } - } } diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/ActivationServiceBehavior.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/ActivationServiceBehavior.java index 2c1c7aa4d..80d4a811c 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/ActivationServiceBehavior.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/ActivationServiceBehavior.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.wultra.security.powerauth.client.model.entity.Activation; import com.wultra.security.powerauth.client.model.request.RecoveryCodeActivationRequest; +import com.wultra.security.powerauth.client.model.request.UpdateActivationNameRequest; import com.wultra.security.powerauth.client.model.response.*; import io.getlime.security.powerauth.app.server.configuration.PowerAuthServiceConfiguration; import io.getlime.security.powerauth.app.server.converter.ActivationOtpValidationConverter; @@ -1325,6 +1326,45 @@ public CommitActivationResponse commitActivation(String activationId, String ext return response; } + /** + * Update name of the given activation. + * + * @param request Update request. + * @return Response with updated activation + * @throws GenericServiceException In case invalid data is provided or activation is not found, in invalid state or already expired. + */ + public UpdateActivationNameResponse updateActivationName(final UpdateActivationNameRequest request) throws GenericServiceException { + final String activationId = request.getActivationId(); + final ActivationRepository activationRepository = repositoryCatalogue.getActivationRepository(); + final ActivationRecordEntity activation = activationRepository.findActivationWithLock(activationId); + if (activation == null) { + logger.info("Activation ID: {} does not exist.", activationId); + // Rollback is not required, error occurs before writing to database + throw localizationProvider.buildExceptionForCode(ServiceError.ACTIVATION_NOT_FOUND); + } + + final List notAllowedStatuses = List.of(ActivationStatus.CREATED, ActivationStatus.REMOVED, ActivationStatus.BLOCKED); + final ActivationStatus activationStatus = activation.getActivationStatus(); + if (notAllowedStatuses.contains(activationStatus)) { + logger.info("Activation is in not allowed status {} to update, activation ID: {}", activationStatus, activationId); + // Rollback is not required, error occurs before writing to database + throw localizationProvider.buildExceptionForCode(ServiceError.ACTIVATION_INCORRECT_STATE); + } + + final Date timestamp = new Date(); + + activation.setActivationName(request.getActivationName()); + activation.setTimestampLastChange(timestamp); + + activationHistoryServiceBehavior.saveActivationAndLogChange(activation, request.getExternalUserId(), AdditionalInformation.Reason.ACTIVATION_NAME_UPDATED); + + final UpdateActivationNameResponse response = new UpdateActivationNameResponse(); + response.setActivationId(activationId); + response.setActivationName(activation.getActivationName()); + response.setActivationStatus(activationStatusConverter.convert(activationStatus)); + return response; + } + /** * Update activation OTP for given activation ID. * diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/model/ServiceError.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/model/ServiceError.java index d2fddb952..7c2d1965f 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/model/ServiceError.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/model/ServiceError.java @@ -28,7 +28,7 @@ * * @author Petr Dvorak, petr@wultra.com */ -public class ServiceError { +public final class ServiceError { /** * Unknown error occurred. 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 new file mode 100644 index 000000000..a35f4f85b --- /dev/null +++ b/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/controller/api/PowerAuthControllerTest.java @@ -0,0 +1,126 @@ +/* + * 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.controller.api; + +import com.wultra.core.audit.base.database.DatabaseAudit; +import io.getlime.security.powerauth.app.server.database.model.entity.ActivationHistoryEntity; +import io.getlime.security.powerauth.app.server.database.model.entity.ActivationRecordEntity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Tuple; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Test for {@link PowerAuthController}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@SpringBootTest +@AutoConfigureMockMvc +@Sql +@Transactional +class PowerAuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private EntityManager entityManager; + + @Autowired + private DatabaseAudit databaseAudit; + + @Test + void testUpdateActivation() throws Exception { + mockMvc.perform(post("/rest/v3/activation/name/update") + .content(""" + { + "requestObject": { + "activationId": "e43a5dec-afea-4a10-a80b-b2183399f16b", + "activationName": "my iPhone", + "externalUserId": "joe-1" + } + } + """) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.responseObject.activationName").value("my iPhone")); + + final ActivationRecordEntity activation = entityManager.find(ActivationRecordEntity.class, "e43a5dec-afea-4a10-a80b-b2183399f16b"); + assertEquals("my iPhone", activation.getActivationName()); + + final List historyEntries = entityManager.createQuery("select h from ActivationHistoryEntity h where h.activation = :activation", ActivationHistoryEntity.class) + .setParameter("activation", activation) + .getResultList(); + assertEquals(1, historyEntries.size()); + + final ActivationHistoryEntity historyEntry = historyEntries.iterator().next(); + assertEquals("my iPhone", historyEntry.getActivationName()); + assertEquals("ACTIVATION_NAME_UPDATED", historyEntry.getEventReason()); + assertEquals("joe-1", historyEntry.getExternalUserId()); + + databaseAudit.flush(); + final String expectedAuditMessage = "Updated activation with ID: e43a5dec-afea-4a10-a80b-b2183399f16b"; + @SuppressWarnings("unchecked") + final List auditEntries = entityManager.createNativeQuery("select * from audit_log where message = :message", Tuple.class) + .setParameter("message", expectedAuditMessage) + .getResultList(); + assertEquals(1, auditEntries.size()); + + final String param = auditEntries.get(0).get("param").toString(); + assertThat(param, containsString("\"activationId\":\"e43a5dec-afea-4a10-a80b-b2183399f16b\"")); + assertThat(param, containsString("\"activationName\":\"my iPhone\"")); + assertThat(param, containsString("\"reason\":\"ACTIVATION_NAME_UPDATED\"")); + } + + @Test + void testUpdateActivation_badRequest() throws Exception { + final String expectedErrorMessage = "requestObject.activationId - must not be blank, requestObject.activationName - must not be blank, requestObject.externalUserId - must not be blank"; + + mockMvc.perform(post("/rest/v3/activation/name/update") + .content(""" + { + "requestObject": {} + } + """) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("ERROR")) + .andExpect(jsonPath("$.responseObject.code").value("ERR0024")) + .andExpect(jsonPath("$.responseObject.message").value(expectedErrorMessage)) + .andExpect(jsonPath("$.responseObject.localizedMessage").value(expectedErrorMessage)); + } +} diff --git a/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/database/model/entity/ActivationHistoryEntityTest.java b/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/database/model/entity/ActivationHistoryEntityTest.java new file mode 100644 index 000000000..1c5c97f29 --- /dev/null +++ b/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/database/model/entity/ActivationHistoryEntityTest.java @@ -0,0 +1,62 @@ +/* + * 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.database.model.entity; + +import lombok.extern.slf4j.Slf4j; +import nl.jqno.equalsverifier.EqualsVerifier; +import nl.jqno.equalsverifier.Warning; +import org.junit.jupiter.api.Test; + +import java.util.Date; +import java.util.List; + +/** + * Test for {@link ActivationHistoryEntity}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Slf4j +class ActivationHistoryEntityTest { + + @Test + void testEqualsContract() { + final ApplicationEntity application1 = new ApplicationEntity(); + application1.setId("app1"); + final ApplicationEntity application2 = new ApplicationEntity(); + application2.setId("app2"); + + final ApplicationVersionEntity applicationVersion1 = new ApplicationVersionEntity(); + applicationVersion1.setId("v1"); + final ApplicationVersionEntity applicationVersion2 = new ApplicationVersionEntity(); + applicationVersion2.setId("v2"); + + final ActivationHistoryEntity activationHistory1 = new ActivationHistoryEntity(); + activationHistory1.setTimestampCreated(new Date(1)); + final ActivationHistoryEntity activationHistory2 = new ActivationHistoryEntity(); + activationHistory2.setTimestampCreated(new Date(2)); + + EqualsVerifier.forClass(ActivationHistoryEntity.class) + .withOnlyTheseFields("activation", "timestampCreated") + // TODO (racansky, 2023-11-09) equals and hashCode is using getActivation().getActivationId() but still getting false positive; https://jqno.nl/equalsverifier/manual/jpa-entities/ + .suppress(Warning.JPA_GETTER) + .withPrefabValues(ApplicationEntity.class, application1, application2) + .withPrefabValues(ApplicationVersionEntity.class, applicationVersion1, applicationVersion2) + .withPrefabValues(List.class, List.of(activationHistory1), List.of(activationHistory2)) + .verify(); + } +} diff --git a/powerauth-java-server/src/test/resources/io/getlime/security/powerauth/app/server/controller/api/PowerAuthControllerTest.sql b/powerauth-java-server/src/test/resources/io/getlime/security/powerauth/app/server/controller/api/PowerAuthControllerTest.sql new file mode 100644 index 000000000..bf3b2bf4b --- /dev/null +++ b/powerauth-java-server/src/test/resources/io/getlime/security/powerauth/app/server/controller/api/PowerAuthControllerTest.sql @@ -0,0 +1,8 @@ +INSERT INTO pa_application (id, name, roles) VALUES + (21, 'PA_Tests', '[ "ROLE3", "ROLE4" ]'); + +INSERT INTO pa_master_keypair (id, application_id, master_key_private_base64, master_key_public_base64, name, timestamp_created) +VALUES (21, 21, 'KdcJHQAT/BBF+26uBGNhGC0GQ93ncTx7V6kusNA8AdE=', 'BP8ZZ0LjiwRCQPob3NFwF9pPDLhxCjnPNmENzayEeeGCiDdk0gl3UzUhYk9ntMg18LZdhpvYnprZ8mk/71WlQqo=', 'PA_Tests Default Keypair', '2022-06-07 09:13:27.599000'); + +INSERT INTO pa_activation (activation_id, application_id, user_id, activation_name, activation_code, activation_status, activation_otp, activation_otp_validation, blocked_reason, counter, ctr_data, device_public_key_base64, extras, platform, device_info, flags, failed_attempts, max_failed_attempts, server_private_key_base64, server_private_key_encryption, server_public_key_base64, timestamp_activation_expire, timestamp_created, timestamp_last_used, timestamp_last_change, master_keypair_id, version) VALUES + ('e43a5dec-afea-4a10-a80b-b2183399f16b', 21, 'TestUserV3_d8c2e122-b12a-47f1-bca7-e04637bffd14', 'test v3', 'PXSNR-E2B46-7TY3G-TMR2Q', 3, null, 0, null, 0, 'D5XibWWPCv+nOOfcdfnUGQ==', 'BF3Sc/vqg8Zk70Y8rbT45xzAIxblGoWgLqknCHuNj7f6QFBNi2UnLbG7yMqf2eWShhyBJdu9zqx7DG2qzlqhbBE=', null, 'unknown', 'backend-tests', '[ ]', 0, 1, 'PUz/He8+RFoOPS1NG6Gw3TDXIQ/DnS1skNBOQWzXX60=', 0, 'BPHJ4N90NUuLDq92FJUPcaKZOMad1KH2HrwQEN9DB5ST5fiJU4baYF1VlK1JHglnnN1miL3/Qb6IyW3YSMBySYM=', '2023-04-03 14:04:06.015000', '2023-04-03 13:59:06.015000', '2023-04-03 13:59:16.293000', '2023-04-03 13:59:16.343000', 21, 3); diff --git a/powerauth-java-server/src/test/resources/schema.sql b/powerauth-java-server/src/test/resources/schema.sql new file mode 100644 index 000000000..3db62afeb --- /dev/null +++ b/powerauth-java-server/src/test/resources/schema.sql @@ -0,0 +1,36 @@ +-- Scheduler lock table - https://github.com/lukas-krecan/ShedLock#configure-lockprovider +CREATE TABLE IF NOT EXISTS shedlock +( + name VARCHAR(64) NOT NULL, + lock_until TIMESTAMP NOT NULL, + locked_at TIMESTAMP NOT NULL, + locked_by VARCHAR(255) NOT NULL, + PRIMARY KEY (name) +); + + +-- Create audit log table - https://github.com/wultra/lime-java-core#wultra-auditing-library +CREATE TABLE IF NOT EXISTS audit_log +( + audit_log_id VARCHAR(36) PRIMARY KEY, + application_name VARCHAR(256) NOT NULL, + audit_level VARCHAR(32) NOT NULL, + audit_type VARCHAR(256), + timestamp_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + message TEXT NOT NULL, + exception_message TEXT, + stack_trace TEXT, + param TEXT, + calling_class VARCHAR(256) NOT NULL, + thread_name VARCHAR(256) NOT NULL, + version VARCHAR(256), + build_time TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS audit_param +( + audit_log_id VARCHAR(36), + timestamp_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + param_key VARCHAR(256), + param_value VARCHAR(4000) +); 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 ed0451d38..06435d2fd 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 @@ -293,6 +293,16 @@ public CreateActivationResponse createActivation(String userId, Date timestampAc return createActivation(request, EMPTY_MULTI_MAP, EMPTY_MULTI_MAP); } + @Override + public UpdateActivationNameResponse updateActivationName(UpdateActivationNameRequest request) throws PowerAuthClientException { + return updateActivationName(request, EMPTY_MULTI_MAP, EMPTY_MULTI_MAP); + } + + @Override + public UpdateActivationNameResponse updateActivationName(UpdateActivationNameRequest request, MultiValueMap queryParams, MultiValueMap httpHeaders) throws PowerAuthClientException { + return callV3RestApi("/activation/name/update", request, queryParams, httpHeaders, UpdateActivationNameResponse.class); + } + @Override public UpdateActivationOtpResponse updateActivationOtp(String activationId, String externalUserId, String activationOtp) throws PowerAuthClientException { final UpdateActivationOtpRequest request = new UpdateActivationOtpRequest();