Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
762da86
Import Keychain access into Networking layer to store application pas…
selanthiraiyan Dec 14, 2022
8306481
Add `ApplicationPasswordNetwork` to perform application password gene…
selanthiraiyan Dec 14, 2022
ae2f7ec
Add `ApplicationPasswordMapper` to map application password generatio…
selanthiraiyan Dec 14, 2022
c330d5f
Introduce `DefaultApplicationPasswordUseCase` to perform application …
selanthiraiyan Dec 14, 2022
325e1d0
Add ability to store password to keychain.
selanthiraiyan Dec 14, 2022
cacc16e
Generate application password and handle errors.
selanthiraiyan Dec 14, 2022
de36ede
Fetch wpadmin username using WPCOM token.
selanthiraiyan Dec 14, 2022
a0b8e16
Delete application password using WPCOM authentication token.
selanthiraiyan Dec 14, 2022
cdc20de
Conform to `ApplicationPasswordUseCase`
selanthiraiyan Dec 14, 2022
6b63664
Change keychain service name to match WooCommerce.
selanthiraiyan Dec 14, 2022
d905e7a
Change application password name format to include bundle identifier.
selanthiraiyan Dec 14, 2022
38c7dec
Delete password and try again when `duplicatName` error occurs.
selanthiraiyan Dec 14, 2022
b2f3d74
Explain why we need `ApplicationPasswordNetwork`.
selanthiraiyan Dec 14, 2022
7d98684
Merge branch 'trunk' into feat/8394-generate-application-password
selanthiraiyan Dec 14, 2022
76a8f12
Send password generation and username fetching in parallel.
selanthiraiyan Dec 14, 2022
7b5dbe0
Add missing `networking_pods` dependencies to Yosemite in `Podfile`
mokagio Dec 15, 2022
cd306a2
Merge pull request #8418 from woocommerce/mokagio/test-8394-ci-fix
selanthiraiyan Dec 15, 2022
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
20 changes: 14 additions & 6 deletions Networking/Networking.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1491,7 +1493,9 @@
E1BAB2C22913FA6400C3982B /* ResponseDataValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseDataValidator.swift; sourceTree = "<group>"; };
E1BAB2C42913FB1800C3982B /* WordPressApiValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressApiValidator.swift; sourceTree = "<group>"; };
E1BAB2C62913FB5800C3982B /* WordPressApiError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressApiError.swift; sourceTree = "<group>"; };
EE54C8932947229800A9BF61 /* ApplicationPasswordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordUseCase.swift; sourceTree = "<group>"; };
EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordUseCase.swift; sourceTree = "<group>"; };
EE54C8A4294859D200A9BF61 /* ApplicationPasswordNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordNetwork.swift; sourceTree = "<group>"; };
EE54C8A629486B6800A9BF61 /* ApplicationPasswordMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordMapper.swift; sourceTree = "<group>"; };
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 = "<group>"; };
EECB7EE7286555180028C888 /* media-update-product-id.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-update-product-id.json"; sourceTree = "<group>"; };
F3F25DC15EC1D7C631169CB5 /* Pods_Networking.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Networking.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -1704,6 +1708,7 @@
B518662320A099BF00037A38 /* AlamofireNetwork.swift */,
B518662620A09BCC00037A38 /* MockNetwork.swift */,
D87F6150226591E10031A13B /* NullNetwork.swift */,
EE54C8A4294859D200A9BF61 /* ApplicationPasswordNetwork.swift */,
);
path = Network;
sourceTree = "<group>";
Expand Down Expand Up @@ -1816,7 +1821,7 @@
B557D9E5209753AA005962F4 /* Networking */ = {
isa = PBXGroup;
children = (
EE54C8922947227900A9BF61 /* ApplicationPassword */,
EE54C899294777D000A9BF61 /* ApplicationPassword */,
B5A0369F214C0F4C00774E2C /* Internal */,
B5BB1D0A20A204F400112D92 /* Extensions */,
B567AF2720A0FA0A00AB6C62 /* Mapper */,
Expand Down Expand Up @@ -2376,6 +2381,7 @@
DE34051828BDEE6A00CF0D97 /* JetpackConnectionURLMapper.swift */,
68CB800D28D8901B00E169F8 /* CustomerMapper.swift */,
68F48B0C28E3B2E80045C15B /* WCAnalyticsCustomerMapper.swift */,
EE54C8A629486B6800A9BF61 /* ApplicationPasswordMapper.swift */,
);
path = Mapper;
sourceTree = "<group>";
Expand Down Expand Up @@ -2578,10 +2584,10 @@
path = SystemStatusDetails;
sourceTree = "<group>";
};
EE54C8922947227900A9BF61 /* ApplicationPassword */ = {
EE54C899294777D000A9BF61 /* ApplicationPassword */ = {
isa = PBXGroup;
children = (
EE54C8932947229800A9BF61 /* ApplicationPasswordUseCase.swift */,
EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */,
);
path = ApplicationPassword;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
let password = try await {
do {
return try await createApplicationPasswordUsingWPCOMAuthToken()
} catch ApplicationPasswordUseCaseError.duplicateName {
try await deletePassword()
return try await createApplicationPasswordUsingWPCOMAuthToken()
}
}()
let username = try await fetchWPAdminUsername()

let applicationPassword = 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 }
}
}
20 changes: 20 additions & 0 deletions Networking/Networking/Mapper/ApplicationPasswordMapper.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading