Skip to content

Commit

Permalink
PAC with claim (#132)
Browse files Browse the repository at this point in the history
* Implement new Operation Endpoint Claim and extend WMTOperations with claim function. Rename TOTPParser for PACParser

* #125: update parser so it is able to parse deeplink and also JWT in QR code

* Implement Detail endpoint and getDetail method to OperationsImpl

* Add docs for PACUtils

* Remove WMTPACUtils Keys from docs and add description of topt format in JWT/query

* Remove unneccesary spaces from doc

* Format docs

* Improved PAC Utils

# Conflicts:
#	WultraMobileTokenSDKTests/TOTPParserTests.swift

* Remove `Package.resolved`

* Fix endpoints to be signed with token

* Update docs

* Implement detail and claim integration tests

* Rename WMTClaim file

* Improve claim integration test

* Improve docs

* Fix claim docs

---------

Co-authored-by: Jan Kobersky <[email protected]>
  • Loading branch information
Hopsaheysa and kober32 committed Jan 9, 2024
1 parent ea46b73 commit 6a5942a
Show file tree
Hide file tree
Showing 8 changed files with 288 additions and 2 deletions.
5 changes: 4 additions & 1 deletion WultraMobileTokenSDK.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -157,6 +158,7 @@
EA6DDF0E29F8036B0011E234 /* WMTPreApprovalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WMTPreApprovalScreen.swift; sourceTree = "<group>"; };
EA6DDF1929F804D60011E234 /* WMTPostApprovalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WMTPostApprovalScreen.swift; sourceTree = "<group>"; };
EA6DDF1B29F807230011E234 /* OperationUIDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationUIDataTests.swift; sourceTree = "<group>"; };
EA7A6E572B0E639800C1D4F4 /* WMTOperationDetailRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WMTOperationDetailRequest.swift; sourceTree = "<group>"; };
EA9CE2BD2AEAA9FD00FE4E35 /* WMTProximityCheck.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WMTProximityCheck.swift; sourceTree = "<group>"; };
EA9CE2C12AEBDB0D00FE4E35 /* WMTPACUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WMTPACUtils.swift; sourceTree = "<group>"; };
EAB705492AF1161500756AC2 /* PACParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PACParserTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -433,6 +435,7 @@
children = (
DCC5CCD7244DBBBD004679AC /* WMTAuthorizationData.swift */,
DCC5CCD9244DBBE2004679AC /* WMTRejectionData.swift */,
EA7A6E572B0E639800C1D4F4 /* WMTOperationDetailRequest.swift */,
);
path = Requests;
sourceTree = "<group>";
Expand Down Expand Up @@ -506,7 +509,6 @@
DCC5CC912449EE21004679AC /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1140;
LastUpgradeCheck = 1500;
ORGANIZATIONNAME = Wultra;
Expand Down Expand Up @@ -652,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;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,14 @@ enum WMTOperationEndpoints {
typealias EndpointType = WPNEndpointSigned<WPNRequest<WMTRejectionData>, WPNResponseBase>
static let endpoint: EndpointType = WPNEndpointSigned(endpointURLPath: "/api/auth/token/app/operation/cancel", uriId: "/operation/cancel")
}

enum OperationDetail {
typealias EndpointType = WPNEndpointSignedWithToken<WPNRequest<WMTOperationDetailRequest>, WPNResponse<WMTUserOperation>>
static let endpoint: EndpointType = WPNEndpointSignedWithToken(endpointURLPath: "/api/auth/token/app/operation/detail", tokenName: "possession_universal")
}

enum OperationClaim {
typealias EndpointType = WPNEndpointSignedWithToken<WPNRequest<WMTOperationDetailRequest>, WPNResponse<WMTUserOperation>>
static let endpoint: EndpointType = WPNEndpointSignedWithToken(endpointURLPath: "/api/auth/token/app/operation/detail/claim", tokenName: "possession_universal")
}
}
53 changes: 52 additions & 1 deletion WultraMobileTokenSDK/Operations/Service/WMTOperationsImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,46 @@ class WMTOperationsImpl<T: WMTUserOperation>: WMTOperations, WMTService {
}
}

func getDetail(operationId: String, completion: @escaping (Result<WMTUserOperation, WMTError>) -> 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<WMTUserOperation, WMTError>) -> 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, WMTError>) -> Void) -> Operation? {

guard validateActivation(completion) else {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions WultraMobileTokenSDK/Operations/WMTOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<WMTUserOperation, WMTError>) -> 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<WMTUserOperation, WMTError>) -> Void) -> Operation?

/// Authorize operation with given PowerAuth authentication object.
///
/// - Parameters:
Expand Down
40 changes: 40 additions & 0 deletions WultraMobileTokenSDKTests/IntegrationProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -244,6 +273,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
Expand Down
80 changes: 80 additions & 0 deletions WultraMobileTokenSDKTests/IntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
52 changes: 52 additions & 0 deletions docs/Using-Operations-Service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down

0 comments on commit 6a5942a

Please sign in to comment.