diff --git a/WooCommerce/Classes/Authentication/WebAuth/AuthenticationService.swift b/WooCommerce/Classes/Authentication/WebAuth/AuthenticationService.swift new file mode 100644 index 00000000000..f0713c67988 --- /dev/null +++ b/WooCommerce/Classes/Authentication/WebAuth/AuthenticationService.swift @@ -0,0 +1,222 @@ +import AutomatticTracks +import Foundation + +/// Full copy of the same file from WP-iOS +/// Couple of crash logging calls replaced with WC-iOS counterparts +/// Consider moving it to WordPressAuthenticator lib +/// https://github.com/wordpress-mobile/WordPress-iOS/blob/9b1e03b/WordPress/Classes/Services/AuthenticationService.swift + +class AuthenticationService { + + static let wpComLoginEndpoint = "https://wordpress.com/wp-login.php" + + enum RequestAuthCookieError: Error, LocalizedError { + case wpcomCookieNotReturned + + public var errorDescription: String? { + switch self { + case .wpcomCookieNotReturned: + return "Response to request for auth cookie for WP.com site failed to return cookie." + } + } + } + + // MARK: - Self Hosted + + func loadAuthCookiesForSelfHosted( + into cookieJar: CookieJar, + loginURL: URL, + username: String, + password: String, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + + cookieJar.hasWordPressSelfHostedAuthCookie(for: loginURL, username: username) { hasCookie in + guard !hasCookie else { + success() + return + } + + self.getAuthCookiesForSelfHosted(loginURL: loginURL, username: username, password: password, success: { cookies in + cookieJar.setCookies(cookies) { + success() + } + + cookieJar.hasWordPressSelfHostedAuthCookie(for: loginURL, username: username) { hasCookie in + print("Has cookie: \(hasCookie)") + } + }) { error in + // Make sure this error scenario isn't silently ignored. + ServiceLocator.crashLogging.logError(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + failure(error) + } + } + } + + func getAuthCookiesForSelfHosted( + loginURL: URL, + username: String, + password: String, + success: @escaping (_ cookies: [HTTPCookie]) -> Void, + failure: @escaping (Error) -> Void) { + + let headers = [String: String]() + let parameters = [ + "log": username, + "pwd": password, + "rememberme": "true" + ] + + requestAuthCookies( + from: loginURL, + headers: headers, + parameters: parameters, + success: success, + failure: failure) + } + + // MARK: - WP.com + + func loadAuthCookiesForWPCom( + into cookieJar: CookieJar, + username: String, + authToken: String, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + + cookieJar.hasWordPressComAuthCookie( + username: username, + atomicSite: false) { hasCookie in + + guard !hasCookie else { + // The stored cookie can be stale but we'll try to use it and refresh it if the request fails. + success() + return + } + + self.getAuthCookiesForWPCom(username: username, authToken: authToken, success: { cookies in + cookieJar.setCookies(cookies) { + + cookieJar.hasWordPressComAuthCookie(username: username, atomicSite: false) { hasCookie in + guard hasCookie else { + failure(RequestAuthCookieError.wpcomCookieNotReturned) + return + } + success() + } + + } + }) { error in + // Make sure this error scenario isn't silently ignored. + ServiceLocator.crashLogging.logError(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + failure(error) + } + } + } + + func getAuthCookiesForWPCom( + username: String, + authToken: String, + success: @escaping (_ cookies: [HTTPCookie]) -> Void, + failure: @escaping (Error) -> Void) { + + let loginURL = URL(string: AuthenticationService.wpComLoginEndpoint)! + let headers = [ + "Authorization": "Bearer \(authToken)" + ] + let parameters = [ + "log": username, + "rememberme": "true" + ] + + requestAuthCookies( + from: loginURL, + headers: headers, + parameters: parameters, + success: success, + failure: failure) + } + + // MARK: - Request Construction + + private func requestAuthCookies( + from url: URL, + headers: [String: String], + parameters: [String: String], + success: @escaping (_ cookies: [HTTPCookie]) -> Void, + failure: @escaping (Error) -> Void) { + + // We don't want these cookies persisted in other sessions + let session = URLSession(configuration: .ephemeral) + var request = URLRequest(url: url) + + request.httpMethod = "POST" + request.httpBody = body(withParameters: parameters) + + headers.forEach { (key, value) in + request.setValue(value, forHTTPHeaderField: key) + } +// request.setValue(WPUserAgent.wordPress(), forHTTPHeaderField: "User-Agent") + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + DispatchQueue.main.async { + failure(error) + } + return + } + + // The following code is a bit complicated to read, apologies. + // We're retrieving all cookies from the "Set-Cookie" header manually, and combining + // those cookies with the ones from the current session. The reason behind this is that + // iOS's URLSession processes the cookies from such header before this callback is executed, + // whereas OHTTPStubs.framework doesn't (the cookies are left in the header fields of + // the response). The only way to combine both is to just add them together here manually. + // + // To know if you can remove this, you'll have to test this code live and in our unit tests + // and compare the session cookies. + let responseCookies = self.cookies(from: response, loginURL: url) + let cookies = (session.configuration.httpCookieStorage?.cookies ?? [HTTPCookie]()) + responseCookies + DispatchQueue.main.async { + success(cookies) + } + } + + task.resume() + } + + private func body(withParameters parameters: [String: String]) -> Data? { + var queryItems = [URLQueryItem]() + + for parameter in parameters { + let queryItem = URLQueryItem(name: parameter.key, value: parameter.value) + queryItems.append(queryItem) + } + + var components = URLComponents() + components.queryItems = queryItems + + return components.percentEncodedQuery?.data(using: .utf8) + } + + // MARK: - Response Parsing + + private func cookies(from response: URLResponse?, loginURL: URL) -> [HTTPCookie] { + guard let httpResponse = response as? HTTPURLResponse, + let headers = httpResponse.allHeaderFields as? [String: String] else { + return [] + } + + return HTTPCookie.cookies(withResponseHeaderFields: headers, for: loginURL) + } +} diff --git a/WooCommerce/Classes/Authentication/WebAuth/CookieJar.swift b/WooCommerce/Classes/Authentication/WebAuth/CookieJar.swift new file mode 100644 index 00000000000..cc795cd5c35 --- /dev/null +++ b/WooCommerce/Classes/Authentication/WebAuth/CookieJar.swift @@ -0,0 +1,230 @@ +import Foundation +import WebKit + +/// Full copy of the same file from WP-iOS +/// Consider moving it to WordPressAuthenticator lib +/// https://github.com/wordpress-mobile/WordPress-iOS/blob/9b1e03b/WordPress/Classes/Utility/CookieJar.swift + +/// Provides a common interface to look for a logged-in WordPress cookie in different +/// cookie storage systems. +/// +@objc protocol CookieJar { + func getCookies(url: URL, completion: @escaping ([HTTPCookie]) -> Void) + func getCookies(completion: @escaping ([HTTPCookie]) -> Void) + func hasWordPressSelfHostedAuthCookie(for url: URL, username: String, completion: @escaping (Bool) -> Void) + func hasWordPressComAuthCookie(username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) + func removeCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) + func removeWordPressComCookies(completion: @escaping () -> Void) + func setCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) +} + +// As long as CookieJar is @objc, we can't have shared methods in protocol +// extensions, as it needs to be accessible to Obj-C. +// Whenever we migrate enough code so this doesn't need to be called from Swift, +// a regular CookieJar protocol with shared implementation on an extension would suffice. +// +// Also, although you're not supposed to use this outside this file, it can't be private +// since we're subclassing HTTPCookieStorage (which conforms to this) in MockCookieJar in +// the test target, and the swift compiler will crash when doing that ¯\_(ツ)_/¯ +// +// https://bugs.swift.org/browse/SR-2370 +// +protocol CookieJarSharedImplementation: CookieJar { +} + +extension CookieJarSharedImplementation { + func _hasWordPressComAuthCookie(username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) { + let url = URL(string: "https://wordpress.com/")! + + return _hasWordPressAuthCookie(for: url, username: username, atomicSite: atomicSite, completion: completion) + } + + func _hasWordPressAuthCookie(for url: URL, username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) { + getCookies(url: url) { (cookies) in + let cookie = cookies + .contains(where: { cookie in + return cookie.isWordPressLoggedIn(username: username, atomic: atomicSite) + }) + + completion(cookie) + } + } + + func _removeWordPressComCookies(completion: @escaping () -> Void) { + getCookies { [unowned self] (cookies) in + self.removeCookies(cookies.filter({ $0.domain.hasSuffix(".wordpress.com") }), completion: completion) + } + } +} + +extension HTTPCookieStorage: CookieJarSharedImplementation { + func getCookies(url: URL, completion: @escaping ([HTTPCookie]) -> Void) { + completion(cookies(for: url) ?? []) + } + + func getCookies(completion: @escaping ([HTTPCookie]) -> Void) { + completion(cookies ?? []) + } + + func hasWordPressComAuthCookie(username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) { + _hasWordPressComAuthCookie(username: username, atomicSite: atomicSite, completion: completion) + } + + func hasWordPressSelfHostedAuthCookie(for url: URL, username: String, completion: @escaping (Bool) -> Void) { + _hasWordPressAuthCookie(for: url, username: username, atomicSite: false, completion: completion) + } + + func removeCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) { + cookies.forEach(deleteCookie(_:)) + completion() + } + + func removeWordPressComCookies(completion: @escaping () -> Void) { + _removeWordPressComCookies(completion: completion) + } + + func setCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) { + for cookie in cookies { + setCookie(cookie) + } + + completion() + } +} + +extension WKHTTPCookieStore: CookieJarSharedImplementation { + func getCookies(url: URL, completion: @escaping ([HTTPCookie]) -> Void) { + + // This fixes an issue with `getAllCookies` not calling its completion block (related: https://stackoverflow.com/q/55565188) + // - adds timeout so the above failure will eventually return + // - waits for the cookies on a background thread so that: + // 1. we are not blocking the main thread for UI reasons + // 2. cookies seem to never load when main thread is blocked (perhaps they dispatch to the main thread later on) + + DispatchQueue.global(qos: .userInitiated).async { + let group = DispatchGroup() + group.enter() + + var urlCookies: [HTTPCookie] = [] + + DispatchQueue.main.async { + self.getAllCookies { (cookies) in + urlCookies = cookies.filter({ (cookie) in + return cookie.matches(url: url) + }) + group.leave() + } + } + + let result = group.wait(timeout: .now() + .seconds(2)) + if result == .timedOut { + DDLogWarn("Time out waiting for WKHTTPCookieStore to get cookies") + } + + DispatchQueue.main.async { + completion(urlCookies) + } + } + } + + func getCookies(completion: @escaping ([HTTPCookie]) -> Void) { + getAllCookies(completion) + } + + func hasWordPressComAuthCookie(username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) { + _hasWordPressComAuthCookie(username: username, atomicSite: atomicSite, completion: completion) + } + + func hasWordPressSelfHostedAuthCookie(for url: URL, username: String, completion: @escaping (Bool) -> Void) { + _hasWordPressAuthCookie(for: url, username: username, atomicSite: false, completion: completion) + } + + func removeCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) { + let group = DispatchGroup() + cookies + .forEach({ [unowned self] (cookie) in + group.enter() + self.delete(cookie, completionHandler: { + group.leave() + }) + }) + let result = group.wait(timeout: .now() + .seconds(2)) + if result == .timedOut { + DDLogWarn("Time out waiting for WKHTTPCookieStore to remove cookies") + } + completion() + } + + func removeWordPressComCookies(completion: @escaping () -> Void) { + _removeWordPressComCookies(completion: completion) + } + + func setCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) { + guard let cookie = cookies.last else { + return completion() + } + + DispatchQueue.main.async { + self.setCookie(cookie) { [weak self] in + self?.setCookies(cookies.dropLast(), completion: completion) + } + } + } +} + +#if DEBUG + func __removeAllWordPressComCookies() { + var jars = [CookieJarSharedImplementation]() + jars.append(HTTPCookieStorage.shared) + jars.append(WKWebsiteDataStore.default().httpCookieStore) + + let group = DispatchGroup() + jars.forEach({ jar in + group.enter() + jar.removeWordPressComCookies { + group.leave() + } + }) + _ = group.wait(timeout: .now() + .seconds(5)) + } +#endif + +private let atomicLoggedInCookieNamePrefix = "wordpress_logged_in_" +private let loggedInCookieName = "wordpress_logged_in" + +private extension HTTPCookie { + func isWordPressLoggedIn(username: String, atomic: Bool) -> Bool { + guard !atomic else { + return isWordPressLoggedInAtomic(username: username) + } + + return isWordPressLoggedIn(username: username) + } + + private func isWordPressLoggedIn(username: String) -> Bool { + return name.hasPrefix(loggedInCookieName) + && value.components(separatedBy: "%").first == username + } + + private func isWordPressLoggedInAtomic(username: String) -> Bool { + return name.hasPrefix(atomicLoggedInCookieNamePrefix) + && value.components(separatedBy: "|").first == username + } + + func matches(url: URL) -> Bool { + guard let host = url.host else { + return false + } + + let matchesDomain: Bool + if domain.hasPrefix(".") { + matchesDomain = host.hasSuffix(domain) + || host == domain.dropFirst() + } else { + matchesDomain = host == domain + } + return matchesDomain + && url.path.hasPrefix(path) + && (!isSecure || (url.scheme == "https")) + } +} diff --git a/WooCommerce/Classes/Authentication/WebAuth/RequestAuthenticator.swift b/WooCommerce/Classes/Authentication/WebAuth/RequestAuthenticator.swift new file mode 100644 index 00000000000..49fc8e2f245 --- /dev/null +++ b/WooCommerce/Classes/Authentication/WebAuth/RequestAuthenticator.swift @@ -0,0 +1,269 @@ +import AutomatticTracks +import struct Networking.Site + +/// Partial copy of the same file from WP-iOS +/// Trimmed down and adapted for WC +/// https://github.com/wordpress-mobile/WordPress-iOS/blob/9b1e03b/WordPress/Classes/Utility/Networking/RequestAuthenticator.swift + +/// Authenticator for requests to self-hosted sites, wp.com sites, including private +/// sites and atomic sites. +/// +class RequestAuthenticator: NSObject { + + enum DotComAuthenticationType { + case regular + case regularMapped(siteID: Int) + case atomic(loginURL: String) + case privateAtomic(blogID: Int) + } + + enum WPNavigationActionType { + case reload + case allow + } + + enum Credentials { + case dotCom(username: String, authToken: String, authenticationType: DotComAuthenticationType) + case siteLogin(loginURL: URL, username: String, password: String) + } + + fileprivate let credentials: Credentials + + // MARK: - Services + + private let authenticationService: AuthenticationService + + // MARK: - Initializers + + init(credentials: Credentials, authenticationService: AuthenticationService = AuthenticationService()) { + self.credentials = credentials + self.authenticationService = authenticationService + } + + convenience init?(site: Site, username: String, token: String) { + var authenticationType: DotComAuthenticationType = .regular + + if site.isWordPressStore { + authenticationType = .atomic(loginURL: site.loginURL) + // TODO: consider private atomic case + } + + self.init(credentials: .dotCom(username: username, authToken: token, authenticationType: authenticationType)) + } + + /// Potentially rewrites a request for authentication. + /// + /// This method will call the completion block with the request to be used. + /// + /// - Warning: On WordPress.com, this uses a special redirect system. It + /// requires the web view to call `interceptRedirect(request:)` before + /// loading any request. + /// + /// - Parameters: + /// - url: the URL to be loaded. + /// - cookieJar: a CookieJar object where the authenticator will look + /// for existing cookies. + /// - completion: this will be called with either the request for + /// authentication, or a request for the original URL. + /// + @objc func request(url: URL, cookieJar: CookieJar, completion: @escaping (URLRequest) -> Void) { + switch self.credentials { + case .dotCom(let username, let authToken, let authenticationType): + requestForWPCom( + url: url, + cookieJar: cookieJar, + username: username, + authToken: authToken, + authenticationType: authenticationType, + completion: completion) + case .siteLogin(let loginURL, let username, let password): + requestForSelfHosted( + url: url, + loginURL: loginURL, + cookieJar: cookieJar, + username: username, + password: password, + completion: completion) + } + } + + private func requestForWPCom(url: URL, + cookieJar: CookieJar, + username: String, + authToken: String, + authenticationType: DotComAuthenticationType, + completion: @escaping (URLRequest) -> Void) { + + switch authenticationType { + case .regular: + requestForWPCom( + url: url, + cookieJar: cookieJar, + username: username, + authToken: authToken, + completion: completion) + case .atomic(let loginURL): + requestForAtomicWPCom( + url: url, + loginURL: loginURL, + cookieJar: cookieJar, + username: username, + authToken: authToken, + completion: completion) + case .regularMapped, .privateAtomic: + // not supported + return + } + } + + private func requestForSelfHosted(url: URL, + loginURL: URL, + cookieJar: CookieJar, + username: String, + password: String, + completion: @escaping (URLRequest) -> Void) { + + func done() { + let request = URLRequest(url: url) + completion(request) + } + + authenticationService.loadAuthCookiesForSelfHosted(into: cookieJar, loginURL: loginURL, username: username, password: password, success: { + done() + }) { [weak self] error in + // Make sure this error scenario isn't silently ignored. + self?.logErrorIfNeeded(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + done() + } + } + + private func requestForAtomicWPCom(url: URL, + loginURL: String, + cookieJar: CookieJar, + username: String, + authToken: String, + completion: @escaping (URLRequest) -> Void) { + + func done() { + // For non-private Atomic sites, proxy the request through wp-login like Calypso does. + // If the site has SSO enabled auth should happen and we get redirected to our preview. + // If SSO is not enabled wp-admin prompts for credentials, then redirected. + var components = URLComponents(string: loginURL) + var queryItems = components?.queryItems ?? [] + queryItems.append(URLQueryItem(name: "redirect_to", value: url.absoluteString)) + components?.queryItems = queryItems + let requestURL = components?.url ?? url + + let request = URLRequest(url: requestURL) + completion(request) + } + + authenticationService.loadAuthCookiesForWPCom(into: cookieJar, username: username, authToken: authToken, success: { + done() + }) { [weak self] error in + // Make sure this error scenario isn't silently ignored. + self?.logErrorIfNeeded(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + done() + } + } + + private func requestForWPCom(url: URL, cookieJar: CookieJar, username: String, authToken: String, completion: @escaping (URLRequest) -> Void) { + + func done() { + let request = URLRequest(url: url) + completion(request) + } + + authenticationService.loadAuthCookiesForWPCom(into: cookieJar, username: username, authToken: authToken, success: { + done() + }) { [weak self] error in + // Make sure this error scenario isn't silently ignored. + self?.logErrorIfNeeded(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + done() + } + } + + private func logErrorIfNeeded(_ error: Swift.Error) { + let nsError = error as NSError + + switch nsError.code { + case NSURLErrorTimedOut, NSURLErrorNotConnectedToInternet: + return + default: + ServiceLocator.crashLogging.logError(error) + } + } +} + +private extension RequestAuthenticator { + static let wordPressComLoginUrl = URL(string: "https://wordpress.com/wp-login.php")! +} + +extension RequestAuthenticator { + func isLogin(url: URL) -> Bool { + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.queryItems = nil + + return components?.url == RequestAuthenticator.wordPressComLoginUrl + } +} + +// MARK: Navigation Validator +extension RequestAuthenticator { + /// Validates that the navigation worked as expected then provides a recommendation on if the screen should reload or not. + func decideActionFor(response: URLResponse, cookieJar: CookieJar, completion: @escaping (WPNavigationActionType) -> Void) { + switch self.credentials { + case .dotCom(let username, _, let authenticationType): + decideActionForWPCom(response: response, cookieJar: cookieJar, username: username, authenticationType: authenticationType, completion: completion) + case .siteLogin: + completion(.allow) + } + } + + private func decideActionForWPCom(response: URLResponse, + cookieJar: CookieJar, + username: String, + authenticationType: DotComAuthenticationType, + completion: @escaping (WPNavigationActionType) -> Void) { + + guard didEncouterRecoverableChallenge(response) else { + completion(.allow) + return + } + + cookieJar.removeWordPressComCookies { + completion(.reload) + } + } + + private func didEncouterRecoverableChallenge(_ response: URLResponse) -> Bool { + guard let url = response.url?.absoluteString else { + return false + } + + if url.contains("r-login.wordpress.com") || url.contains("wordpress.com/log-in?") { + return true + } + + guard let statusCode = (response as? HTTPURLResponse)?.statusCode else { + return false + } + + return 400 <= statusCode && statusCode < 500 + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 17ba406748f..3b47d1f0d97 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1159,6 +1159,13 @@ AEA622B427466B78002A9B57 /* BottomSheetOrderType.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA622B327466B78002A9B57 /* BottomSheetOrderType.swift */; }; AEA622B727468790002A9B57 /* AddOrderCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA622B627468790002A9B57 /* AddOrderCoordinatorTests.swift */; }; AEACCB6D2785FF4A000D01F0 /* NavigationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEACCB6C2785FF4A000D01F0 /* NavigationRow.swift */; }; + AEB4DB97290AE74B00AE4340 /* CookieJar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB4DB96290AE74B00AE4340 /* CookieJar.swift */; }; + AEB4DB99290AE8F300AE4340 /* MockCookieJar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB4DB98290AE8F300AE4340 /* MockCookieJar.swift */; }; + AEB4DB9B290AE92800AE4340 /* CookieJarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB4DB9A290AE92800AE4340 /* CookieJarTests.swift */; }; + AEB4DB9D290AE94600AE4340 /* WKCookieJarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB4DB9C290AE94600AE4340 /* WKCookieJarTests.swift */; }; + AEB4DB9F290AEA6100AE4340 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB4DB9E290AEA6100AE4340 /* AuthenticationService.swift */; }; + AEB4DBA1290AEC0D00AE4340 /* RequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB4DBA0290AEC0D00AE4340 /* RequestAuthenticator.swift */; }; + AEB4DBA3290AEDB900AE4340 /* RequestAuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB4DBA2290AEDB900AE4340 /* RequestAuthenticatorTests.swift */; }; AEB73C0C25CD734200A8454A /* AttributePickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB73C0B25CD734200A8454A /* AttributePickerViewModel.swift */; }; AEB73C1725CD8E5800A8454A /* AttributePickerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB73C1625CD8E5800A8454A /* AttributePickerViewModelTests.swift */; }; AEBFD13F28E7655F00F598C6 /* StoreInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEBFD13E28E7655F00F598C6 /* StoreInfoView.swift */; }; @@ -3069,6 +3076,13 @@ AEA622B327466B78002A9B57 /* BottomSheetOrderType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetOrderType.swift; sourceTree = ""; }; AEA622B627468790002A9B57 /* AddOrderCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddOrderCoordinatorTests.swift; sourceTree = ""; }; AEACCB6C2785FF4A000D01F0 /* NavigationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRow.swift; sourceTree = ""; }; + AEB4DB96290AE74B00AE4340 /* CookieJar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieJar.swift; sourceTree = ""; }; + AEB4DB98290AE8F300AE4340 /* MockCookieJar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCookieJar.swift; sourceTree = ""; }; + AEB4DB9A290AE92800AE4340 /* CookieJarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieJarTests.swift; sourceTree = ""; }; + AEB4DB9C290AE94600AE4340 /* WKCookieJarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKCookieJarTests.swift; sourceTree = ""; }; + AEB4DB9E290AEA6100AE4340 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; + AEB4DBA0290AEC0D00AE4340 /* RequestAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestAuthenticator.swift; sourceTree = ""; }; + AEB4DBA2290AEDB900AE4340 /* RequestAuthenticatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestAuthenticatorTests.swift; sourceTree = ""; }; AEB73C0B25CD734200A8454A /* AttributePickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributePickerViewModel.swift; sourceTree = ""; }; AEB73C1625CD8E5800A8454A /* AttributePickerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributePickerViewModelTests.swift; sourceTree = ""; }; AEBFD13E28E7655F00F598C6 /* StoreInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInfoView.swift; sourceTree = ""; }; @@ -6278,6 +6292,7 @@ B958A7D228B52A2300823EEF /* MockRoute.swift */, B958A7D728B5316A00823EEF /* MockURLOpener.swift */, EE8DCA7F28BF964700F23B23 /* MockAuthentication.swift */, + AEB4DB98290AE8F300AE4340 /* MockCookieJar.swift */, ); path = Mocks; sourceTree = ""; @@ -6525,6 +6540,16 @@ path = CustomerSection; sourceTree = ""; }; + AEB4DB95290AE72D00AE4340 /* WebAuth */ = { + isa = PBXGroup; + children = ( + AEB4DB96290AE74B00AE4340 /* CookieJar.swift */, + AEB4DB9E290AEA6100AE4340 /* AuthenticationService.swift */, + AEB4DBA0290AEC0D00AE4340 /* RequestAuthenticator.swift */, + ); + path = WebAuth; + sourceTree = ""; + }; AEB73C1525CD8E3100A8454A /* Edit Product Variation */ = { isa = PBXGroup; children = ( @@ -6634,6 +6659,9 @@ 4590B651261C8D1E00A6FCE0 /* WeightFormatterTests.swift */, B96B536A2816ECFC00F753E6 /* CardPresentPluginsDataProviderTests.swift */, EEADF625281A65A9001B40F1 /* DefaultShippingValueLocalizerTests.swift */, + AEB4DB9A290AE92800AE4340 /* CookieJarTests.swift */, + AEB4DB9C290AE94600AE4340 /* WKCookieJarTests.swift */, + AEB4DBA2290AEDB900AE4340 /* RequestAuthenticatorTests.swift */, ); path = Tools; sourceTree = ""; @@ -6687,6 +6715,7 @@ B55D4C0420B6026700D7A50F /* Authentication */ = { isa = PBXGroup; children = ( + AEB4DB95290AE72D00AE4340 /* WebAuth */, D881A318256B5C9C00FE5605 /* Navigation Exceptions */, B5A8F8AB20B88D8400D211DE /* Prologue */, B5D1AFC420BC7B3000DB0E8C /* Epilogue */, @@ -9837,6 +9866,7 @@ D8D15F83230A17A000D48B3F /* ServiceLocator.swift in Sources */, 317F679826420E9D00BA2A7A /* CardReaderSettingsViewModelsOrderedList.swift in Sources */, DE279BA426E9C4DC002BA963 /* ShippingLabelPackagesForm.swift in Sources */, + AEB4DB9F290AEA6100AE4340 /* AuthenticationService.swift in Sources */, CE583A0421076C0100D73C1C /* NewNoteViewController.swift in Sources */, CECC759723D607C900486676 /* OrderItemRefund+Woo.swift in Sources */, 03EF250228C615A5006A033E /* InPersonPaymentsMenuViewModel.swift in Sources */, @@ -10102,6 +10132,7 @@ B54FBE552111F70700390F57 /* ResultsController+UIKit.swift in Sources */, CE2409F1215D12D30091F887 /* WooNavigationController.swift in Sources */, 0294F8AB25E8A12C005B537A /* WooTabNavigationController.swift in Sources */, + AEB4DBA1290AEC0D00AE4340 /* RequestAuthenticator.swift in Sources */, 029F29FA24D93E9E004751CA /* EditableProductModel.swift in Sources */, 31FC8CE927B476BA004B9456 /* CardReaderSettingsResultsControllers.swift in Sources */, D449C52926DFBCCC00D75B02 /* WhatsNewHostingController.swift in Sources */, @@ -10178,6 +10209,7 @@ 45C8B2582313FA570002FA77 /* CustomerNoteTableViewCell.swift in Sources */, 02C3FACE282A93020095440A /* WooAnalyticsEvent+Dashboard.swift in Sources */, DE67D46726B98FD000EFE8DB /* Publisher+WithLatestFrom.swift in Sources */, + AEB4DB97290AE74B00AE4340 /* CookieJar.swift in Sources */, 7493BB8E2149852A003071A9 /* TopPerformersHeaderView.swift in Sources */, D88CA756237CE515005D2F44 /* UITabBar+Appearance.swift in Sources */, 45A8DA402664E40B00308FBE /* EmptyState.swift in Sources */, @@ -10592,6 +10624,7 @@ 02AB40822784297C00929CF3 /* ProductTableViewCellViewModelTests.swift in Sources */, 02829BAA288FA8B300951E1E /* MockUserNotification.swift in Sources */, D85B8336222FCDA1002168F3 /* StatusListTableViewCellTests.swift in Sources */, + AEB4DBA3290AEDB900AE4340 /* RequestAuthenticatorTests.swift in Sources */, 3198A1E82694DC7200597213 /* MockKnownReadersProvider.swift in Sources */, DEC51B04276B30F6009F3DF4 /* SystemStatusReportViewModelTests.swift in Sources */, 26B119C224D1CD3500FED5C7 /* WooConstantsTests.swift in Sources */, @@ -10787,6 +10820,7 @@ CCB366AF274518EC007D437A /* EditableOrderViewModelTests.swift in Sources */, 020BE76B23B4A380007FE54C /* AztecUnderlineFormatBarCommandTests.swift in Sources */, D83F5937225B402E00626E75 /* TitleAndEditableValueTableViewCellTests.swift in Sources */, + AEB4DB9D290AE94600AE4340 /* WKCookieJarTests.swift in Sources */, 773077F3251E954300178696 /* ProductDownloadFileViewModelTests.swift in Sources */, 2602A64A27BDC80200B347F1 /* RemoteOrderSynchronizerTests.swift in Sources */, DE4B3B2E269455D400EEF2D8 /* MockShipmentActionStoresManager.swift in Sources */, @@ -10820,6 +10854,7 @@ 4569D3F425DC1BFF00CDC3E2 /* ShippingLabelFormViewModelTests.swift in Sources */, 57F2C6CD246DECC10074063B /* SummaryTableViewCellViewModelTests.swift in Sources */, 03EF250028C0E9EE006A033E /* InPersonPaymentsCashOnDeliveryToggleRowViewModelTests.swift in Sources */, + AEB4DB99290AE8F300AE4340 /* MockCookieJar.swift in Sources */, 02A275C423FE5B64005C560F /* MockPHAssetImageLoader.swift in Sources */, 456738972743DE9A00743054 /* OrderDateRangeFilterTests.swift in Sources */, A650BE872578E76600C655E0 /* MockStorageManager.swift in Sources */, @@ -10895,6 +10930,7 @@ 02ECD1E124FF496200735BE5 /* PaginationTrackerTests.swift in Sources */, 45E9A6EB24DAFC3E00A600E8 /* ProductReviewsViewModelTests.swift in Sources */, 268EC46426D3F9C100716F5C /* EditCustomerNoteViewModelTests.swift in Sources */, + AEB4DB9B290AE92800AE4340 /* CookieJarTests.swift in Sources */, 453770D12431FF4700AC718D /* ProductSettingsViewModelTests.swift in Sources */, 2619FA2C25C897930006DAFF /* AddAttributeOptionsViewModelTests.swift in Sources */, 020BE77523B4A7EC007FE54C /* AztecSourceCodeFormatBarCommandTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Mocks/MockCookieJar.swift b/WooCommerce/WooCommerceTests/Mocks/MockCookieJar.swift new file mode 100644 index 00000000000..bf13b9dcdb9 --- /dev/null +++ b/WooCommerce/WooCommerceTests/Mocks/MockCookieJar.swift @@ -0,0 +1,60 @@ +import Foundation +import WooCommerce +import WebKit + +class MockCookieJar: HTTPCookieStorage { + var _cookies = [HTTPCookie]() + + override func cookies(for URL: URL) -> [HTTPCookie]? { + return _cookies + } + + override var cookies: [HTTPCookie]? { + return _cookies + } + + override func deleteCookie(_ cookie: HTTPCookie) { + if let index = _cookies.firstIndex(of: cookie) { + _cookies.remove(at: index) + } + } + + override func setCookie(_ cookie: HTTPCookie) { + guard !_cookies.contains(cookie) else { + return + } + _cookies.append(cookie) + } +} + +fileprivate func wordPressCookie(username: String, domain: String) -> HTTPCookie { + return HTTPCookie(properties: [ + .domain: domain, + .path: "/", + .secure: true, + .name: "wordpress_logged_in", + .value: "\(username)%00000" + ])! +} + +extension HTTPCookieStorage { + func setWordPressCookie(username: String, domain: String) { + let cookie = wordPressCookie(username: username, domain: domain) + setCookie(cookie) + } + + func setWordPressComCookie(username: String) { + setWordPressCookie(username: username, domain: ".wordpress.com") + } +} + +extension WKHTTPCookieStore { + func setWordPressCookie(username: String, domain: String) { + let cookie = wordPressCookie(username: username, domain: domain) + setCookie(cookie) + } + + func setWordPressComCookie(username: String) { + setWordPressCookie(username: username, domain: ".wordpress.com") + } +} diff --git a/WooCommerce/WooCommerceTests/Tools/CookieJarTests.swift b/WooCommerce/WooCommerceTests/Tools/CookieJarTests.swift new file mode 100644 index 00000000000..2ad17df2cf8 --- /dev/null +++ b/WooCommerce/WooCommerceTests/Tools/CookieJarTests.swift @@ -0,0 +1,67 @@ +import XCTest +import WebKit +@testable import WooCommerce + +class CookieJarTests: XCTestCase { + var mockCookieJar = MockCookieJar() + var cookieJar: CookieJar { + return mockCookieJar + } + let wordPressComLoginURL = URL(string: "https://wordpress.com/wp-login.php")! + + override func setUp() { + super.setUp() + mockCookieJar = MockCookieJar() + } + + func testGetCookies() { + addCookies() + + let expectation = self.expectation(description: "getCookies completion called") + cookieJar.getCookies(url: wordPressComLoginURL) { (cookies) in + XCTAssertEqual(cookies.count, 2) + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + } + + func testHasCookieMatching() { + addCookies() + + let expectation = self.expectation(description: "hasCookie completion called") + cookieJar.hasWordPressComAuthCookie(username: "testuser", atomicSite: false) { (matches) in + XCTAssertTrue(matches) + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + } + func testHasCookieNotMatching() { + addCookies() + + let expectation = self.expectation(description: "hasCookie completion called") + cookieJar.hasWordPressComAuthCookie(username: "anotheruser", atomicSite: false) { (matches) in + XCTAssertFalse(matches) + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + } + + func testRemoveCookies() { + addCookies() + + let expectation = self.expectation(description: "removeCookies completion called") + cookieJar.removeWordPressComCookies { [mockCookieJar] in + XCTAssertEqual(mockCookieJar.cookies?.count, 1) + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + } +} + +private extension CookieJarTests { + func addCookies() { + mockCookieJar.setWordPressComCookie(username: "testuser") + mockCookieJar.setWordPressCookie(username: "testuser", domain: "example.com") + } +} diff --git a/WooCommerce/WooCommerceTests/Tools/RequestAuthenticatorTests.swift b/WooCommerce/WooCommerceTests/Tools/RequestAuthenticatorTests.swift new file mode 100644 index 00000000000..0db1811f60f --- /dev/null +++ b/WooCommerce/WooCommerceTests/Tools/RequestAuthenticatorTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import WooCommerce + +private extension URLRequest { + var httpBodyString: String? { + return httpBody.flatMap({ data in + return String(data: data, encoding: .utf8) + }) + } +} + +class RequestAuthenticatorTests: XCTestCase { + let dotComLoginURL = URL(string: "https://wordpress.com/wp-login.php")! + let dotComUser = "comuser" + let dotComToken = "comtoken" + let siteLoginURL = URL(string: "https://example.com/wp-login.php")! + let siteUser = "siteuser" + let sitePassword = "x>73R9&9;r&ju9$J499FmZ?2*Nii/?$8" + let sitePasswordEncoded = "x%3E73R9%269;r%26ju9$J499FmZ?2*Nii/?$8" + + var dotComAuthenticator: RequestAuthenticator { + return RequestAuthenticator(credentials: .dotCom(username: dotComUser, authToken: dotComToken, authenticationType: .regular)) + } + + var siteAuthenticator: RequestAuthenticator { + return RequestAuthenticator( + credentials: .siteLogin(loginURL: siteLoginURL, username: siteUser, password: sitePassword)) + } + + func testUnauthenticatedDotComRequestWithCookie() { + let url = URL(string: "https://example.wordpress.com/some-page/")! + let authenticator = dotComAuthenticator + + let cookieJar = MockCookieJar() + cookieJar.setWordPressComCookie(username: dotComUser) + var authenticatedRequest: URLRequest? = nil + authenticator.request(url: url, cookieJar: cookieJar) { + authenticatedRequest = $0 + } + guard let request = authenticatedRequest else { + XCTFail("The authenticator should return a valid request") + return + } + XCTAssertEqual(request.url, url) + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + } + + func testDecideActionForNavigationResponse() { + let url = URL(string: "https://example.wordpress.com/some-page/")! + let authenticator = dotComAuthenticator + let cookieJar = MockCookieJar() + cookieJar.setWordPressComCookie(username: dotComUser) + + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + + let expectation = self.expectation(description: "Action Should be decided") + authenticator.decideActionFor(response: response, cookieJar: cookieJar) { action in + XCTAssertEqual(action, RequestAuthenticator.WPNavigationActionType.allow) + expectation.fulfill() + } + + waitForExpectations(timeout: 0.2) + } + + func testDecideActionForNavigationResponse_RemoteLoginError() { + let url = URL(string: "https://r-login.wordpress.com/remote-login.php?action=auth")! + let authenticator = dotComAuthenticator + let cookieJar = MockCookieJar() + cookieJar.setWordPressComCookie(username: dotComUser) + + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + + let expectation = self.expectation(description: "Action Should be decided") + authenticator.decideActionFor(response: response, cookieJar: cookieJar) { action in + XCTAssertEqual(action, RequestAuthenticator.WPNavigationActionType.reload) + expectation.fulfill() + } + + waitForExpectations(timeout: 0.2) + } + + func testDecideActionForNavigationResponse_ClientError() { + let url = URL(string: "https://example.wordpress.com/some-page/")! + let authenticator = dotComAuthenticator + let cookieJar = MockCookieJar() + cookieJar.setWordPressComCookie(username: dotComUser) + + let response = HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)! + + let expectation = self.expectation(description: "Action Should be decided") + authenticator.decideActionFor(response: response, cookieJar: cookieJar) { action in + XCTAssertEqual(action, RequestAuthenticator.WPNavigationActionType.reload) + expectation.fulfill() + } + + waitForExpectations(timeout: 0.2) + } +} diff --git a/WooCommerce/WooCommerceTests/Tools/WKCookieJarTests.swift b/WooCommerce/WooCommerceTests/Tools/WKCookieJarTests.swift new file mode 100644 index 00000000000..424c859e2a0 --- /dev/null +++ b/WooCommerce/WooCommerceTests/Tools/WKCookieJarTests.swift @@ -0,0 +1,66 @@ +import XCTest +import WebKit +@testable import WooCommerce + +class WKCookieJarTests: XCTestCase { + var wkCookieStore = WKWebsiteDataStore.nonPersistent().httpCookieStore + var cookieJar: CookieJar { + return wkCookieStore + } + let wordPressComLoginURL = URL(string: "https://wordpress.com/wp-login.php")! + + override func setUp() { + super.setUp() + wkCookieStore = WKWebsiteDataStore.nonPersistent().httpCookieStore + addCookies() + } + + override func tearDown() { + super.tearDown() + } + + func testGetCookies() { + let expectation = self.expectation(description: "getCookies completion called") + cookieJar.getCookies(url: wordPressComLoginURL) { (cookies) in + XCTAssertEqual(cookies.count, 1, "Should be one cookie for wordpress.com") + expectation.fulfill() + } + waitForExpectations(timeout: 5, handler: nil) + } + + func testHasCookieMatching() { + let expectation = self.expectation(description: "hasCookie completion called") + cookieJar.hasWordPressComAuthCookie(username: "testuser", atomicSite: false) { (matches) in + XCTAssertTrue(matches, "Cookies should exist for wordpress.com + testuser") + expectation.fulfill() + } + waitForExpectations(timeout: 5, handler: nil) + + } + func testHasCookieNotMatching() { + let expectation = self.expectation(description: "hasCookie completion called") + cookieJar.hasWordPressComAuthCookie(username: "anotheruser", atomicSite: false) { (matches) in + XCTAssertFalse(matches, "Cookies should not exist for wordpress.com + anotheruser") + expectation.fulfill() + } + waitForExpectations(timeout: 5, handler: nil) + } + + func testRemoveCookies() { + let expectation = self.expectation(description: "removeCookies completion called") + cookieJar.removeWordPressComCookies { [wkCookieStore] in + wkCookieStore.getAllCookies { cookies in + XCTAssertEqual(cookies.count, 1) + expectation.fulfill() + } + } + waitForExpectations(timeout: 5, handler: nil) + } +} + +private extension WKCookieJarTests { + func addCookies() { + wkCookieStore.setWordPressComCookie(username: "testuser") + wkCookieStore.setWordPressCookie(username: "testuser", domain: "example.com") + } +}