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 5328018..d8b2473 100644 --- a/Shared/Account/AccountModel.swift +++ b/Shared/Account/AccountModel.swift @@ -1,63 +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 96971c3..eacfcff 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -1,166 +1,178 @@ 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? init() { #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() 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 // 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 } }