From 4eda5c76310f64c9cd411612d2d75d2ee68c6018 Mon Sep 17 00:00:00 2001 From: Robert Collins Date: Fri, 27 Oct 2017 13:50:53 -0700 Subject: [PATCH 1/3] Prototype of using privacy screen and LocalAuthentication - Adds Component that: - Checks if device can use LocalAuthentication - Checks if a successful auth challenge has occurred Given that LocalAuthentication is available on the device: When the application becomes foreground after launching a privacy screen is presented. A successful LocalAuthentication dismisses the privacy screen. When the application enters the background state the privacy screen is presented. This prevents tokens from being displayed during app switching. None of the keychain items are using LocalAuthentication for encryption. This is purely UI related so the security/encryption of the keychain items have not been changed by this feature. Tokens are still readable/displayable by the app no matter what the state of the LocalAuthentication challenge is. --- Authenticator/Source/AppController.swift | 17 +++++ Authenticator/Source/OTPAppDelegate.swift | 8 ++ Authenticator/Source/Root.swift | 76 ++++++++++++++++++- Authenticator/Source/RootViewController.swift | 46 +++++++++++ Authenticator/Source/RootViewModel.swift | 1 + 5 files changed, 147 insertions(+), 1 deletion(-) diff --git a/Authenticator/Source/AppController.swift b/Authenticator/Source/AppController.swift index 745c1c80..0df7c361 100644 --- a/Authenticator/Source/AppController.swift +++ b/Authenticator/Source/AppController.swift @@ -28,6 +28,7 @@ import UIKit import SafariServices import OneTimePassword import SVProgressHUD +import LocalAuthentication class AppController { private let store: TokenStore @@ -214,6 +215,14 @@ class AppController { func addTokenFromURL(_ token: Token) { handleAction(.addTokenFromURL(token)) } + + func checkForLocalAuth() { + handleAction(Auth.checkForLocalAuth()) + } + + func enablePrivacy() { + handleAction(.authAction(.enablePrivacy)) + } } private extension DisplayTime { @@ -225,3 +234,11 @@ private extension DisplayTime { return DisplayTime(date: Date()) } } + +private extension Auth { + static func checkForLocalAuth() -> Root.Action { + let context = LAContext() + let canUseLocalAuth = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) + return .authAction(.enableLocalAuth(isEnabled: canUseLocalAuth)) + } +} diff --git a/Authenticator/Source/OTPAppDelegate.swift b/Authenticator/Source/OTPAppDelegate.swift index 46155362..d25cc3ad 100644 --- a/Authenticator/Source/OTPAppDelegate.swift +++ b/Authenticator/Source/OTPAppDelegate.swift @@ -48,6 +48,14 @@ class OTPAppDelegate: UIResponder, UIApplicationDelegate { return true } + func applicationDidBecomeActive(_ application: UIApplication) { + app.checkForLocalAuth() + } + + func applicationDidEnterBackground(_ application: UIApplication) { + app.enablePrivacy() + } + func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { if let token = Token(url: url) { let message = "Do you want to add a token for “\(token.name)”?" diff --git a/Authenticator/Source/Root.swift b/Authenticator/Source/Root.swift index a9ed94b6..eafd14c5 100644 --- a/Authenticator/Source/Root.swift +++ b/Authenticator/Source/Root.swift @@ -30,6 +30,7 @@ struct Root: Component { fileprivate var tokenList: TokenList fileprivate var modal: Modal fileprivate let deviceCanScan: Bool + fileprivate var auth: Auth fileprivate enum Modal { case none @@ -57,10 +58,69 @@ struct Root: Component { init(deviceCanScan: Bool) { tokenList = TokenList() modal = .none + auth = Auth() self.deviceCanScan = deviceCanScan } } +struct Auth: Component { + typealias ViewModel = AuthViewModel + var authAvailable: Bool = false + var authRequired: Bool = false + + enum Action { + case enableLocalAuth(isEnabled: Bool) + case enablePrivacy + case authResult(reply: Bool, error: Error?) + } + + enum Effect { + case authRequired + case authObtained + } + + var viewModel: AuthViewModel { + get { + return AuthViewModel(enabled: authAvailable && authRequired) + } + } + + mutating func update(_ action: Action) throws -> Effect? { + switch action { + case .enableLocalAuth(let isEnabled): + return try handleEnableLocalAuth(isEnabled) + case .enablePrivacy: + authRequired = true + return authAvailable ? .authRequired : nil + case .authResult(let reply, let error): + if reply { + authRequired = false + return .authObtained + } + return nil + } + } + + private mutating func handleEnableLocalAuth(_ shouldEnable: Bool ) throws -> Effect? { + // no change, no effect + if( authAvailable == shouldEnable ) { + return nil + } + authAvailable = shouldEnable + + // enabling after not being enabled, show privacy screen + if ( authAvailable ) { + return try update(.enablePrivacy) + } + return nil + } + +} + +struct AuthViewModel { + var enabled: Bool +} + // MARK: View extension Root { @@ -69,7 +129,8 @@ extension Root { func viewModel(for persistentTokens: [PersistentToken], at displayTime: DisplayTime) -> ViewModel { return ViewModel( tokenList: tokenList.viewModel(for: persistentTokens, at: displayTime), - modal: modal.viewModel + modal: modal.viewModel, + privacy: auth.viewModel ) } } @@ -88,6 +149,7 @@ extension Root { case dismissInfo case addTokenFromURL(Token) + case authAction(Auth.Action) } enum Event { @@ -166,6 +228,9 @@ extension Root { return .addToken(token, success: Event.addTokenFromURLSucceeded, failure: Event.addTokenFailed) + + case .authAction(let action): + return try auth.update(action).flatMap { handleAuthEffect($0) } } } catch { throw ComponentError(underlyingError: error, action: action, component: self) @@ -341,6 +406,15 @@ extension Root { return .openURL(url) } } + + private mutating func handleAuthEffect(_ effect: Auth.Effect) -> Effect? { + switch effect { + case .authRequired: + return nil + case .authObtained: + return nil + } + } } private extension Root.Modal { diff --git a/Authenticator/Source/RootViewController.swift b/Authenticator/Source/RootViewController.swift index 7fadf21f..572b42a1 100644 --- a/Authenticator/Source/RootViewController.swift +++ b/Authenticator/Source/RootViewController.swift @@ -24,6 +24,7 @@ // import UIKit +import LocalAuthentication class OpaqueNavigationController: UINavigationController { override func viewDidLoad() { @@ -52,6 +53,7 @@ class RootViewController: OpaqueNavigationController { fileprivate var tokenListViewController: TokenListViewController fileprivate var modalNavController: UINavigationController? + fileprivate var authController: UIViewController? fileprivate let dispatchAction: (Root.Action) -> Void @@ -162,7 +164,10 @@ extension RootViewController { case .info(let infoListViewModel, let infoViewModel): updateWithInfoViewModels(infoListViewModel, infoViewModel) + } + updateWithAuthViewModel(viewModel.privacy) + currentViewModel = viewModel } @@ -187,6 +192,47 @@ extension RootViewController { ) presentViewControllers([infoListViewController, infoViewController]) } + + private func updateWithAuthViewModel(_ viewModel: AuthViewModel) { + if viewModel.enabled == currentViewModel.privacy.enabled { + return + } + if viewModel.enabled { + if authController == nil { + authController = UIViewController() + authController?.view.backgroundColor = UIColor.purple + let button = UIButton(type: .roundedRect) + button.setTitle("Hello Wrold", for: .normal) + button.addTarget(self, action: #selector(authChallenge), for: .touchUpInside) + button.sizeToFit() + authController?.view.addSubview(button) + button.center = authController!.view.center + authController?.modalPresentationStyle = .overFullScreen + } + + guard let controller = authController else { + return + } + if let navController = modalNavController { + navController.topViewController?.present(controller, animated: false) + } else { + present(controller, animated: false) + } + + } + if !viewModel.enabled { + authController?.presentingViewController?.dismiss(animated: true) + } + } + + @objc private func authChallenge() { + let context = LAContext() + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "LOLZ") { (reply, error) in + DispatchQueue.main.async { + self.dispatchAction(.authAction(.authResult(reply: reply, error: error))) + } + } + } } private func compose(_ transform: @escaping (A) -> B, _ handler: @escaping (B) -> C) -> (A) -> C { diff --git a/Authenticator/Source/RootViewModel.swift b/Authenticator/Source/RootViewModel.swift index 0d5df25a..a1b7e780 100644 --- a/Authenticator/Source/RootViewModel.swift +++ b/Authenticator/Source/RootViewModel.swift @@ -26,6 +26,7 @@ struct RootViewModel { let tokenList: TokenList.ViewModel let modal: ModalViewModel + let privacy: Auth.ViewModel enum ModalViewModel { case none From 40ba192fd1c3373e87ef5ebec0bb371d62e71572 Mon Sep 17 00:00:00 2001 From: Robert Collins Date: Fri, 27 Oct 2017 15:18:17 -0700 Subject: [PATCH 2/3] A less abrasive prototype UI --- Authenticator/Source/RootViewController.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Authenticator/Source/RootViewController.swift b/Authenticator/Source/RootViewController.swift index 572b42a1..ec6bee20 100644 --- a/Authenticator/Source/RootViewController.swift +++ b/Authenticator/Source/RootViewController.swift @@ -200,9 +200,10 @@ extension RootViewController { if viewModel.enabled { if authController == nil { authController = UIViewController() - authController?.view.backgroundColor = UIColor.purple + authController?.view.backgroundColor = UIColor.otpBackgroundColor let button = UIButton(type: .roundedRect) - button.setTitle("Hello Wrold", for: .normal) + button.setTitleColor(UIColor.otpForegroundColor, for: .normal) + button.setTitle("Unlock", for: .normal) button.addTarget(self, action: #selector(authChallenge), for: .touchUpInside) button.sizeToFit() authController?.view.addSubview(button) @@ -213,15 +214,16 @@ extension RootViewController { guard let controller = authController else { return } - if let navController = modalNavController { - navController.topViewController?.present(controller, animated: false) + if let presented = presentedViewController { + presented.present(controller, animated: false) + return } else { present(controller, animated: false) } - } if !viewModel.enabled { authController?.presentingViewController?.dismiss(animated: true) + authController = nil } } From 5c9d8a90ebf8f198c8bb258f856e5f63b7f89e98 Mon Sep 17 00:00:00 2001 From: Robert Collins Date: Thu, 27 Sep 2018 11:42:31 -0700 Subject: [PATCH 3/3] Conforms to changed protocol update(with: ActionType) method --- Authenticator/Source/Root.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Authenticator/Source/Root.swift b/Authenticator/Source/Root.swift index 1ef92141..b7b685fd 100644 --- a/Authenticator/Source/Root.swift +++ b/Authenticator/Source/Root.swift @@ -85,14 +85,14 @@ struct Auth: Component { } } - mutating func update(_ action: Action) throws -> Effect? { + mutating func update(with action: Action) throws -> Effect? { switch action { case .enableLocalAuth(let isEnabled): return try handleEnableLocalAuth(isEnabled) case .enablePrivacy: authRequired = true return authAvailable ? .authRequired : nil - case .authResult(let reply, let error): + case .authResult(let reply, _): if reply { authRequired = false return .authObtained @@ -110,7 +110,7 @@ struct Auth: Component { // enabling after not being enabled, show privacy screen if ( authAvailable ) { - return try update(.enablePrivacy) + return try update(with: .enablePrivacy) } return nil } @@ -250,7 +250,7 @@ extension Root { failure: Event.addTokenFailed) case .authAction(let action): - return try auth.update(action).flatMap { handleAuthEffect($0) } + return try auth.update(with: action).flatMap { handleAuthEffect($0) } } } catch { throw ComponentError(underlyingError: error, action: action, component: self)