diff --git a/Shared/Account/AccountLogoutView.swift b/Shared/Account/AccountLogoutView.swift index 16b9fd0..b86cf18 100644 --- a/Shared/Account/AccountLogoutView.swift +++ b/Shared/Account/AccountLogoutView.swift @@ -1,30 +1,30 @@ import SwiftUI struct AccountLogoutView: View { @EnvironmentObject var model: WriteFreelyModel var body: some View { VStack { Spacer() VStack { - Text("Logged in as \(model.account.user?.username ?? "Anonymous")") + Text("Logged in as \(model.account.username ?? "Anonymous")") Text("on \(model.account.server)") } Spacer() Button(action: logoutHandler, label: { Text("Logout") }) } } func logoutHandler() { model.logout() } } struct AccountLogoutView_Previews: PreviewProvider { static var previews: some View { AccountLogoutView() .environmentObject(WriteFreelyModel()) } } diff --git a/Shared/Account/AccountModel.swift b/Shared/Account/AccountModel.swift index 9f20628..d8b2473 100644 --- a/Shared/Account/AccountModel.swift +++ b/Shared/Account/AccountModel.swift @@ -1,52 +1,71 @@ import Foundation import WriteFreely enum AccountError: Error { case invalidPassword case usernameNotFound case serverNotFound } extension AccountError: LocalizedError { public var errorDescription: String? { switch self { case .serverNotFound: return NSLocalizedString( "The server could not be found. Please check the information you've entered and try again.", comment: "" ) case .invalidPassword: return NSLocalizedString( "Invalid password. Please check that you've entered your password correctly and try logging in again.", comment: "" ) case .usernameNotFound: return NSLocalizedString( "Username not found. Did you use your email address by mistake?", comment: "" ) } } } struct AccountModel { + private let defaults = UserDefaults.standard + let isLoggedInFlag = "isLoggedInFlag" + let usernameStringKey = "usernameStringKey" + let serverStringKey = "serverStringKey" + var server: String = "" + var username: String = "" var hasError: Bool = false var currentError: AccountError? { didSet { hasError = true } } private(set) var user: WFUser? private(set) var isLoggedIn: Bool = false mutating func login(_ user: WFUser) { self.user = user + self.username = user.username ?? "" self.isLoggedIn = true + defaults.set(true, forKey: isLoggedInFlag) + defaults.set(user.username, forKey: usernameStringKey) + defaults.set(server, forKey: serverStringKey) } mutating func logout() { self.user = nil self.isLoggedIn = false + defaults.set(false, forKey: isLoggedInFlag) + defaults.removeObject(forKey: usernameStringKey) + defaults.removeObject(forKey: serverStringKey) + } + + mutating func restoreState() { + isLoggedIn = defaults.bool(forKey: isLoggedInFlag) + server = defaults.string(forKey: serverStringKey) ?? "" + username = defaults.string(forKey: usernameStringKey) ?? "" } } diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index 99aa14b..b54ac8a 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -1,123 +1,210 @@ import Foundation import WriteFreely +import Security // MARK: - WriteFreelyModel class WriteFreelyModel: ObservableObject { @Published var account = AccountModel() @Published var preferences = PreferencesModel() @Published var store = PostStore() @Published var collections = CollectionListModel(with: []) @Published var post: Post? @Published var isLoggingIn: Bool = false private var client: WFClient? + private let defaults = UserDefaults.standard init() { + // Set the color scheme based on what's been saved in UserDefaults. + DispatchQueue.main.async { + self.preferences.appearance = self.defaults.integer(forKey: self.preferences.colorSchemeIntegerKey) + } + #if DEBUG for post in testPostData { store.add(post) } #endif + + DispatchQueue.main.async { + self.account.restoreState() + } } } // MARK: - WriteFreelyModel API extension WriteFreelyModel { - func login( - to server: URL, - as username: String, - password: String - ) { + func login(to server: URL, as username: String, password: String) { isLoggingIn = true account.server = server.absoluteString client = WFClient(for: server) client?.login(username: username, password: password, completion: loginHandler) } - func logout () { - guard let loggedInClient = client else { return } + func logout() { + guard let loggedInClient = client else { + do { + try purgeTokenFromKeychain(username: account.username, server: account.server) + account.logout() + } catch { + fatalError("Failed to log out persisted state") + } + return + } loggedInClient.logout(completion: logoutHandler) } func fetchUserCollections() { guard let loggedInClient = client else { return } loggedInClient.getUserCollections(completion: fetchUserCollectionsHandler) } } private extension WriteFreelyModel { func loginHandler(result: Result) { DispatchQueue.main.async { self.isLoggingIn = false } do { let user = try result.get() fetchUserCollections() + saveTokenToKeychain(user.token, username: user.username, server: account.server) DispatchQueue.main.async { self.account.login(user) } } catch WFError.notFound { DispatchQueue.main.async { self.account.currentError = AccountError.usernameNotFound } } catch WFError.unauthorized { DispatchQueue.main.async { self.account.currentError = AccountError.invalidPassword } } catch { if let error = error as? NSError, error.domain == NSURLErrorDomain, error.code == -1003 { DispatchQueue.main.async { self.account.currentError = AccountError.serverNotFound } } } } func logoutHandler(result: Result) { do { _ = try result.get() - client = nil - DispatchQueue.main.async { - self.account.logout() - self.collections.clearUserCollection() + do { + try purgeTokenFromKeychain(username: account.user?.username, server: account.server) + client = nil + DispatchQueue.main.async { + self.account.logout() + self.collections.clearUserCollection() + } + } catch { + print("Something went wrong purging the token from the Keychain.") } } catch WFError.notFound { // The user token is invalid or doesn't exist, so it's been invalidated by the server. Proceed with - // destroying the client object and setting the AccountModel to its logged-out state. - client = nil - DispatchQueue.main.async { - self.account.logout() - self.collections.clearUserCollection() + // purging the token from the Keychain, destroying the client object, and setting the AccountModel to its + // logged-out state. + do { + try purgeTokenFromKeychain(username: account.user?.username, server: account.server) + client = nil + DispatchQueue.main.async { + self.account.logout() + self.collections.clearUserCollection() + } + } catch { + print("Something went wrong purging the token from the Keychain.") } } catch { // We get a 'cannot parse response' (similar to what we were seeing in the Swift package) NSURLError here, // so we're using a hacky workaround — if we get the NSURLError, but the AccountModel still thinks we're // logged in, try calling the logout function again and see what we get. // Conditional cast from 'Error' to 'NSError' always succeeds but is the only way to check error properties. if let error = error as? NSError, error.domain == NSURLErrorDomain, error.code == NSURLErrorCannotParseResponse { if account.isLoggedIn { self.logout() } } } } func fetchUserCollectionsHandler(result: Result<[WFCollection], Error>) { do { let fetchedCollections = try result.get() var fetchedCollectionsArray: [PostCollection] = [] for fetchedCollection in fetchedCollections { var postCollection = PostCollection(title: fetchedCollection.title) postCollection.wfCollection = fetchedCollection fetchedCollectionsArray.append(postCollection) } DispatchQueue.main.async { self.collections = CollectionListModel(with: fetchedCollectionsArray) } } catch { print(error) } } } + +private extension WriteFreelyModel { + // MARK: - Keychain Helpers + func saveTokenToKeychain(_ token: String, username: String?, server: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecValueData as String: token.data(using: .utf8)!, + kSecAttrAccount as String: username ?? "anonymous", + kSecAttrService as String: server + ] + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecDuplicateItem || status == errSecSuccess else { + fatalError("Error storing in Keychain with OSStatus: \(status)") + } + } + + func purgeTokenFromKeychain(username: String?, server: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: username ?? "anonymous", + kSecAttrService as String: server + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + fatalError("Error deleting from Keychain with OSStatus: \(status)") + } + } + + func fetchTokenFromKeychain(username: String?, server: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: username ?? "anonymous", + kSecAttrService as String: server, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true + ] + + var secItem: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &secItem) + + guard status != errSecItemNotFound else { + return nil + } + + guard status == errSecSuccess else { + fatalError("Error fetching from Keychain with OSStatus: \(status)") + } + + guard let existingSecItem = secItem as? [String: Any], + let tokenData = existingSecItem[kSecValueData as String] as? Data, + let token = String(data: tokenData, encoding: .utf8) else { + return nil + } + + return token + } +} diff --git a/Shared/Preferences/PreferencesModel.swift b/Shared/Preferences/PreferencesModel.swift index 39e9c24..c4ac721 100644 --- a/Shared/Preferences/PreferencesModel.swift +++ b/Shared/Preferences/PreferencesModel.swift @@ -1,54 +1,59 @@ import SwiftUI class PreferencesModel: ObservableObject { + private let defaults = UserDefaults.standard + let colorSchemeIntegerKey = "colorSchemeIntegerKey" + /* We're stuck dropping into AppKit/UIKit to set light/dark schemes for now, * because setting the .preferredColorScheme modifier on views in SwiftUI is * currently unreliable. * * Feedback submitted to Apple: * * FB8382883: "On macOS 11β4, preferredColorScheme modifier does not respect .light ColorScheme" * FB8383053: "On iOS 14β4/macOS 11β4, it is not possible to unset preferredColorScheme after setting * it to either .light or .dark" */ #if os(iOS) var window: UIWindow? { guard let scene = UIApplication.shared.connectedScenes.first, let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate, let window = windowSceneDelegate.window else { return nil } return window } #endif @Published var selectedColorScheme: ColorScheme? @Published var appearance: Int = 0 { didSet { switch appearance { case 1: // selectedColorScheme = .light #if os(macOS) NSApp.appearance = NSAppearance(named: .aqua) #else window?.overrideUserInterfaceStyle = .light #endif case 2: // selectedColorScheme = .dark #if os(macOS) NSApp.appearance = NSAppearance(named: .darkAqua) #else window?.overrideUserInterfaceStyle = .dark #endif default: // selectedColorScheme = .none #if os(macOS) NSApp.appearance = nil #else window?.overrideUserInterfaceStyle = .unspecified #endif } + + defaults.set(appearance, forKey: colorSchemeIntegerKey) } } }