diff --git a/WultraMobileTokenSDK.xcodeproj/project.pbxproj b/WultraMobileTokenSDK.xcodeproj/project.pbxproj index ecb4b7c..7b5b951 100644 --- a/WultraMobileTokenSDK.xcodeproj/project.pbxproj +++ b/WultraMobileTokenSDK.xcodeproj/project.pbxproj @@ -71,6 +71,7 @@ EA6DDF0F29F8036B0011E234 /* WMTPreApprovalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6DDF0E29F8036B0011E234 /* WMTPreApprovalScreen.swift */; }; EA6DDF1A29F804D60011E234 /* WMTPostApprovalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6DDF1929F804D60011E234 /* WMTPostApprovalScreen.swift */; }; EA6DDF1C29F807230011E234 /* OperationUIDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6DDF1B29F807230011E234 /* OperationUIDataTests.swift */; }; + EA7A6E582B0E639800C1D4F4 /* WMTOperationDetailRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA7A6E572B0E639800C1D4F4 /* WMTOperationDetailRequest.swift */; }; EA9CE2BE2AEAA9FD00FE4E35 /* WMTProximityCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9CE2BD2AEAA9FD00FE4E35 /* WMTProximityCheck.swift */; }; EA9CE2C22AEBDB0D00FE4E35 /* WMTPACUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9CE2C12AEBDB0D00FE4E35 /* WMTPACUtils.swift */; }; EAB7054A2AF1161500756AC2 /* PACParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB705492AF1161500756AC2 /* PACParserTests.swift */; }; @@ -157,6 +158,7 @@ EA6DDF0E29F8036B0011E234 /* WMTPreApprovalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WMTPreApprovalScreen.swift; sourceTree = ""; }; EA6DDF1929F804D60011E234 /* WMTPostApprovalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WMTPostApprovalScreen.swift; sourceTree = ""; }; EA6DDF1B29F807230011E234 /* OperationUIDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationUIDataTests.swift; sourceTree = ""; }; + EA7A6E572B0E639800C1D4F4 /* WMTOperationDetailRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WMTOperationDetailRequest.swift; sourceTree = ""; }; EA9CE2BD2AEAA9FD00FE4E35 /* WMTProximityCheck.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WMTProximityCheck.swift; sourceTree = ""; }; EA9CE2C12AEBDB0D00FE4E35 /* WMTPACUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WMTPACUtils.swift; sourceTree = ""; }; EAB705492AF1161500756AC2 /* PACParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PACParserTests.swift; sourceTree = ""; }; @@ -433,6 +435,7 @@ children = ( DCC5CCD7244DBBBD004679AC /* WMTAuthorizationData.swift */, DCC5CCD9244DBBE2004679AC /* WMTRejectionData.swift */, + EA7A6E572B0E639800C1D4F4 /* WMTOperationDetailRequest.swift */, ); path = Requests; sourceTree = ""; @@ -651,6 +654,7 @@ EA44366E29F9298100DDEC1C /* WMTPostApprovaScreenGeneric.swift in Sources */, DC48803D292282FF00DB844B /* WMTInbox.swift in Sources */, DC488042292282FF00DB844B /* WMTInboxImpl.swift in Sources */, + EA7A6E582B0E639800C1D4F4 /* WMTOperationDetailRequest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/WultraMobileTokenSDK/Operations/Model/Requests/WMTOperationDetailRequest.swift b/WultraMobileTokenSDK/Operations/Model/Requests/WMTOperationDetailRequest.swift new file mode 100644 index 0000000..3cd5a0a --- /dev/null +++ b/WultraMobileTokenSDK/Operations/Model/Requests/WMTOperationDetailRequest.swift @@ -0,0 +1,32 @@ +// +// Copyright 2023 Wultra s.r.o. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions +// and limitations under the License. +// + +import Foundation + +/// Claim payload +class WMTOperationDetailRequest: Codable { + + /// Operation Id + let operationId: String + + init(operationId: String) { + self.operationId = operationId + } + + enum CodingKeys: String, CodingKey { + case operationId = "id" + } +} diff --git a/WultraMobileTokenSDK/Operations/Service/WMTOperationEndpoints.swift b/WultraMobileTokenSDK/Operations/Service/WMTOperationEndpoints.swift index 22f5f57..5e8ba54 100644 --- a/WultraMobileTokenSDK/Operations/Service/WMTOperationEndpoints.swift +++ b/WultraMobileTokenSDK/Operations/Service/WMTOperationEndpoints.swift @@ -38,4 +38,14 @@ enum WMTOperationEndpoints { typealias EndpointType = WPNEndpointSigned, WPNResponseBase> static let endpoint: EndpointType = WPNEndpointSigned(endpointURLPath: "/api/auth/token/app/operation/cancel", uriId: "/operation/cancel") } + + enum OperationDetail { + typealias EndpointType = WPNEndpointSignedWithToken, WPNResponse> + static let endpoint: EndpointType = WPNEndpointSignedWithToken(endpointURLPath: "/api/auth/token/app/operation/detail", tokenName: "possession_universal") + } + + enum OperationClaim { + typealias EndpointType = WPNEndpointSignedWithToken, WPNResponse> + static let endpoint: EndpointType = WPNEndpointSignedWithToken(endpointURLPath: "/api/auth/token/app/operation/detail/claim", tokenName: "possession_universal") + } } diff --git a/WultraMobileTokenSDK/Operations/Service/WMTOperationsImpl.swift b/WultraMobileTokenSDK/Operations/Service/WMTOperationsImpl.swift index 83cea61..5e9e186 100644 --- a/WultraMobileTokenSDK/Operations/Service/WMTOperationsImpl.swift +++ b/WultraMobileTokenSDK/Operations/Service/WMTOperationsImpl.swift @@ -237,6 +237,46 @@ class WMTOperationsImpl: WMTOperations, WMTService { } } + func getDetail(operationId: String, completion: @escaping (Result) -> Void) -> Operation? { + guard validateActivation(completion) else { + return nil + } + + let detailData = WMTOperationDetailRequest(operationId: operationId) + + return networking.post(data: .init(detailData), signedWith: .possession(), to: WMTOperationEndpoints.OperationDetail.endpoint) { response, error in + self.processResult(response: response, error: error) { result in + switch result { + case .success(let operation): + completion(.success(operation)) + case .failure(let err): + completion(.failure(self.adjustOperationError(err, auth: false))) + } + } + } + } + + func claim(operationId: String, completion: @escaping(Result) -> Void) -> Operation? { + + guard validateActivation(completion) else { + return nil + } + + let claimData = WMTOperationDetailRequest(operationId: operationId) + + return networking.post(data: .init(claimData), signedWith: .possession(), to: WMTOperationEndpoints.OperationClaim.endpoint) { response, error in + self.processResult(response: response, error: error) { result in + switch result { + case .success(let operation): + self.operationsRegister.add(operation) + completion(.success(operation)) + case .failure(let err): + completion(.failure(self.adjustOperationError(err, auth: false))) + } + } + } + } + func authorize(operation: WMTOperation, with authentication: PowerAuthAuthentication, completion: @escaping (Result) -> Void) -> Operation? { guard validateActivation(completion) else { @@ -472,6 +512,17 @@ private class OperationsRegister { onChangeCallback = callback } + /// Adds an operation from register + func add(_ operation: WMTUserOperation) { + + // Check if the ID of the operation is already in the list otherwise add it + if currentOperations.contains(where: { $0.id == operation.id }) == false { + currentOperations.append(operation) + currentOperationsSet.insert(operation.id) + onChangeCallback(currentOperations, [operation], []) + } + } + /// Adds a multiple operations to the register. /// Returns list of added and removed operations. @discardableResult @@ -503,7 +554,7 @@ private class OperationsRegister { currentOperations.append(contentsOf: addedOperations) currentOperationsSet.formUnion(addedOperationsSet) - // we need to call onChanged even if nothing changed, because the objects are replaced by different insntances + // we need to call onChanged even if nothing changed, because the objects are replaced by different instances onChangeCallback(currentOperations, addedOperations, removedOperations) // Returns list of operations return (addedOperations, removedOperations) diff --git a/WultraMobileTokenSDK/Operations/WMTOperations.swift b/WultraMobileTokenSDK/Operations/WMTOperations.swift index 139dc7b..7d19638 100644 --- a/WultraMobileTokenSDK/Operations/WMTOperations.swift +++ b/WultraMobileTokenSDK/Operations/WMTOperations.swift @@ -76,6 +76,24 @@ public protocol WMTOperations: AnyObject { @discardableResult func getHistory(authentication: PowerAuthAuthentication, completion: @escaping(Result<[WMTOperationHistoryEntry], WMTError>) -> Void) -> Operation? + /// Retrieves operation detail based on operation ID + /// - Parameters: + /// - operationId: Operation ID to get + /// - completion: Result completion. + /// This completion is always called on the main thread. + /// - Returns: Operation object for its state observation. + @discardableResult + func getDetail(operationId: String, completion: @escaping(Result) -> Void) -> Operation? + + /// Assigns the 'non-personalized' operation to the user + /// - Parameters: + /// - operationId: Operation ID which will be claimed to belong to the user + /// - completion: Result completion. + /// This completion is always called on the main thread. + /// - Returns: Operation object for its state observation. + @discardableResult + func claim(operationId: String, completion: @escaping(Result) -> Void) -> Operation? + /// Authorize operation with given PowerAuth authentication object. /// /// - Parameters: diff --git a/WultraMobileTokenSDKTests/IntegrationProxy.swift b/WultraMobileTokenSDKTests/IntegrationProxy.swift index 0928545..a244c88 100644 --- a/WultraMobileTokenSDKTests/IntegrationProxy.swift +++ b/WultraMobileTokenSDKTests/IntegrationProxy.swift @@ -89,6 +89,35 @@ class IntegrationProxy { } } + func createNonPersonalisedPACOperation(_ factors: Factors = .F_2FA, completion: @escaping (NonPersonalisedTOTPOperationObject?) -> Void) { + DispatchQueue.global().async { + let opBody: String + switch factors { + case .F_2FA: + opBody = """ + { + "template": "login_preApproval", + "proximityCheckEnabled": true, + "parameters": { + "party.id": "666", + "party.name": "Datová schránka", + "session.id": "123", + "session.ip-address": "192.168.0.1" + } + } + """ + } + + completion(self.makeRequest(url: URL(string: "\(self.config.cloudServerUrl)/v2/operations")!, body: opBody)) + } + } + + func getOperation(operation: NonPersonalisedTOTPOperationObject, completion: @escaping (NonPersonalisedTOTPOperationObject?) -> Void) { + DispatchQueue.global().async { + completion(self.makeRequest(url: URL(string: "\(self.config.cloudServerUrl)/v2/operations/\(operation.operationId)")!, body: "", httpMethod: "GET")) + } + } + func getQROperation(operation: OperationObject, completion: @escaping (QROperationData?) -> Void) { DispatchQueue.global().async { completion(self.makeRequest(url: URL(string: "\(self.config.cloudServerUrl)/v2/operations/\(operation.operationId)/offline/qr?registrationId=\(self.registrationId)")!, body: "", httpMethod: "GET")) @@ -245,6 +274,17 @@ struct OperationObject: Codable { let timestampExpires: Int } +struct NonPersonalisedTOTPOperationObject: Codable { + let operationId: String + let status: String + let operationType: String + let failureCount: Int + let maxFailureCount: Int + let timestampCreated: Int + let timestampExpires: Int + let proximityOtp: String? +} + private struct IntegrationConfig: Codable { let cloudServerUrl: String let cloudServerLogin: String diff --git a/WultraMobileTokenSDKTests/IntegrationTests.swift b/WultraMobileTokenSDKTests/IntegrationTests.swift index 3ae7e82..137fdaf 100644 --- a/WultraMobileTokenSDKTests/IntegrationTests.swift +++ b/WultraMobileTokenSDKTests/IntegrationTests.swift @@ -91,6 +91,86 @@ class IntegrationTests: XCTestCase { waitForExpectations(timeout: 20, handler: nil) } + /// Operation IDs should be equal + func testDetail() { + let exp = expectation(description: "Operation detail") + + proxy.createNonPersonalisedPACOperation { op in + if let op { + DispatchQueue.main.async { + _ = self.ops.getDetail(operationId: op.operationId) { result in + switch result { + case .success(let operation): + XCTAssertEqual(op.operationId, operation.id) + case .failure(let err): + XCTFail(err.description) + } + exp.fulfill() + } + } + } else { + XCTFail("Failed to get operation detail") + exp.fulfill() + } + } + + waitForExpectations(timeout: 20, handler: nil) + } + + /// Operation IDs should be equal + func testClaim() { + let exp = expectation(description: "Operation Claim should return UserOperation with operation.id") + + proxy.createNonPersonalisedPACOperation { op in + if let op { + DispatchQueue.main.async { + _ = self.ops.claim(operationId: op.operationId) { result in + switch result { + case .success(let operation): + if operation.ui?.preApprovalScreen?.type == .qr { + self.proxy.getOperation(operation: op) { totpOP in + XCTAssertNotNil(totpOP?.proximityOtp, "Even with proximityCheckEnabled: true, in proximityOtp nil") + if let totpOP = totpOP, let proximityOtp = totpOP.proximityOtp { + operation.proximityCheck = WMTProximityCheck(totp: proximityOtp, type: .qrCode) + // wrong password on purpose + let auth = PowerAuthAuthentication.possessionWithPassword(password: "xxxx") + self.ops.authorize(operation: operation, with: auth) { result in + switch result { + case .failure: + let auth = PowerAuthAuthentication.possessionWithPassword(password: self.pin) + self.ops.authorize(operation: operation, with: auth) { result in + if case .failure(let error) = result { + XCTFail("Failed to authorize op: \(error.description)") + } + exp.fulfill() + } + case .success: + XCTFail("Operation approved with wrong password") + exp.fulfill() + } + } + } else { + XCTFail("Operation or TOTP is NIL") + exp.fulfill() + } + } + } + + case .failure(let err): + XCTFail(err.description) + exp.fulfill() + } + } + } + } else { + XCTFail("Failed to get operation detail") + exp.fulfill() + } + } + + waitForExpectations(timeout: 20, handler: nil) + } + /// `currentServerDate` is nil by default and after ops fetch, it should be set func testCurrentServerDate() { let exp = expectation(description: "Server date should be set after operation fetch") diff --git a/docs/Using-Operations-Service.md b/docs/Using-Operations-Service.md index ff2c551..9740dc2 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) - [WMTUserOperation](#wmtuseroperation) @@ -210,6 +212,56 @@ func reject(operation: WMTOperation, reason: WMTRejectionReason) { } ``` +## Operation detail + +To get a detail of an operation based on operation ID use `WMTOperations.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. + +```swift +import WultraMobileTokenSDK +import PowerAuth2 + +// Retrieve operation details based on the operation ID. +func getDetail(operationId: String) { + operationService.getDetail(operationId: operationId) { result in + switch result { + case .success(let operation): + // process operation + break + case .failure(let error): + // process error + break + } + } +} +``` + +## Claim the Operation + +To claim a non-persolized operation use `WMTOperations.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. The returned result is the operation and its current status and also the claimed operation **is inserted into the operation list**. You can simply use it with the following example. + +```swift +import WultraMobileTokenSDK +import PowerAuth2 + +// Assigns the 'non-personalized' operation to the user +func claim(operationId: String) { + operationService.claim(operationId: operationId) { result in + switch result { + case .success(let operation): + // process operation + break + case .failure(let error): + // process error + break + } + } +} +``` + ## Operation History You can retrieve an operation history via the `WMTOperations.getHistory` method. The returned result is operations and their current status.