diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 3a6f1fd8b..61b586995 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ 5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA48227B497600D19003 /* FeedbinAPICaller.swift */; }; 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */; }; 5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */; }; + 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */; }; + 515E4EB62324FF8C0057B0E7 /* URLRequest+RSWeb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */; }; + 515E4EB72324FF8C0057B0E7 /* Credentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB42324FF8C0057B0E7 /* Credentials.swift */; }; 5165D7122282080C00D9D53D /* AccountFolderContentsSyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D7112282080C00D9D53D /* AccountFolderContentsSyncTest.swift */; }; 5165D71622821C2400D9D53D /* taggings_delete.json in Resources */ = {isa = PBXBuildFile; fileRef = 5165D71322821C2400D9D53D /* taggings_delete.json */; }; 5165D71722821C2400D9D53D /* taggings_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 5165D71422821C2400D9D53D /* taggings_add.json */; }; @@ -126,6 +129,9 @@ 5144EA48227B497600D19003 /* FeedbinAPICaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAPICaller.swift; sourceTree = ""; }; 5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAccountDelegate.swift; sourceTree = ""; }; 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinImportResult.swift; sourceTree = ""; }; + 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CredentialsManager.swift; sourceTree = ""; }; + 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLRequest+RSWeb.swift"; sourceTree = ""; }; + 515E4EB42324FF8C0057B0E7 /* Credentials.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Credentials.swift; sourceTree = ""; }; 5165D7112282080C00D9D53D /* AccountFolderContentsSyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFolderContentsSyncTest.swift; sourceTree = ""; }; 5165D71322821C2400D9D53D /* taggings_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = taggings_delete.json; sourceTree = ""; }; 5165D71422821C2400D9D53D /* taggings_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = taggings_add.json; sourceTree = ""; }; @@ -210,6 +216,16 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 515E4EB12324FF7D0057B0E7 /* Credentials */ = { + isa = PBXGroup; + children = ( + 515E4EB42324FF8C0057B0E7 /* Credentials.swift */, + 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */, + 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */, + ); + path = Credentials; + sourceTree = ""; + }; 5165D71F22835E9800D9D53D /* FeedFinder */ = { isa = PBXGroup; children = ( @@ -328,6 +344,7 @@ 841974001F6DD1EC006346C4 /* Folder.swift */, 844B297E210CE37E004020B3 /* UnreadCountProvider.swift */, 5165D71F22835E9800D9D53D /* FeedFinder */, + 515E4EB12324FF7D0057B0E7 /* Credentials */, 8419742B1F6DDE84006346C4 /* LocalAccount */, 84245C7D1FDDD2580074AFBB /* Feedbin */, 552032EA229D5D5A009559E0 /* ReaderAPI */, @@ -555,9 +572,11 @@ 841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */, 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */, 846E77541F6F00E300A165E2 /* AccountManager.swift in Sources */, + 515E4EB72324FF8C0057B0E7 /* Credentials.swift in Sources */, 51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */, 5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */, 844B297D2106C7EC004020B3 /* Feed.swift in Sources */, + 515E4EB62324FF8C0057B0E7 /* URLRequest+RSWeb.swift in Sources */, 5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */, 84B2D4D02238CD8A00498ADA /* FeedMetadata.swift in Sources */, 5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */, @@ -578,6 +597,7 @@ 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */, 841974011F6DD1EC006346C4 /* Folder.swift in Sources */, 846E774F1F6EF9C000A165E2 /* LocalAccountDelegate.swift in Sources */, + 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */, 844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */, 84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */, 84245C851FDDD8CB0074AFBB /* FeedbinSubscription.swift in Sources */, diff --git a/Frameworks/Account/Credentials/Credentials.swift b/Frameworks/Account/Credentials/Credentials.swift new file mode 100644 index 000000000..9ce2c6651 --- /dev/null +++ b/Frameworks/Account/Credentials/Credentials.swift @@ -0,0 +1,23 @@ +// +// Credentials.swift +// NetNewsWire +// +// Created by Brent Simmons on 12/9/17. +// Copyright © 2017 Ranchero Software. All rights reserved. +// + +import Foundation + +public enum CredentialsError: Error { + case incompleteCredentials + case unhandledError(status: OSStatus) +} + +public enum Credentials { + case basic(username: String, password: String) + case readerAPIBasicLogin(username: String, password: String) + case readerAPIAuthLogin(username: String, apiKey: String) + case oauthAccessToken(username: String, token: String) + case oauthRefreshToken(username: String, token: String) +} + diff --git a/Frameworks/Account/Credentials/CredentialsManager.swift b/Frameworks/Account/Credentials/CredentialsManager.swift new file mode 100644 index 000000000..136aef279 --- /dev/null +++ b/Frameworks/Account/Credentials/CredentialsManager.swift @@ -0,0 +1,161 @@ +// +// CredentialsManager.swift +// NetNewsWire +// +// Created by Maurice Parker on 5/5/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Foundation + +public struct CredentialsManager { + + public static func storeCredentials(_ credentials: Credentials, server: String) throws { + + switch credentials { + case .basic(let username, let password): + try storeBasicCredentials(server: server, username: username, password: password) + case .readerAPIBasicLogin(let username, let password): + try storeBasicCredentials(server: server, username: username, password: password) + case .readerAPIAuthLogin(let username, let apiKey): + try storeBasicCredentials(server: server, username: username, password: apiKey) + case .oauthAccessToken(let username, let token): + try storeBasicCredentials(server: server, username: username, password: token) + case .oauthRefreshToken(let username, let token): + try storeBasicCredentials(server: server, username: username, password: token) + } + + } + + public static func retrieveBasicCredentials(server: String, username: String) throws -> Credentials? { + + let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword, + kSecAttrAccount as String: username, + kSecAttrServer as String: server, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status != errSecItemNotFound else { + return nil + } + + guard status == errSecSuccess else { + throw CredentialsError.unhandledError(status: status) + } + + guard let existingItem = item as? [String : Any], + let passwordData = existingItem[kSecValueData as String] as? Data, + let password = String(data: passwordData, encoding: String.Encoding.utf8) else { + return nil + } + + return Credentials.basic(username: username, password: password) + + } + + public static func removeBasicCredentials(server: String, username: String) throws { + + let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword, + kSecAttrAccount as String: username, + kSecAttrServer as String: server, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw CredentialsError.unhandledError(status: status) + } + + } + + public static func retrieveReaderAPIAuthCredentials(server: String, username: String) throws -> Credentials? { + + let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword, + kSecAttrAccount as String: username, + kSecAttrServer as String: server, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status != errSecItemNotFound else { + return nil + } + + guard status == errSecSuccess else { + throw CredentialsError.unhandledError(status: status) + } + + guard let existingItem = item as? [String : Any], + let passwordData = existingItem[kSecValueData as String] as? Data, + let password = String(data: passwordData, encoding: String.Encoding.utf8) else { + return nil + } + + return Credentials.readerAPIAuthLogin(username: username, apiKey: password) + + } + + public static func removeReaderAPIAuthCredentials(server: String, username: String) throws { + + let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword, + kSecAttrAccount as String: username, + kSecAttrServer as String: server, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw CredentialsError.unhandledError(status: status) + } + + } +} + +// MARK: Private + +extension CredentialsManager { + + static func storeBasicCredentials(server: String, username: String, password: String) throws { + + let passwordData = password.data(using: String.Encoding.utf8)! + + let updateQuery: [String: Any] = [kSecClass as String: kSecClassInternetPassword, + kSecAttrAccount as String: username, + kSecAttrServer as String: server] + let attributes: [String: Any] = [kSecValueData as String: passwordData] + let status = SecItemUpdate(updateQuery as CFDictionary, attributes as CFDictionary) + + switch status { + case errSecSuccess: + return + case errSecItemNotFound: + break + default: + throw CredentialsError.unhandledError(status: status) + } + + guard status == errSecItemNotFound else { + return + } + + let addQuery: [String: Any] = [kSecClass as String: kSecClassInternetPassword, + kSecAttrAccount as String: username, + kSecAttrServer as String: server, + kSecValueData as String: passwordData] + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + if addStatus != errSecSuccess { + throw CredentialsError.unhandledError(status: status) + } + + } + +} diff --git a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift new file mode 100755 index 000000000..534b05e7c --- /dev/null +++ b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift @@ -0,0 +1,64 @@ +// +// URLRequest+RSWeb.swift +// NetNewsWire +// +// Created by Brent Simmons on 12/27/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSWeb + +public extension URLRequest { + + init(url: URL, credentials: Credentials?, conditionalGet: HTTPConditionalGetInfo? = nil) { + + self.init(url: url) + + guard let credentials = credentials else { + return + } + + switch credentials { + case .basic(let username, let password): + let data = "\(username):\(password)".data(using: .utf8) + let base64 = data?.base64EncodedString() + let auth = "Basic \(base64 ?? "")" + setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization) + case .readerAPIBasicLogin(let username, let password): + setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + httpMethod = "POST" + let postData = "Email=\(username)&Passwd=\(password)" + httpBody = postData.data(using: String.Encoding.utf8) + case .readerAPIAuthLogin(_, let apiKey): + let auth = "GoogleLogin auth=\(apiKey)" + setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization) + case .oauthAccessToken(_, let token): + let auth = "OAuth \(token)" + setValue(auth, forHTTPHeaderField: "Authorization") + case .oauthRefreshToken: + // While both access and refresh tokens are credentials, it seems the `Credentials` cases + // enumerates how the identity of the user can be proved rather than + // credentials-in-general, such as in this refresh token case, + // the authority to prove an identity. + // TODO: Refactor as usage becomes clearer. + assertionFailure("Refresh tokens are used to replace expired access tokens. Did you mean to use `accessToken` instead?") + break + } + + guard let conditionalGet = conditionalGet else { + return + } + + // Bug seen in the wild: lastModified with last possible 32-bit date, which is in 2038. Ignore those. + // TODO: drop this check in late 2037. + if let lastModified = conditionalGet.lastModified, !lastModified.contains("2038") { + setValue(lastModified, forHTTPHeaderField: HTTPRequestHeader.ifModifiedSince) + } + if let etag = conditionalGet.etag { + setValue(etag, forHTTPHeaderField: HTTPRequestHeader.ifNoneMatch) + } + + } + +} diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 1b91251a2..1f63099ea 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -1086,6 +1086,14 @@ name = Products; sourceTree = ""; }; + 515E4EA82324FF710057B0E7 /* Credentials */ = { + isa = PBXGroup; + children = ( + ); + name = Credentials; + path = ../Frameworks/Account/Credentials; + sourceTree = ""; + }; 5183CCDB226F1EEB0010922C /* Progress */ = { isa = PBXGroup; children = ( @@ -1692,6 +1700,7 @@ 84C9FC6822629C9A00D921D6 /* Shared */ = { isa = PBXGroup; children = ( + 515E4EA82324FF710057B0E7 /* Credentials */, 846E77301F6EF5D600A165E2 /* Account.xcodeproj */, 841D4D542106B3D500DD04E6 /* Articles.xcodeproj */, 841D4D5E2106B3E100DD04E6 /* ArticlesDatabase.xcodeproj */, diff --git a/submodules/RSWeb b/submodules/RSWeb index 250164a9c..168ce1a62 160000 --- a/submodules/RSWeb +++ b/submodules/RSWeb @@ -1 +1 @@ -Subproject commit 250164a9c0b3fc91dc729e013749dfbb1b25ddbf +Subproject commit 168ce1a628847d986d032247498b00293e7659f2