From 911013c7bb7865259fa4a32b034c31b66081c2b1 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 12 Dec 2023 15:29:06 +1300 Subject: [PATCH 1/8] Declare URL properties as URL type --- WordPressKit/WordPressComOAuthClient.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/WordPressKit/WordPressComOAuthClient.swift b/WordPressKit/WordPressComOAuthClient.swift index 5494260a..dd344e97 100644 --- a/WordPressKit/WordPressComOAuthClient.swift +++ b/WordPressKit/WordPressComOAuthClient.swift @@ -85,8 +85,8 @@ public final class WordPressComOAuthClient: NSObject { case socialLogin2FA = "/wp-login.php?action=two-step-authentication-endpoint&version=1.0" case socialLoginNewSMS2FA = "/wp-login.php?action=send-sms-code-endpoint" - func url(base: String) -> URL { - return URL(string: self.rawValue, relativeTo: URL(string: base))! + func url(base: URL) -> URL { + return URL(string: self.rawValue, relativeTo: base)! } } @@ -95,8 +95,8 @@ public final class WordPressComOAuthClient: NSObject { private let clientID: String private let secret: String - private let wordPressComBaseUrl: String - private let wordPressComApiBaseUrl: String + private let wordPressComBaseUrl: URL + private let wordPressComApiBaseUrl: URL private let oauth2SessionManager: SessionManager = { return WordPressComOAuthClient.sessionManager() @@ -160,8 +160,8 @@ public final class WordPressComOAuthClient: NSObject { wordPressComApiBaseUrl: String = WordPressComOAuthClient.WordPressComOAuthDefaultApiBaseUrl) { self.clientID = clientID self.secret = secret - self.wordPressComBaseUrl = wordPressComBaseUrl - self.wordPressComApiBaseUrl = wordPressComApiBaseUrl + self.wordPressComBaseUrl = URL(string: wordPressComBaseUrl)! + self.wordPressComApiBaseUrl = URL(string: wordPressComApiBaseUrl)! } /// Authenticates on WordPress.com using the OAuth endpoints. From 0734426df0b7763872ddabfc5f6f805c977c7284 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 12 Dec 2023 15:30:35 +1300 Subject: [PATCH 2/8] Handle authentication API error response --- WordPressKit/WordPressComOAuthClient.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/WordPressKit/WordPressComOAuthClient.swift b/WordPressKit/WordPressComOAuthClient.swift index dd344e97..140646a3 100644 --- a/WordPressKit/WordPressComOAuthClient.swift +++ b/WordPressKit/WordPressComOAuthClient.swift @@ -689,3 +689,16 @@ extension WordPressComOAuthClient { } } } + +private extension WordPressComOAuthClient { + static func processError(_ response: HTTPAPIResponse) -> AuthenticationFailure? { + guard [400, 409, 403].contains(response.response.statusCode), + let responseObject = try? JSONSerialization.jsonObject(with: response.body, options: .allowFragments), + let responseDictionary = responseObject as? [String: AnyObject] + else { + return nil + } + + return .init(apiJSONResponse: responseDictionary) + } +} From 204a387a3131ffd11cf6b0942d7e798c5fd4b095 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 12 Dec 2023 15:48:31 +1300 Subject: [PATCH 3/8] Refactor username and password authentication --- WordPressKit/WordPressComOAuthClient.swift | 114 +++++++++++++++------ 1 file changed, 80 insertions(+), 34 deletions(-) diff --git a/WordPressKit/WordPressComOAuthClient.swift b/WordPressKit/WordPressComOAuthClient.swift index 140646a3..80852deb 100644 --- a/WordPressKit/WordPressComOAuthClient.swift +++ b/WordPressKit/WordPressComOAuthClient.swift @@ -98,6 +98,11 @@ public final class WordPressComOAuthClient: NSObject { private let wordPressComBaseUrl: URL private let wordPressComApiBaseUrl: URL + // Question: Is it necessary to use these many URLSession instances? + private let oauth2Session: URLSession = { + WordPressComOAuthClient.urlSession() + }() + private let oauth2SessionManager: SessionManager = { return WordPressComOAuthClient.sessionManager() }() @@ -126,6 +131,12 @@ public final class WordPressComOAuthClient: NSObject { return sessionManager } + private class func urlSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpAdditionalHeaders = ["Accept": "application/json"] + return URLSession(configuration: configuration) + } + /// Creates a WordPresComOAuthClient initialized with the clientID and secrets provided /// @objc public class func client(clientID: String, secret: String) -> WordPressComOAuthClient { @@ -164,70 +175,99 @@ public final class WordPressComOAuthClient: NSObject { self.wordPressComApiBaseUrl = URL(string: wordPressComApiBaseUrl)! } + public enum AuthenticationResult { + case authenticated(token: String) + case needsMultiFactor(userID: Int, nonceInfo: SocialLogin2FANonceInfo) + } + /// Authenticates on WordPress.com using the OAuth endpoints. /// /// - Parameters: /// - username: the account's username. /// - password: the account's password. /// - multifactorCode: Multifactor Authentication One-Time-Password. If not needed, can be nil - /// - needsMultifactor: @escaping (_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void, - /// - success: block to be called if authentication was successful. The OAuth2 token is passed as a parameter. - /// - failure: block to be called if authentication failed. The error object is passed as a parameter. - /// public func authenticate( username: String, password: String, - multifactorCode: String?, - needsMultifactor: @escaping ((_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void), - success: @escaping (_ authToken: String?) -> Void, - failure: @escaping (_ error: WordPressComOAuthError) -> Void - ) { - var parameters: [String: AnyObject] = [ - "username": username as AnyObject, - "password": password as AnyObject, - "grant_type": "password" as AnyObject, - "client_id": clientID as AnyObject, - "client_secret": secret as AnyObject, - "wpcom_supports_2fa": true as AnyObject, - "with_auth_types": true as AnyObject + multifactorCode: String? + ) async -> WordPressAPIResult { + var form = [ + "username": username, + "password": password, + "grant_type": "password", + "client_id": clientID, + "client_secret": secret, + "wpcom_supports_2fa": "true", + "with_auth_types": "true" ] - if let multifactorCode = multifactorCode, !multifactorCode.isEmpty { - parameters["wpcom_otp"] = multifactorCode as AnyObject? + if let multifactorCode, !multifactorCode.isEmpty { + form["wpcom_otp"] = multifactorCode } - oauth2SessionManager.request(WordPressComURL.oAuthBase.url(base: wordPressComApiBaseUrl), method: .post, parameters: parameters) - .validate() - .responseJSON(completionHandler: { response in - switch response.result { - case .success(let responseObject): + let builder = tokenRequestBuilder().body(form: form) + return await oauth2Session + .perform(request: builder) + .assessStatusCode( + success: { response in + guard let responseObject = try? JSONSerialization.jsonObject(with: response.body) else { + return nil + } + WPKitLogVerbose("Received OAuth2 response: \(self.cleanedUpResponseForLogging(responseObject as AnyObject? ?? "nil" as AnyObject))") guard let responseDictionary = responseObject as? [String: AnyObject] else { - return failure(.unparsableResponse(response: response.response, body: response.data)) + return nil } // If we found an access_token, we are authed. if let authToken = responseDictionary["access_token"] as? String { - return success(authToken) + return .authenticated(token: authToken) } // If there is no access token, check for a security key nonce guard let responseData = responseDictionary["data"] as? [String: AnyObject], let userID = responseData["user_id"] as? Int, let _ = responseData["two_step_nonce_webauthn"] else { - return failure(.unparsableResponse(response: response.response, body: response.data)) + return nil } let nonceInfo = self.extractNonceInfo(data: responseData) - needsMultifactor(userID, nonceInfo) - case .failure(let error): - let nserror = self.processError(response: response, originalError: error) - WPKitLogError("Error receiving OAuth2 token: \(nserror)") - failure(nserror) - } - }) + return .needsMultiFactor(userID: userID, nonceInfo: nonceInfo) + }, + failure: Self.processError(_:) + ) + } + + /// Authenticates on WordPress.com using the OAuth endpoints. + /// + /// - Parameters: + /// - username: the account's username. + /// - password: the account's password. + /// - multifactorCode: Multifactor Authentication One-Time-Password. If not needed, can be nil + /// - needsMultifactor: @escaping (_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void, + /// - success: block to be called if authentication was successful. The OAuth2 token is passed as a parameter. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func authenticate( + username: String, + password: String, + multifactorCode: String?, + needsMultifactor: @escaping ((_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void), + success: @escaping (_ authToken: String?) -> Void, + failure: @escaping (_ error: WordPressComOAuthError) -> Void + ) { + Task { @MainActor in + let result = await authenticate(username: username, password: password, multifactorCode: multifactorCode) + switch result { + case let .success(.authenticated(token)): + success(token) + case let .success(.needsMultiFactor(userID, nonceInfo)): + needsMultifactor(userID, nonceInfo) + case let .failure(error): + failure(error) + } + } } /// Requests a One Time Code, to be sent via SMS. @@ -691,6 +731,12 @@ extension WordPressComOAuthClient { } private extension WordPressComOAuthClient { + func tokenRequestBuilder() -> HTTPRequestBuilder { + HTTPRequestBuilder(url: wordPressComApiBaseUrl) + .method(.post) + .append(path: "/oauth2/token") + } + static func processError(_ response: HTTPAPIResponse) -> AuthenticationFailure? { guard [400, 409, 403].contains(response.response.statusCode), let responseObject = try? JSONSerialization.jsonObject(with: response.body, options: .allowFragments), From 1ad9261aec9023b243e982401acc24ac0d74386d Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 12 Dec 2023 15:53:15 +1300 Subject: [PATCH 4/8] Refactor requesting one time password --- WordPressKit/WordPressComOAuthClient.swift | 52 +++++++++++++--------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/WordPressKit/WordPressComOAuthClient.swift b/WordPressKit/WordPressComOAuthClient.swift index 80852deb..19cf4437 100644 --- a/WordPressKit/WordPressComOAuthClient.swift +++ b/WordPressKit/WordPressComOAuthClient.swift @@ -78,7 +78,6 @@ public final class WordPressComOAuthClient: NSObject { @objc public static let WordPressComOAuthDefaultApiBaseUrl = "https://public-api.wordpress.com" enum WordPressComURL: String { - case oAuthBase = "/oauth2/token" case webauthnChallenge = "wp-login.php?action=webauthn-challenge-endpoint" case webauthnAuthentication = "wp-login.php?action=webauthn-authentication-endpoint" case socialLogin = "/wp-login.php?action=social-login-endpoint&version=1.0" @@ -277,35 +276,44 @@ public final class WordPressComOAuthClient: NSObject { /// - password: the account's password. /// - success: block to be called if authentication was successful. /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func requestOneTimeCode(username: String, password: String) async -> WordPressAPIResult { + let builder = tokenRequestBuilder() + .body(form: [ + "username": username, + "password": password, + "grant_type": "password", + "client_id": clientID, + "client_secret": secret, + "wpcom_supports_2fa": "true", + "wpcom_resend_otp": "true" + ]) + return await oauth2Session + .perform(request: builder) + .assessStatusCode(success: { _ in () }, failure: Self.processError(_:)) + } + + /// Requests a One Time Code, to be sent via SMS. /// + /// - Parameters: + /// - username: the account's username. + /// - password: the account's password. + /// - success: block to be called if authentication was successful. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. public func requestOneTimeCode( username: String, password: String, success: @escaping () -> Void, failure: @escaping (_ error: WordPressComOAuthError) -> Void ) { - let parameters = [ - "username": username, - "password": password, - "grant_type": "password", - "client_id": clientID, - "client_secret": secret, - "wpcom_supports_2fa": true, - "wpcom_resend_otp": true - ] as [String: Any] - - oauth2SessionManager.request(WordPressComURL.oAuthBase.url(base: wordPressComApiBaseUrl), method: .post, parameters: parameters) - .validate() - .responseJSON(completionHandler: { response in - switch response.result { - case .success: - success() - case .failure(let error): - let nserror = self.processError(response: response, originalError: error) - failure(nserror) - } + Task { @MainActor in + let result = await requestOneTimeCode(username: username, password: password) + switch result { + case .success: + success() + case let .failure(error): + failure(error) } - ) + } } /// Request a new SMS code to be sent during social login From a59263c9f925c2b5c36da881bcd31e17ea9a70ab Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 13 Dec 2023 16:01:40 +1300 Subject: [PATCH 5/8] Remove a no longer property --- WordPressKit/WordPressComOAuthClient.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/WordPressKit/WordPressComOAuthClient.swift b/WordPressKit/WordPressComOAuthClient.swift index 19cf4437..e6968f53 100644 --- a/WordPressKit/WordPressComOAuthClient.swift +++ b/WordPressKit/WordPressComOAuthClient.swift @@ -102,10 +102,6 @@ public final class WordPressComOAuthClient: NSObject { WordPressComOAuthClient.urlSession() }() - private let oauth2SessionManager: SessionManager = { - return WordPressComOAuthClient.sessionManager() - }() - private let webauthnSessionManager: SessionManager = { return WordPressComOAuthClient.sessionManager() }() From 5c98260fcfc4796aef53973d4ad17fab400bf6f2 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 13 Dec 2023 16:02:08 +1300 Subject: [PATCH 6/8] Add a changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4abc7a8..58892d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,7 @@ _None._ ### Internal Changes -_None._ +- Refactor WP.com authentication API requests. [#660] ## 10.0.0 From 72451fa8cca8da2384f3cd4c40555c6b981fd7de Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 12 Jan 2024 09:14:18 +1300 Subject: [PATCH 7/8] Adopt the new URLSession helper API --- WordPressKit/WordPressComOAuthClient.swift | 55 +++++++++++----------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/WordPressKit/WordPressComOAuthClient.swift b/WordPressKit/WordPressComOAuthClient.swift index e6968f53..1c8c5f1d 100644 --- a/WordPressKit/WordPressComOAuthClient.swift +++ b/WordPressKit/WordPressComOAuthClient.swift @@ -203,36 +203,34 @@ public final class WordPressComOAuthClient: NSObject { let builder = tokenRequestBuilder().body(form: form) return await oauth2Session .perform(request: builder) - .assessStatusCode( - success: { response in - guard let responseObject = try? JSONSerialization.jsonObject(with: response.body) else { - return nil - } + .mapUnacceptableStatusCodeError(Self.processError(response:body:)) + .mapSuccess { response in + guard let responseObject = try? JSONSerialization.jsonObject(with: response.body) else { + return nil + } - WPKitLogVerbose("Received OAuth2 response: \(self.cleanedUpResponseForLogging(responseObject as AnyObject? ?? "nil" as AnyObject))") + WPKitLogVerbose("Received OAuth2 response: \(self.cleanedUpResponseForLogging(responseObject as AnyObject? ?? "nil" as AnyObject))") - guard let responseDictionary = responseObject as? [String: AnyObject] else { - return nil - } + guard let responseDictionary = responseObject as? [String: AnyObject] else { + return nil + } - // If we found an access_token, we are authed. - if let authToken = responseDictionary["access_token"] as? String { - return .authenticated(token: authToken) - } + // If we found an access_token, we are authed. + if let authToken = responseDictionary["access_token"] as? String { + return .authenticated(token: authToken) + } - // If there is no access token, check for a security key nonce - guard let responseData = responseDictionary["data"] as? [String: AnyObject], - let userID = responseData["user_id"] as? Int, - let _ = responseData["two_step_nonce_webauthn"] else { - return nil - } + // If there is no access token, check for a security key nonce + guard let responseData = responseDictionary["data"] as? [String: AnyObject], + let userID = responseData["user_id"] as? Int, + let _ = responseData["two_step_nonce_webauthn"] else { + return nil + } - let nonceInfo = self.extractNonceInfo(data: responseData) + let nonceInfo = self.extractNonceInfo(data: responseData) - return .needsMultiFactor(userID: userID, nonceInfo: nonceInfo) - }, - failure: Self.processError(_:) - ) + return .needsMultiFactor(userID: userID, nonceInfo: nonceInfo) + } } /// Authenticates on WordPress.com using the OAuth endpoints. @@ -285,7 +283,8 @@ public final class WordPressComOAuthClient: NSObject { ]) return await oauth2Session .perform(request: builder) - .assessStatusCode(success: { _ in () }, failure: Self.processError(_:)) + .mapUnacceptableStatusCodeError(Self.processError(response:body:)) + .mapSuccess { _ in () } } /// Requests a One Time Code, to be sent via SMS. @@ -741,9 +740,9 @@ private extension WordPressComOAuthClient { .append(path: "/oauth2/token") } - static func processError(_ response: HTTPAPIResponse) -> AuthenticationFailure? { - guard [400, 409, 403].contains(response.response.statusCode), - let responseObject = try? JSONSerialization.jsonObject(with: response.body, options: .allowFragments), + static func processError(response: HTTPURLResponse, body: Data) -> AuthenticationFailure? { + guard [400, 409, 403].contains(response.statusCode), + let responseObject = try? JSONSerialization.jsonObject(with: body, options: .allowFragments), let responseDictionary = responseObject as? [String: AnyObject] else { return nil From a83f361719e570ae14632aa8c986d355181c7d8c Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 12 Jan 2024 09:47:05 +1300 Subject: [PATCH 8/8] Change a static function to initialiser --- WordPressKit/WordPressComOAuthClient.swift | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/WordPressKit/WordPressComOAuthClient.swift b/WordPressKit/WordPressComOAuthClient.swift index 1c8c5f1d..3a639640 100644 --- a/WordPressKit/WordPressComOAuthClient.swift +++ b/WordPressKit/WordPressComOAuthClient.swift @@ -47,6 +47,17 @@ public struct AuthenticationFailure: LocalizedError { public var newNonce: String? public var originalErrorJSON: [String: AnyObject] + init?(response: HTTPURLResponse, body: Data) { + guard [400, 409, 403].contains(response.statusCode), + let responseObject = try? JSONSerialization.jsonObject(with: body, options: .allowFragments), + let responseDictionary = responseObject as? [String: AnyObject] + else { + return nil + } + + self.init(apiJSONResponse: responseDictionary) + } + init(apiJSONResponse responseDict: [String: AnyObject]) { originalErrorJSON = responseDict @@ -203,7 +214,7 @@ public final class WordPressComOAuthClient: NSObject { let builder = tokenRequestBuilder().body(form: form) return await oauth2Session .perform(request: builder) - .mapUnacceptableStatusCodeError(Self.processError(response:body:)) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) .mapSuccess { response in guard let responseObject = try? JSONSerialization.jsonObject(with: response.body) else { return nil @@ -283,7 +294,7 @@ public final class WordPressComOAuthClient: NSObject { ]) return await oauth2Session .perform(request: builder) - .mapUnacceptableStatusCodeError(Self.processError(response:body:)) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) .mapSuccess { _ in () } } @@ -739,15 +750,4 @@ private extension WordPressComOAuthClient { .method(.post) .append(path: "/oauth2/token") } - - static func processError(response: HTTPURLResponse, body: Data) -> AuthenticationFailure? { - guard [400, 409, 403].contains(response.statusCode), - let responseObject = try? JSONSerialization.jsonObject(with: body, options: .allowFragments), - let responseDictionary = responseObject as? [String: AnyObject] - else { - return nil - } - - return .init(apiJSONResponse: responseDictionary) - } }