Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #852: Allow scoping operation to a single activation ID #1127

Merged
merged 11 commits into from
Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/PowerAuth-Server-1.6.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

6 changes: 4 additions & 2 deletions docs/WebServices-Methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>` | `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<String, String>` | `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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">

<changeSet id="1" logicalFilePath="powerauth-java-server/1.6.x/20231112-add-activation-id.xml" author="Jan Dusil">
<preConditions onFail="MARK_RAN">
<tableExists tableName="pa_operation"/>
<not>
<columnExists tableName="pa_operation" columnName="activation_id"/>
</not>
<tableExists tableName="pa_activation"/>
<columnExists tableName="pa_activation" columnName="activation_id"/>
</preConditions>
<comment>Add activation_id column to pa_operation with foreign key constraint </comment>
<addColumn tableName="pa_operation">
<column name="activation_id" type="varchar(37)">
<constraints nullable="true" foreignKeyName="pa_operation_activation_id_fk"
referencedTableName="pa_activation" referencedColumnNames="activation_id"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
<include file="20231018-add-constraint-operation-template-name.xml" relativeToChangelogFile="true" />
<include file="20231103-add-activation-name-history.xml" relativeToChangelogFile="true" />
<include file="20231106-add-foreign-keys.xml" relativeToChangelogFile="true" />
<include file="20231112-add-activation-id.xml" relativeToChangelogFile="true" />

</databaseChangeLog>
Binary file modified docs/images/arch_db_structure.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand All @@ -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<String> 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<String, String> 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;
jandusil marked this conversation as resolved.
Show resolved Hide resolved

}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ public OperationDetailResponse createOperation(OperationCreateRequest request) t
final Date timestampExpiresRequest = request.getTimestampExpires();
final Map<String, String> 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();
Expand Down Expand Up @@ -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())
Expand All @@ -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);

Expand Down Expand Up @@ -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);
Expand All @@ -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);

Expand Down Expand Up @@ -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());

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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) {
Expand Down
Loading