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 bd8869b..18e2ebc 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -1,99 +1,184 @@ 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 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) { 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 } + 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) } } private extension WriteFreelyModel { func loginHandler(result: Result) { DispatchQueue.main.async { self.isLoggingIn = false } do { let user = try result.get() + 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() + do { + try purgeTokenFromKeychain(username: account.user?.username, server: account.server) + client = nil + DispatchQueue.main.async { + self.account.logout() + } + } 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() + // 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() + } + } 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() } } } } } + +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 + } +}