diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index a73041e96e8..b989c1c28e3 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -729,7 +729,9 @@ E1BAB2C32913FA6400C3982B /* ResponseDataValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BAB2C22913FA6400C3982B /* ResponseDataValidator.swift */; }; E1BAB2C52913FB1800C3982B /* WordPressApiValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BAB2C42913FB1800C3982B /* WordPressApiValidator.swift */; }; E1BAB2C72913FB5800C3982B /* WordPressApiError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BAB2C62913FB5800C3982B /* WordPressApiError.swift */; }; - EE54C8942947229800A9BF61 /* ApplicationPasswordUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE54C8932947229800A9BF61 /* ApplicationPasswordUseCase.swift */; }; + EE54C89F2947782E00A9BF61 /* ApplicationPasswordUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */; }; + EE54C8A5294859D200A9BF61 /* ApplicationPasswordNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE54C8A4294859D200A9BF61 /* ApplicationPasswordNetwork.swift */; }; + EE54C8A729486B6800A9BF61 /* ApplicationPasswordMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE54C8A629486B6800A9BF61 /* ApplicationPasswordMapper.swift */; }; EE8A86F1286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json in Resources */ = {isa = PBXBuildFile; fileRef = EE8A86F0286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json */; }; EECB7EE8286555180028C888 /* media-update-product-id.json in Resources */ = {isa = PBXBuildFile; fileRef = EECB7EE7286555180028C888 /* media-update-product-id.json */; }; FE28F6E226840DED004465C7 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE28F6E126840DED004465C7 /* User.swift */; }; @@ -1491,7 +1493,9 @@ E1BAB2C22913FA6400C3982B /* ResponseDataValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseDataValidator.swift; sourceTree = ""; }; E1BAB2C42913FB1800C3982B /* WordPressApiValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressApiValidator.swift; sourceTree = ""; }; E1BAB2C62913FB5800C3982B /* WordPressApiError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressApiError.swift; sourceTree = ""; }; - EE54C8932947229800A9BF61 /* ApplicationPasswordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordUseCase.swift; sourceTree = ""; }; + EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordUseCase.swift; sourceTree = ""; }; + EE54C8A4294859D200A9BF61 /* ApplicationPasswordNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordNetwork.swift; sourceTree = ""; }; + EE54C8A629486B6800A9BF61 /* ApplicationPasswordMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordMapper.swift; sourceTree = ""; }; EE8A86F0286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-update-product-id-in-wordpress-site.json"; sourceTree = ""; }; EECB7EE7286555180028C888 /* media-update-product-id.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-update-product-id.json"; sourceTree = ""; }; F3F25DC15EC1D7C631169CB5 /* Pods_Networking.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Networking.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1704,6 +1708,7 @@ B518662320A099BF00037A38 /* AlamofireNetwork.swift */, B518662620A09BCC00037A38 /* MockNetwork.swift */, D87F6150226591E10031A13B /* NullNetwork.swift */, + EE54C8A4294859D200A9BF61 /* ApplicationPasswordNetwork.swift */, ); path = Network; sourceTree = ""; @@ -1816,7 +1821,7 @@ B557D9E5209753AA005962F4 /* Networking */ = { isa = PBXGroup; children = ( - EE54C8922947227900A9BF61 /* ApplicationPassword */, + EE54C899294777D000A9BF61 /* ApplicationPassword */, B5A0369F214C0F4C00774E2C /* Internal */, B5BB1D0A20A204F400112D92 /* Extensions */, B567AF2720A0FA0A00AB6C62 /* Mapper */, @@ -2376,6 +2381,7 @@ DE34051828BDEE6A00CF0D97 /* JetpackConnectionURLMapper.swift */, 68CB800D28D8901B00E169F8 /* CustomerMapper.swift */, 68F48B0C28E3B2E80045C15B /* WCAnalyticsCustomerMapper.swift */, + EE54C8A629486B6800A9BF61 /* ApplicationPasswordMapper.swift */, ); path = Mapper; sourceTree = ""; @@ -2578,10 +2584,10 @@ path = SystemStatusDetails; sourceTree = ""; }; - EE54C8922947227900A9BF61 /* ApplicationPassword */ = { + EE54C899294777D000A9BF61 /* ApplicationPassword */ = { isa = PBXGroup; children = ( - EE54C8932947229800A9BF61 /* ApplicationPasswordUseCase.swift */, + EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */, ); path = ApplicationPassword; sourceTree = ""; @@ -3022,6 +3028,7 @@ FE28F6E6268429B6004465C7 /* UserRemote.swift in Sources */, 7452387421124B7700A973CD /* AnyDecodable.swift in Sources */, CEF88DAD233E95B000BED485 /* OrderRefundCondensed.swift in Sources */, + EE54C8A729486B6800A9BF61 /* ApplicationPasswordMapper.swift in Sources */, FE28F6E426842848004465C7 /* UserMapper.swift in Sources */, CE430670234B99F50073CBFF /* RefundsRemote.swift in Sources */, B56C1EB620EA757B00D749F9 /* SiteListMapper.swift in Sources */, @@ -3072,7 +3079,7 @@ 743E84EE2217244C00FAC9D7 /* ShipmentTrackingListMapper.swift in Sources */, 451A97E5260B631E0059D135 /* ShippingLabelPredefinedPackage.swift in Sources */, BAB373722795A1FB00837B4A /* OrderTaxLine.swift in Sources */, - EE54C8942947229800A9BF61 /* ApplicationPasswordUseCase.swift in Sources */, + EE54C89F2947782E00A9BF61 /* ApplicationPasswordUseCase.swift in Sources */, B567AF2520A0CCA300AB6C62 /* AuthenticatedRequest.swift in Sources */, 453305E92459DF2100264E50 /* PostMapper.swift in Sources */, E12552C526385B05001CEE70 /* ShippingLabelAddressValidationSuccess.swift in Sources */, @@ -3222,6 +3229,7 @@ 93D8BBFD226BBEE800AD2EB3 /* AccountSettingsMapper.swift in Sources */, 31D27C8726028CE9002EDB1D /* SitePluginsMapper.swift in Sources */, 74D522B62113607F00042831 /* StatGranularity.swift in Sources */, + EE54C8A5294859D200A9BF61 /* ApplicationPasswordNetwork.swift in Sources */, 02C2549A25636E1500A04423 /* ShippingLabelAddress.swift in Sources */, 03DCB786262739D200C8953D /* CouponMapper.swift in Sources */, B518662220A097C200037A38 /* Network.swift in Sources */, diff --git a/Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift b/Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift index 908146c5ed2..211a0dac103 100644 --- a/Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift +++ b/Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift @@ -1,5 +1,11 @@ import Foundation import WordPressShared +import KeychainAccess + +enum ApplicationPasswordUseCaseError: Error { + case duplicateName + case applicationPasswordsDisabled +} struct ApplicationPassword { /// WordPress org username that the application password belongs to @@ -28,3 +34,235 @@ protocol ApplicationPasswordUseCase { /// func deletePassword() async throws } + +final class DefaultApplicationPasswordUseCase: ApplicationPasswordUseCase { + /// WordPress.com Credentials. + /// + private let credentials: Credentials + + /// SiteID needed when using WPCOM credentials + /// + private let siteID: Int64 + + /// To generate and delete application password + /// + private let network: Network + + /// Stores the application password + /// + private let keychain: Keychain + + /// Used to name the password in wpadmin. + /// + private var applicationPasswordName: String { + get async { + let bundleIdentifier = Bundle.main.bundleIdentifier ?? "Unknown" + let model = await UIDevice.current.model + return bundleIdentifier + ".ios-app-client." + model + } + } + + init(siteID: Int64, + networkcredentials: Credentials, + network: Network? = nil, + keychain: Keychain = Keychain(service: KeychainServiceName.name)) { + self.siteID = siteID + self.credentials = networkcredentials + self.keychain = keychain + + if let network { + self.network = network + } else { + self.network = ApplicationPasswordNetwork(credentials: networkcredentials) + } + } + + /// Returns the locally saved ApplicationPassword if available + /// + var applicationPassword: ApplicationPassword? { + guard let password = keychain.applicationPassword, + let username = keychain.applicationPasswordUsername else { + return nil + } + return ApplicationPassword(wpOrgUsername: username, password: Secret(password)) + } + + /// Generates new ApplicationPassword + /// + /// When `duplicateName` error occurs this method will delete the password and try generating again + /// + /// - Returns: Generated `ApplicationPassword` instance + /// + func generateNewPassword() async throws -> ApplicationPassword { + async let password = try { + do { + return try await createApplicationPasswordUsingWPCOMAuthToken() + } catch ApplicationPasswordUseCaseError.duplicateName { + try await deletePassword() + return try await createApplicationPasswordUsingWPCOMAuthToken() + } + }() + async let username = try fetchWPAdminUsername() + + let applicationPassword = try await ApplicationPassword(wpOrgUsername: username, password: Secret(password)) + saveApplicationPassword(applicationPassword) + return applicationPassword + } + + /// Deletes the application password + /// + /// Deletes locally and also sends an API request to delete it from the site + /// + func deletePassword() async throws { + try await deleteApplicationPasswordUsingWPCOMAuthToken() + } +} + +private extension DefaultApplicationPasswordUseCase { + /// Creates application password using WordPress.com authentication token + /// + /// - Returns: Application password as `String` + /// + func createApplicationPasswordUsingWPCOMAuthToken() async throws -> String { + let passwordName = await applicationPasswordName + + let parameters = [ParameterKey.name: passwordName] + let request = JetpackRequest(wooApiVersion: .none, method: .post, siteID: siteID, path: Path.applicationPasswords, parameters: parameters) + + return try await withCheckedThrowingContinuation { continuation in + network.responseData(for: request) { result in + switch result { + case .success(let data): + do { + let validator = request.responseDataValidator() + try validator.validate(data: data) + let mapper = ApplicationPasswordMapper() + let password = try mapper.map(response: data) + continuation.resume(returning: password) + } catch let DotcomError.unknown(code, _) where code == ErrorCode.applicationPasswordsDisabledErrorCode { + continuation.resume(throwing: ApplicationPasswordUseCaseError.applicationPasswordsDisabled) + } catch let DotcomError.unknown(code, _) where code == ErrorCode.duplicateNameErrorCode { + continuation.resume(throwing: ApplicationPasswordUseCaseError.duplicateName) + } catch { + continuation.resume(throwing: error) + } + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + /// Fetches wpadmin username using WordPress.com authentication token + /// + /// - Returns: wpadmin username + /// + func fetchWPAdminUsername() async throws -> String { + let parameters = [ + "context": "edit", + "fields": "id,username,id_wpcom,email,first_name,last_name,nickname,roles" + ] + let request = JetpackRequest(wooApiVersion: .none, method: .get, siteID: siteID, path: Path.users, parameters: parameters) + + return try await withCheckedThrowingContinuation { continuation in + network.responseData(for: request) { [weak self] result in + guard let self else { return } + + switch result { + case .success(let data): + do { + let validator = request.responseDataValidator() + try validator.validate(data: data) + let mapper = UserMapper(siteID: self.siteID) + let username = try mapper.map(response: data).username + continuation.resume(returning: username) + } catch { + continuation.resume(throwing: error) + } + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + /// Deletes application password using WordPress.com authentication token + /// + func deleteApplicationPasswordUsingWPCOMAuthToken() async throws { + // Delete password from keychain + keychain.applicationPassword = nil + keychain.applicationPasswordUsername = nil + + let passwordName = await applicationPasswordName + + let parameters = [ParameterKey.name: passwordName] + let request = JetpackRequest(wooApiVersion: .none, method: .delete, siteID: siteID, path: Path.applicationPasswords, parameters: parameters) + + try await withCheckedThrowingContinuation { continuation in + network.responseData(for: request) { result in + switch result { + case .success(let data): + do { + let validator = request.responseDataValidator() + try validator.validate(data: data) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + /// Saves application password into keychain + /// + /// - Parameter password: `ApplicationPasword` to be saved + /// + func saveApplicationPassword(_ password: ApplicationPassword) { + keychain.applicationPassword = password.wpOrgUsername + keychain.applicationPasswordUsername = password.password.secretValue + } +} + +// MARK: - Constants +// +private extension DefaultApplicationPasswordUseCase { + enum KeychainServiceName { + /// Matching `WooConstants.keychainServiceName` + /// + static let name = "com.automattic.woocommerce" + } + + enum Path { + static let applicationPasswords = "wp/v2/users/me/application-passwords" + static let users = "wp/v2/users/me" + } + + enum ParameterKey { + static let name = "name" + } + + enum ErrorCode { + static let applicationPasswordsDisabledErrorCode = "application_passwords_disabled" + static let duplicateNameErrorCode = "application_password_duplicate_name" + } +} + +// MARK: - For storing the application password in keychain +// +private extension Keychain { + private static let keychainApplicationPassword = "ApplicationPassword" + private static let keychainApplicationPasswordUsername = "ApplicationPasswordUsername" + + var applicationPassword: String? { + get { self[Keychain.keychainApplicationPassword] } + set { self[Keychain.keychainApplicationPassword] = newValue } + } + + var applicationPasswordUsername: String? { + get { self[Keychain.keychainApplicationPasswordUsername] } + set { self[Keychain.keychainApplicationPasswordUsername] = newValue } + } +} diff --git a/Networking/Networking/Mapper/ApplicationPasswordMapper.swift b/Networking/Networking/Mapper/ApplicationPasswordMapper.swift new file mode 100644 index 00000000000..02d73251907 --- /dev/null +++ b/Networking/Networking/Mapper/ApplicationPasswordMapper.swift @@ -0,0 +1,20 @@ +import Foundation + +struct ApplicationPasswordMapper: Mapper { + private struct ApplicationPassword: Decodable { + let password: String + } + + private struct ApplicationPasswordEnvelope: Decodable { + let password: ApplicationPassword + + private enum CodingKeys: String, CodingKey { + case password = "data" + } + } + + func map(response: Data) throws -> String { + let decoder = JSONDecoder() + return try decoder.decode(ApplicationPasswordEnvelope.self, from: response).password.password + } +} diff --git a/Networking/Networking/Network/ApplicationPasswordNetwork.swift b/Networking/Networking/Network/ApplicationPasswordNetwork.swift new file mode 100644 index 00000000000..62c10ec6401 --- /dev/null +++ b/Networking/Networking/Network/ApplicationPasswordNetwork.swift @@ -0,0 +1,52 @@ +import Combine +import Foundation +import Alamofire + +/// This Network is specific for generating and deleting application passwords +/// +/// - We cannot use the AlamofireNetwork as we will be initiating the application password generation from there. (By listening to other API calls) +/// - `ApplicationPasswordNetwork` currently takes in WPCOM credentials. In future it will also work with .org site credentials as well. +/// +public class ApplicationPasswordNetwork: Network { + /// WordPress.com Credentials. + /// + private let credentials: Credentials + + public var session: URLSession { SessionManager.default.session } + + /// Public Initializer + /// + public required init(credentials: Credentials) { + self.credentials = credentials + } + + /// Executes the specified Network Request. Upon completion, the payload will be sent back to the caller as a Data instance. + /// + /// - Important: + /// - Authentication Headers will be injected, based on the Network's Credentials. + /// + /// - Parameters: + /// - request: Request that should be performed. + /// - completion: Closure to be executed upon completion. + /// + public func responseData(for request: URLRequestConvertible, completion: @escaping (Swift.Result) -> Void) { + let request = AuthenticatedRequest(credentials: credentials, request: request) + + Alamofire.request(request).responseData { response in + completion(response.result.toSwiftResult()) + } + } + + @available(*, deprecated, message: "Not implemented. Use the `Result` based method instead.") + public func responseData(for request: URLRequestConvertible, completion: @escaping (Data?, Error?) -> Void) { } + + @available(*, deprecated, message: "Not implemented. Use the `Result` based method instead.") + public func responseDataPublisher(for request: URLRequestConvertible) -> AnyPublisher, Never> { + Empty, Never>().eraseToAnyPublisher() + } + + @available(*, deprecated, message: "Not implemented") + public func uploadMultipartFormData(multipartFormData: @escaping (MultipartFormData) -> Void, + to request: URLRequestConvertible, + completion: @escaping (Data?, Error?) -> Void) { } +} diff --git a/Podfile b/Podfile index 5d3266f1453..1db37333421 100644 --- a/Podfile +++ b/Podfile @@ -56,6 +56,21 @@ def stripe_terminal pod 'StripeTerminal', '~> 2.14' end +def networking_pods + alamofire + cocoa_lumberjack + + pod 'Sourcery', '~> 1.0.3', configuration: 'Debug' + + # Used for HTML parsing + aztec + + # Used for storing application password + keychain + + wordpress_kit +end + # Main Target! # ============ # @@ -122,6 +137,7 @@ def yosemite_pods stripe_terminal cocoa_lumberjack wordpress_kit + networking_pods aztec end @@ -165,21 +181,6 @@ target 'WooFoundationTests' do woofoundation_pods end -# Networking Layer: -# ================= -# -def networking_pods - alamofire - cocoa_lumberjack - - pod 'Sourcery', '~> 1.0.3', configuration: 'Debug' - - # Used for HTML parsing - aztec - - wordpress_kit -end - # Networking Target: # ================== # diff --git a/Podfile.lock b/Podfile.lock index 593fe5738ab..9f11c86db3f 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -169,6 +169,6 @@ SPEC CHECKSUMS: ZendeskSupportProvidersSDK: 2bdf8544f7cd0fd4c002546f5704b813845beb2a ZendeskSupportSDK: 3a8e508ab1d9dd22dc038df6c694466414e037ba -PODFILE CHECKSUM: cc3c6d9d046f232dba2b1be5dad549543da973bd +PODFILE CHECKSUM: 670bb1b07306bd212f79b8afb1ebed4fc6eaa7b7 COCOAPODS: 1.11.3