Skip to content

Commit c5aafe4

Browse files
Merge pull request #8400 from woocommerce/feat/8394-generate-application-password
[REST API] Use case to generate and delete Application password
2 parents e84dcb0 + cd306a2 commit c5aafe4

File tree

6 files changed

+341
-22
lines changed

6 files changed

+341
-22
lines changed

Networking/Networking.xcodeproj/project.pbxproj

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -730,7 +730,9 @@
730730
E1BAB2C32913FA6400C3982B /* ResponseDataValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BAB2C22913FA6400C3982B /* ResponseDataValidator.swift */; };
731731
E1BAB2C52913FB1800C3982B /* WordPressApiValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BAB2C42913FB1800C3982B /* WordPressApiValidator.swift */; };
732732
E1BAB2C72913FB5800C3982B /* WordPressApiError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BAB2C62913FB5800C3982B /* WordPressApiError.swift */; };
733-
EE54C8942947229800A9BF61 /* ApplicationPasswordUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE54C8932947229800A9BF61 /* ApplicationPasswordUseCase.swift */; };
733+
EE54C89F2947782E00A9BF61 /* ApplicationPasswordUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */; };
734+
EE54C8A5294859D200A9BF61 /* ApplicationPasswordNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE54C8A4294859D200A9BF61 /* ApplicationPasswordNetwork.swift */; };
735+
EE54C8A729486B6800A9BF61 /* ApplicationPasswordMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE54C8A629486B6800A9BF61 /* ApplicationPasswordMapper.swift */; };
734736
EE8A86F1286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json in Resources */ = {isa = PBXBuildFile; fileRef = EE8A86F0286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json */; };
735737
EECB7EE8286555180028C888 /* media-update-product-id.json in Resources */ = {isa = PBXBuildFile; fileRef = EECB7EE7286555180028C888 /* media-update-product-id.json */; };
736738
FE28F6E226840DED004465C7 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE28F6E126840DED004465C7 /* User.swift */; };
@@ -1493,7 +1495,9 @@
14931495
E1BAB2C22913FA6400C3982B /* ResponseDataValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseDataValidator.swift; sourceTree = "<group>"; };
14941496
E1BAB2C42913FB1800C3982B /* WordPressApiValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressApiValidator.swift; sourceTree = "<group>"; };
14951497
E1BAB2C62913FB5800C3982B /* WordPressApiError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressApiError.swift; sourceTree = "<group>"; };
1496-
EE54C8932947229800A9BF61 /* ApplicationPasswordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordUseCase.swift; sourceTree = "<group>"; };
1498+
EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordUseCase.swift; sourceTree = "<group>"; };
1499+
EE54C8A4294859D200A9BF61 /* ApplicationPasswordNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordNetwork.swift; sourceTree = "<group>"; };
1500+
EE54C8A629486B6800A9BF61 /* ApplicationPasswordMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordMapper.swift; sourceTree = "<group>"; };
14971501
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>"; };
14981502
EECB7EE7286555180028C888 /* media-update-product-id.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-update-product-id.json"; sourceTree = "<group>"; };
14991503
F3F25DC15EC1D7C631169CB5 /* Pods_Networking.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Networking.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -1706,6 +1710,7 @@
17061710
B518662320A099BF00037A38 /* AlamofireNetwork.swift */,
17071711
B518662620A09BCC00037A38 /* MockNetwork.swift */,
17081712
D87F6150226591E10031A13B /* NullNetwork.swift */,
1713+
EE54C8A4294859D200A9BF61 /* ApplicationPasswordNetwork.swift */,
17091714
);
17101715
path = Network;
17111716
sourceTree = "<group>";
@@ -1818,7 +1823,7 @@
18181823
B557D9E5209753AA005962F4 /* Networking */ = {
18191824
isa = PBXGroup;
18201825
children = (
1821-
EE54C8922947227900A9BF61 /* ApplicationPassword */,
1826+
EE54C899294777D000A9BF61 /* ApplicationPassword */,
18221827
B5A0369F214C0F4C00774E2C /* Internal */,
18231828
B5BB1D0A20A204F400112D92 /* Extensions */,
18241829
B567AF2720A0FA0A00AB6C62 /* Mapper */,
@@ -2379,6 +2384,7 @@
23792384
DE34051828BDEE6A00CF0D97 /* JetpackConnectionURLMapper.swift */,
23802385
68CB800D28D8901B00E169F8 /* CustomerMapper.swift */,
23812386
68F48B0C28E3B2E80045C15B /* WCAnalyticsCustomerMapper.swift */,
2387+
EE54C8A629486B6800A9BF61 /* ApplicationPasswordMapper.swift */,
23822388
);
23832389
path = Mapper;
23842390
sourceTree = "<group>";
@@ -2581,10 +2587,10 @@
25812587
path = SystemStatusDetails;
25822588
sourceTree = "<group>";
25832589
};
2584-
EE54C8922947227900A9BF61 /* ApplicationPassword */ = {
2590+
EE54C899294777D000A9BF61 /* ApplicationPassword */ = {
25852591
isa = PBXGroup;
25862592
children = (
2587-
EE54C8932947229800A9BF61 /* ApplicationPasswordUseCase.swift */,
2593+
EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */,
25882594
);
25892595
path = ApplicationPassword;
25902596
sourceTree = "<group>";
@@ -3026,6 +3032,7 @@
30263032
FE28F6E6268429B6004465C7 /* UserRemote.swift in Sources */,
30273033
7452387421124B7700A973CD /* AnyDecodable.swift in Sources */,
30283034
CEF88DAD233E95B000BED485 /* OrderRefundCondensed.swift in Sources */,
3035+
EE54C8A729486B6800A9BF61 /* ApplicationPasswordMapper.swift in Sources */,
30293036
FE28F6E426842848004465C7 /* UserMapper.swift in Sources */,
30303037
CE430670234B99F50073CBFF /* RefundsRemote.swift in Sources */,
30313038
B56C1EB620EA757B00D749F9 /* SiteListMapper.swift in Sources */,
@@ -3076,7 +3083,7 @@
30763083
743E84EE2217244C00FAC9D7 /* ShipmentTrackingListMapper.swift in Sources */,
30773084
451A97E5260B631E0059D135 /* ShippingLabelPredefinedPackage.swift in Sources */,
30783085
BAB373722795A1FB00837B4A /* OrderTaxLine.swift in Sources */,
3079-
EE54C8942947229800A9BF61 /* ApplicationPasswordUseCase.swift in Sources */,
3086+
EE54C89F2947782E00A9BF61 /* ApplicationPasswordUseCase.swift in Sources */,
30803087
B567AF2520A0CCA300AB6C62 /* AuthenticatedRequest.swift in Sources */,
30813088
453305E92459DF2100264E50 /* PostMapper.swift in Sources */,
30823089
E12552C526385B05001CEE70 /* ShippingLabelAddressValidationSuccess.swift in Sources */,
@@ -3226,6 +3233,7 @@
32263233
93D8BBFD226BBEE800AD2EB3 /* AccountSettingsMapper.swift in Sources */,
32273234
31D27C8726028CE9002EDB1D /* SitePluginsMapper.swift in Sources */,
32283235
74D522B62113607F00042831 /* StatGranularity.swift in Sources */,
3236+
EE54C8A5294859D200A9BF61 /* ApplicationPasswordNetwork.swift in Sources */,
32293237
02C2549A25636E1500A04423 /* ShippingLabelAddress.swift in Sources */,
32303238
03DCB786262739D200C8953D /* CouponMapper.swift in Sources */,
32313239
B518662220A097C200037A38 /* Network.swift in Sources */,

Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import Foundation
22
import WordPressShared
3+
import KeychainAccess
4+
5+
enum ApplicationPasswordUseCaseError: Error {
6+
case duplicateName
7+
case applicationPasswordsDisabled
8+
}
39

410
struct ApplicationPassword {
511
/// WordPress org username that the application password belongs to
@@ -28,3 +34,235 @@ protocol ApplicationPasswordUseCase {
2834
///
2935
func deletePassword() async throws
3036
}
37+
38+
final class DefaultApplicationPasswordUseCase: ApplicationPasswordUseCase {
39+
/// WordPress.com Credentials.
40+
///
41+
private let credentials: Credentials
42+
43+
/// SiteID needed when using WPCOM credentials
44+
///
45+
private let siteID: Int64
46+
47+
/// To generate and delete application password
48+
///
49+
private let network: Network
50+
51+
/// Stores the application password
52+
///
53+
private let keychain: Keychain
54+
55+
/// Used to name the password in wpadmin.
56+
///
57+
private var applicationPasswordName: String {
58+
get async {
59+
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "Unknown"
60+
let model = await UIDevice.current.model
61+
return bundleIdentifier + ".ios-app-client." + model
62+
}
63+
}
64+
65+
init(siteID: Int64,
66+
networkcredentials: Credentials,
67+
network: Network? = nil,
68+
keychain: Keychain = Keychain(service: KeychainServiceName.name)) {
69+
self.siteID = siteID
70+
self.credentials = networkcredentials
71+
self.keychain = keychain
72+
73+
if let network {
74+
self.network = network
75+
} else {
76+
self.network = ApplicationPasswordNetwork(credentials: networkcredentials)
77+
}
78+
}
79+
80+
/// Returns the locally saved ApplicationPassword if available
81+
///
82+
var applicationPassword: ApplicationPassword? {
83+
guard let password = keychain.applicationPassword,
84+
let username = keychain.applicationPasswordUsername else {
85+
return nil
86+
}
87+
return ApplicationPassword(wpOrgUsername: username, password: Secret(password))
88+
}
89+
90+
/// Generates new ApplicationPassword
91+
///
92+
/// When `duplicateName` error occurs this method will delete the password and try generating again
93+
///
94+
/// - Returns: Generated `ApplicationPassword` instance
95+
///
96+
func generateNewPassword() async throws -> ApplicationPassword {
97+
async let password = try {
98+
do {
99+
return try await createApplicationPasswordUsingWPCOMAuthToken()
100+
} catch ApplicationPasswordUseCaseError.duplicateName {
101+
try await deletePassword()
102+
return try await createApplicationPasswordUsingWPCOMAuthToken()
103+
}
104+
}()
105+
async let username = try fetchWPAdminUsername()
106+
107+
let applicationPassword = try await ApplicationPassword(wpOrgUsername: username, password: Secret(password))
108+
saveApplicationPassword(applicationPassword)
109+
return applicationPassword
110+
}
111+
112+
/// Deletes the application password
113+
///
114+
/// Deletes locally and also sends an API request to delete it from the site
115+
///
116+
func deletePassword() async throws {
117+
try await deleteApplicationPasswordUsingWPCOMAuthToken()
118+
}
119+
}
120+
121+
private extension DefaultApplicationPasswordUseCase {
122+
/// Creates application password using WordPress.com authentication token
123+
///
124+
/// - Returns: Application password as `String`
125+
///
126+
func createApplicationPasswordUsingWPCOMAuthToken() async throws -> String {
127+
let passwordName = await applicationPasswordName
128+
129+
let parameters = [ParameterKey.name: passwordName]
130+
let request = JetpackRequest(wooApiVersion: .none, method: .post, siteID: siteID, path: Path.applicationPasswords, parameters: parameters)
131+
132+
return try await withCheckedThrowingContinuation { continuation in
133+
network.responseData(for: request) { result in
134+
switch result {
135+
case .success(let data):
136+
do {
137+
let validator = request.responseDataValidator()
138+
try validator.validate(data: data)
139+
let mapper = ApplicationPasswordMapper()
140+
let password = try mapper.map(response: data)
141+
continuation.resume(returning: password)
142+
} catch let DotcomError.unknown(code, _) where code == ErrorCode.applicationPasswordsDisabledErrorCode {
143+
continuation.resume(throwing: ApplicationPasswordUseCaseError.applicationPasswordsDisabled)
144+
} catch let DotcomError.unknown(code, _) where code == ErrorCode.duplicateNameErrorCode {
145+
continuation.resume(throwing: ApplicationPasswordUseCaseError.duplicateName)
146+
} catch {
147+
continuation.resume(throwing: error)
148+
}
149+
case .failure(let error):
150+
continuation.resume(throwing: error)
151+
}
152+
}
153+
}
154+
}
155+
156+
/// Fetches wpadmin username using WordPress.com authentication token
157+
///
158+
/// - Returns: wpadmin username
159+
///
160+
func fetchWPAdminUsername() async throws -> String {
161+
let parameters = [
162+
"context": "edit",
163+
"fields": "id,username,id_wpcom,email,first_name,last_name,nickname,roles"
164+
]
165+
let request = JetpackRequest(wooApiVersion: .none, method: .get, siteID: siteID, path: Path.users, parameters: parameters)
166+
167+
return try await withCheckedThrowingContinuation { continuation in
168+
network.responseData(for: request) { [weak self] result in
169+
guard let self else { return }
170+
171+
switch result {
172+
case .success(let data):
173+
do {
174+
let validator = request.responseDataValidator()
175+
try validator.validate(data: data)
176+
let mapper = UserMapper(siteID: self.siteID)
177+
let username = try mapper.map(response: data).username
178+
continuation.resume(returning: username)
179+
} catch {
180+
continuation.resume(throwing: error)
181+
}
182+
case .failure(let error):
183+
continuation.resume(throwing: error)
184+
}
185+
}
186+
}
187+
}
188+
189+
/// Deletes application password using WordPress.com authentication token
190+
///
191+
func deleteApplicationPasswordUsingWPCOMAuthToken() async throws {
192+
// Delete password from keychain
193+
keychain.applicationPassword = nil
194+
keychain.applicationPasswordUsername = nil
195+
196+
let passwordName = await applicationPasswordName
197+
198+
let parameters = [ParameterKey.name: passwordName]
199+
let request = JetpackRequest(wooApiVersion: .none, method: .delete, siteID: siteID, path: Path.applicationPasswords, parameters: parameters)
200+
201+
try await withCheckedThrowingContinuation { continuation in
202+
network.responseData(for: request) { result in
203+
switch result {
204+
case .success(let data):
205+
do {
206+
let validator = request.responseDataValidator()
207+
try validator.validate(data: data)
208+
continuation.resume()
209+
} catch {
210+
continuation.resume(throwing: error)
211+
}
212+
case .failure(let error):
213+
continuation.resume(throwing: error)
214+
}
215+
}
216+
}
217+
}
218+
219+
/// Saves application password into keychain
220+
///
221+
/// - Parameter password: `ApplicationPasword` to be saved
222+
///
223+
func saveApplicationPassword(_ password: ApplicationPassword) {
224+
keychain.applicationPassword = password.wpOrgUsername
225+
keychain.applicationPasswordUsername = password.password.secretValue
226+
}
227+
}
228+
229+
// MARK: - Constants
230+
//
231+
private extension DefaultApplicationPasswordUseCase {
232+
enum KeychainServiceName {
233+
/// Matching `WooConstants.keychainServiceName`
234+
///
235+
static let name = "com.automattic.woocommerce"
236+
}
237+
238+
enum Path {
239+
static let applicationPasswords = "wp/v2/users/me/application-passwords"
240+
static let users = "wp/v2/users/me"
241+
}
242+
243+
enum ParameterKey {
244+
static let name = "name"
245+
}
246+
247+
enum ErrorCode {
248+
static let applicationPasswordsDisabledErrorCode = "application_passwords_disabled"
249+
static let duplicateNameErrorCode = "application_password_duplicate_name"
250+
}
251+
}
252+
253+
// MARK: - For storing the application password in keychain
254+
//
255+
private extension Keychain {
256+
private static let keychainApplicationPassword = "ApplicationPassword"
257+
private static let keychainApplicationPasswordUsername = "ApplicationPasswordUsername"
258+
259+
var applicationPassword: String? {
260+
get { self[Keychain.keychainApplicationPassword] }
261+
set { self[Keychain.keychainApplicationPassword] = newValue }
262+
}
263+
264+
var applicationPasswordUsername: String? {
265+
get { self[Keychain.keychainApplicationPasswordUsername] }
266+
set { self[Keychain.keychainApplicationPasswordUsername] = newValue }
267+
}
268+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Foundation
2+
3+
struct ApplicationPasswordMapper: Mapper {
4+
private struct ApplicationPassword: Decodable {
5+
let password: String
6+
}
7+
8+
private struct ApplicationPasswordEnvelope: Decodable {
9+
let password: ApplicationPassword
10+
11+
private enum CodingKeys: String, CodingKey {
12+
case password = "data"
13+
}
14+
}
15+
16+
func map(response: Data) throws -> String {
17+
let decoder = JSONDecoder()
18+
return try decoder.decode(ApplicationPasswordEnvelope.self, from: response).password.password
19+
}
20+
}

0 commit comments

Comments
 (0)