From d28abb5285158afa7487f183ac25ef32a9590861 Mon Sep 17 00:00:00 2001 From: Marek Stransky <77441794+Hopsaheysa@users.noreply.github.com> Date: Thu, 1 Aug 2024 13:46:36 +0200 Subject: [PATCH] 1.7.x PAC with claim (#155) --- .github/workflows/tests.yml | 18 +++++- docs/Using-Operations-Service.md | 41 ++++++++++++++ .../src/androidTest/java/IntegrationTests.kt | 55 +++++++++++++++++++ .../src/androidTest/java/IntegrationUtils.kt | 37 +++++++++++++ .../mtokensdk/api/operation/OperationApi.kt | 14 +++++ .../model/OperationClaimDetailData.kt | 25 +++++++++ .../mtokensdk/operation/IOperationsService.kt | 16 ++++++ .../mtokensdk/operation/OperationsService.kt | 33 +++++++++++ 8 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 library/src/main/java/com/wultra/android/mtokensdk/api/operation/model/OperationClaimDetailData.kt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 552b63a..e98fd55 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,18 +10,27 @@ on: jobs: tests: name: Tests - runs-on: macos-latest + runs-on: ubuntu-latest steps: - name: Checkout the repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Setup Java 17 uses: actions/setup-java@v3 with: distribution: 'temurin' java-version: '17' cache: 'gradle' + - name: Test the app (unit test) run: ./gradlew clean test + - name: Prepare configuration for integration tests env: CL_URL: ${{ secrets.TESTS_CL_URL }} @@ -35,8 +44,11 @@ jobs: OP_URL: ${{ secrets.TESTS_OP_URL }} IN_URL: ${{ secrets.TESTS_IN_URL }} run: echo -e tests.sdk.cloudServerUrl="$CL_URL"\\ntests.sdk.cloudServerLogin="$CL_LGN"\\ntests.sdk.cloudServerPassword="$CL_PWD"\\ntests.sdk.cloudApplicationId="$CL_AID"\\ntests.sdk.enrollmentServerUrl="$ER_URL"\\ntests.sdk.operationsServerUrl="$OP_URL"\\ntests.sdk.inboxServerUrl="$IN_URL"\\ntests.sdk.appKey="$APP_KEY"\\ntests.sdk.appSecret="$APP_SECRET"\\ntests.sdk.masterServerPublicKey="$MASTER_SERVER_PUBLIC_KEY" > configs/integration-tests.properties + - name: Test the app (integration tests) uses: reactivecircus/android-emulator-runner@v2 with: api-level: 29 - script: ./gradlew clean connectedAndroidTest --info + arch: x86_64 + target: default + script: ./gradlew connectedAndroidTest --info diff --git a/docs/Using-Operations-Service.md b/docs/Using-Operations-Service.md index db38ce2..be385a5 100644 --- a/docs/Using-Operations-Service.md +++ b/docs/Using-Operations-Service.md @@ -7,6 +7,8 @@ - [Start Periodic Polling](#start-periodic-polling) - [Approve an Operation](#approve-an-operation) - [Reject an Operation](#reject-an-operation) +- [Operation detail](#operation-detail) +- [Claim the Operation](#claim-the-operation) - [Off-line Authorization](#off-line-authorization) - [Operations API Reference](#operations-api-reference) - [UserOperation](#useroperation) @@ -195,6 +197,45 @@ fun reject(operation: IOperation, reason: RejectionReason) { } ``` +## Operation detail + +To get a detail of the operation based on operation ID use `IOperationsService.getDetail`. Operation detail is confirmed by the possession factor so there is no need for creating `PowerAuthAuthentication` object. The returned result is the operation and its current status. + +```kotlin +// Retrieve operation details based on the operation ID. +fun getDetail(operationId: String) { + this.operationService.getDetail(operationId: operationId) { + it.onSuccess { + // process operation + }.onFailure { + // show error UI + } + } +} +``` + +## Claim the Operation + +To claim a non-persolized operation use `IOperationsService.claim`. + +A non-personalized operation refers to an operation that is initiated without a specific operationId. In this state, the operation is not tied to a particular user and lacks a unique identifier. + +Operation claim is confirmed by the possession factor so there is no need for creating `PowerAuthAuthentication` object. Returned result is the operation and its current status. You can simply use it with the following example. + +```kotlin +// Assigns the 'non-personalized' operation to the user +fun claim(operationId: String) { + this.operationService.claim(operationId: operationId) { + it.onSuccess { + // process operation + }.onFailure { + // show error UI + } + } +} +``` + + ## Operation History You can retrieve an operation history via the `IOperationsService.getHistory` method. The returned result is operations and their current status. diff --git a/library/src/androidTest/java/IntegrationTests.kt b/library/src/androidTest/java/IntegrationTests.kt index 0c9eec2..1bb38db 100644 --- a/library/src/androidTest/java/IntegrationTests.kt +++ b/library/src/androidTest/java/IntegrationTests.kt @@ -18,6 +18,9 @@ package com.wultra.android.mtokensdk.test import com.wultra.android.mtokensdk.api.operation.model.OperationHistoryEntry import com.wultra.android.mtokensdk.api.operation.model.OperationHistoryEntryStatus +import com.wultra.android.mtokensdk.api.operation.model.PreApprovalScreen +import com.wultra.android.mtokensdk.api.operation.model.ProximityCheck +import com.wultra.android.mtokensdk.api.operation.model.ProximityCheckType import com.wultra.android.mtokensdk.api.operation.model.QROperationParser import com.wultra.android.mtokensdk.api.operation.model.UserOperation import com.wultra.android.mtokensdk.operation.* @@ -274,4 +277,56 @@ class IntegrationTests { Assert.assertTrue(verifiedResult.otpValid) } + + @Test + fun testDetail() { + val op = IntegrationUtils.createNonPersonalizedPACOperation(IntegrationUtils.Companion.Factors.F_2FA) + val future = CompletableFuture() + + ops.getDetail(op.operationId) { result -> + result.onSuccess { future.complete(it) } + .onFailure { future.completeExceptionally(it) } + } + + val operation = future.get(20, TimeUnit.SECONDS) + Assert.assertTrue("Failed to create & get the operation", operation != null) + Assert.assertEquals("Operations ids are not equal", op.operationId, operation.id) + } + + @Test + fun testClaim() { + val op = IntegrationUtils.createNonPersonalizedPACOperation(IntegrationUtils.Companion.Factors.F_2FA) + val future = CompletableFuture() + + ops.claim(op.operationId) { result -> + result.onSuccess { future.complete(it) } + .onFailure { future.completeExceptionally(it) } + } + + val operation = future.get(20, TimeUnit.SECONDS) + + Assert.assertEquals("Incorrect type of preapproval screen", operation.ui?.preApprovalScreen?.type, PreApprovalScreen.Type.QR_SCAN) + + val totp = IntegrationUtils.getOperation(op).proximityOtp + Assert.assertNotNull("Even with proximityCheckEnabled: true, in proximityOtp nil", totp) + + operation.proximityCheck = ProximityCheck(totp!!, ProximityCheckType.QR_CODE) + + val authorizedFuture = CompletableFuture() + var auth = PowerAuthAuthentication.possessionWithPassword("xxxx") // wrong password on purpose + + ops.authorizeOperation(operation, auth) { result -> + result.onSuccess { authorizedFuture.completeExceptionally(Exception("Operation should not be authorized")) } + .onFailure { authorizedFuture.complete(null) } + } + Assert.assertNull(authorizedFuture.get(20, TimeUnit.SECONDS)) + + auth = PowerAuthAuthentication.possessionWithPassword(pin) + val authorizedFuture2 = CompletableFuture() + ops.authorizeOperation(operation, auth) { result -> + result.onSuccess { authorizedFuture2.complete(null) } + .onFailure { authorizedFuture2.completeExceptionally(it) } + } + Assert.assertNull(authorizedFuture2.get(20, TimeUnit.SECONDS)) + } } diff --git a/library/src/androidTest/java/IntegrationUtils.kt b/library/src/androidTest/java/IntegrationUtils.kt index ba4112e..a14a38f 100644 --- a/library/src/androidTest/java/IntegrationUtils.kt +++ b/library/src/androidTest/java/IntegrationUtils.kt @@ -192,6 +192,32 @@ class IntegrationUtils { return makeCall(opBody, "$cloudServerUrl/v2/operations") } + @Throws + fun createNonPersonalizedPACOperation(factors: Factors): NonPersonalisedTOTPOperationObject { + val opBody = when (factors) { + Factors.F_2FA -> { """ + { + "template": "login_preApproval", + "proximityCheckEnabled": true, + "parameters": { + "party.id": "666", + "party.name": "Datová schránka", + "session.id": "123", + "session.ip-address": "192.168.0.1" + } + } + """.trimIndent() + } + } + // create an operation on the nextstep server + return makeCall(opBody, "$cloudServerUrl/v2/operations") + } + + @Throws + fun getOperation(operation: NonPersonalisedTOTPOperationObject): NonPersonalisedTOTPOperationObject { + return makeCall(null, "$cloudServerUrl/v2/operations/${operation.operationId}", "GET") + } + @Throws fun getQROperation(operation: OperationObject): QRData { return makeCall(null, "$cloudServerUrl/v2/operations/${operation.operationId}/offline/qr?registrationId=$registrationId", "GET") @@ -275,6 +301,17 @@ data class OperationObject( val timestampExpires: Double ) +data class NonPersonalisedTOTPOperationObject( + val operationId: String, + val status: String, + val operationType: String, + val failureCount: Int, + val maxFailureCount: Int, + val timestampCreated: Double, + val timestampExpires: Double, + val proximityOtp: String? +) + data class QRData( val operationQrCodeData: String, val nonce: String diff --git a/library/src/main/java/com/wultra/android/mtokensdk/api/operation/OperationApi.kt b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/OperationApi.kt index 34f46c3..cddbb1b 100644 --- a/library/src/main/java/com/wultra/android/mtokensdk/api/operation/OperationApi.kt +++ b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/OperationApi.kt @@ -38,6 +38,8 @@ internal class OperationListResponse( internal class OperationHistoryResponse(responseObject: List, status: Status): ObjectResponse>(responseObject, status) internal class AuthorizeRequest(requestObject: AuthorizeRequestObject): ObjectRequest(requestObject) internal class RejectRequest(requestObject: RejectRequestObject): ObjectRequest(requestObject) +internal class OperationClaimDetailRequest(requestObject: OperationClaimDetailData): ObjectRequest(requestObject) +internal class OperationClaimDetailResponse(responseObject: UserOperation, status: Status): ObjectResponse(responseObject, status) /** * API for operations requests. @@ -59,6 +61,8 @@ internal class OperationApi( private val listEndpoint = EndpointSignedWithToken("api/auth/token/app/operation/list", "possession_universal") private val authorizeEndpoint = EndpointSigned("api/auth/token/app/operation/authorize", "/operation/authorize") private val rejectEndpoint = EndpointSigned("api/auth/token/app/operation/cancel", "/operation/cancel") + private val detailEndpoint = EndpointSignedWithToken("api/auth/token/app/operation/detail", "possession_universal") + private val claimEndpoint = EndpointSignedWithToken("api/auth/token/app/operation/detail/claim", "possession_universal") const val OFFLINE_AUTHORIZE_URI_ID = "/operation/authorize/offline" } @@ -84,4 +88,14 @@ internal class OperationApi( fun authorize(authorizeRequest: AuthorizeRequest, authentication: PowerAuthAuthentication, listener: IApiCallResponseListener) { post(authorizeRequest, authorizeEndpoint, authentication, null, null, okHttpInterceptor, listener) } + + /** Get an operation detail. */ + fun getDetail(claimRequest: OperationClaimDetailRequest, listener: IApiCallResponseListener) { + post(data = claimRequest, endpoint = detailEndpoint, headers = null, encryptor = null, okHttpInterceptor = okHttpInterceptor, listener = listener) + } + + /** Claim an operation. */ + fun claim(claimRequest: OperationClaimDetailRequest, listener: IApiCallResponseListener) { + post(data = claimRequest, endpoint = claimEndpoint, headers = null, encryptor = null, okHttpInterceptor = okHttpInterceptor, listener = listener) + } } diff --git a/library/src/main/java/com/wultra/android/mtokensdk/api/operation/model/OperationClaimDetailData.kt b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/model/OperationClaimDetailData.kt new file mode 100644 index 0000000..8616793 --- /dev/null +++ b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/model/OperationClaimDetailData.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023, Wultra s.r.o. (www.wultra.com). + * + * All rights reserved. This source code can be used only for purposes specified + * by the given license contract signed by the rightful deputy of Wultra s.r.o. + * This source code can be used only by the owner of the license. + * + * Any disputes arising in respect of this agreement (license) shall be brought + * before the Municipal Court of Prague. + */ + +package com.wultra.android.mtokensdk.api.operation.model + +import com.google.gson.annotations.SerializedName + +/** + * Model class for handling requests related to claiming and retrieving details of operations. + * + * @property operationId The unique identifier of the operation to be claimed or for which details are requested. + */ +internal data class OperationClaimDetailData( + + @SerializedName("id") + val operationId: String +) diff --git a/library/src/main/java/com/wultra/android/mtokensdk/operation/IOperationsService.kt b/library/src/main/java/com/wultra/android/mtokensdk/operation/IOperationsService.kt index 815b393..246d71e 100644 --- a/library/src/main/java/com/wultra/android/mtokensdk/operation/IOperationsService.kt +++ b/library/src/main/java/com/wultra/android/mtokensdk/operation/IOperationsService.kt @@ -88,6 +88,22 @@ interface IOperationsService { */ fun getHistory(authentication: PowerAuthAuthentication, callback: (result: Result>) -> Unit) + /** + * Retrieves operation detail based on operation ID + * + * @param operationId The identifier of the specific operation. + * @param callback Callback with result. + */ + fun getDetail(operationId: String, callback: (Result) -> Unit) + + /** + * Claims the "non-personalized" operation and assigns it to the user. + * + * @param operationId Operation ID that will be claimed as belonging to the user. + * @param callback Callback with result. + */ + fun claim(operationId: String, callback: (Result) -> Unit) + /** * Returns if operation polling is running */ diff --git a/library/src/main/java/com/wultra/android/mtokensdk/operation/OperationsService.kt b/library/src/main/java/com/wultra/android/mtokensdk/operation/OperationsService.kt index c300df7..95451f5 100644 --- a/library/src/main/java/com/wultra/android/mtokensdk/operation/OperationsService.kt +++ b/library/src/main/java/com/wultra/android/mtokensdk/operation/OperationsService.kt @@ -286,6 +286,39 @@ class OperationsService: IOperationsService { ?: throw Exception("Cannot sign this operation") } + override fun getDetail(operationId: String, callback: (Result) -> Unit) { + val detailRequest = OperationClaimDetailRequest(OperationClaimDetailData(operationId)) + + operationApi.getDetail( + detailRequest, + object : IApiCallResponseListener { + override fun onFailure(error: ApiError) { + callback(Result.failure(ApiErrorException(error))) + } + + override fun onSuccess(result: OperationClaimDetailResponse) { + callback(Result.success(result.responseObject)) + } + } + ) + } + + override fun claim(operationId: String, callback: (Result) -> Unit) { + val claimRequest = OperationClaimDetailRequest(OperationClaimDetailData(operationId)) + operationApi.claim( + claimRequest, + object : IApiCallResponseListener { + override fun onFailure(error: ApiError) { + callback(Result.failure(ApiErrorException(error))) + } + + override fun onSuccess(result: OperationClaimDetailResponse) { + callback(Result.success(result.responseObject)) + } + } + ) + } + override fun isPollingOperations() = timer != null @Synchronized