diff --git a/Authenticator/Source/AppController.swift b/Authenticator/Source/AppController.swift index 493f2f8b..f52ed9d9 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 @@ -210,6 +211,14 @@ class AppController { handleAction(.addTokenFromURL(token)) } + func checkForLocalAuth() { + handleAction(Auth.checkForLocalAuth()) + } + + func enablePrivacy() { + handleAction(.authAction(.enablePrivacy)) + } + private func confirmDeletion(of persistentToken: PersistentToken, failure: @escaping (Error) -> Root.Event) { let messagePrefix = persistentToken.token.displayName.map({ "The token “\($0)”" }) ?? "The unnamed token" let message = messagePrefix + " will be permanently deleted from this device." @@ -258,3 +267,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 1d46636a..3a46c30c 100644 --- a/Authenticator/Source/OTPAppDelegate.swift +++ b/Authenticator/Source/OTPAppDelegate.swift @@ -56,6 +56,14 @@ class OTPAppDelegate: UIResponder, UIApplicationDelegate { return true } + func applicationDidBecomeActive(_ application: UIApplication) { + app.checkForLocalAuth() + } + + func applicationDidEnterBackground(_ application: UIApplication) { + app.enablePrivacy() + } + func applicationWillEnterForeground(_ application: UIApplication) { // Ensure the UI is updated with the latest view model whenever the app returns from the background. app.updateView() diff --git a/Authenticator/Source/Root.swift b/Authenticator/Source/Root.swift index 466d616e..b7b685fd 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(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, _): + 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(with: .enablePrivacy) + } + return nil + } + +} + +struct AuthViewModel { + var enabled: Bool +} + // MARK: View extension Root { @@ -74,7 +134,8 @@ extension Root { ) let viewModel = ViewModel( tokenList: tokenListViewModel, - modal: modal.viewModel(digitGroupSize: digitGroupSize) + modal: modal.viewModel(digitGroupSize: digitGroupSize), + privacy: auth.viewModel ) return (viewModel: viewModel, nextRefreshTime: nextRefreshTime) } @@ -96,6 +157,7 @@ extension Root { case dismissDisplayOptions case addTokenFromURL(Token) + case authAction(Auth.Action) } enum Event { @@ -186,6 +248,9 @@ extension Root { return .addToken(token, success: Event.addTokenFromURLSucceeded, failure: Event.addTokenFailed) + + case .authAction(let action): + return try auth.update(with: action).flatMap { handleAuthEffect($0) } } } catch { throw ComponentError(underlyingError: error, action: action, component: self) @@ -372,6 +437,15 @@ extension Root { } } + private mutating func handleAuthEffect(_ effect: Auth.Effect) -> Effect? { + switch effect { + case .authRequired: + return nil + case .authObtained: + return nil + } + } + private mutating func handleDisplayOptionsEffect(_ effect: DisplayOptions.Effect) -> Effect? { switch effect { case .done: diff --git a/Authenticator/Source/RootViewController.swift b/Authenticator/Source/RootViewController.swift index e315de11..113120ca 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 @@ -173,6 +175,8 @@ extension RootViewController { actionTransform: Root.Action.infoListEffect) } } + updateWithAuthViewModel(viewModel.privacy) + currentViewModel = viewModel } @@ -201,6 +205,49 @@ extension RootViewController { ) presentViewControllers([viewControllerA, viewControllerB]) } + + private func updateWithAuthViewModel(_ viewModel: AuthViewModel) { + if viewModel.enabled == currentViewModel.privacy.enabled { + return + } + if viewModel.enabled { + if authController == nil { + authController = UIViewController() + authController?.view.backgroundColor = UIColor.otpBackgroundColor + let button = UIButton(type: .roundedRect) + 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) + button.center = authController!.view.center + authController?.modalPresentationStyle = .overFullScreen + } + + guard let controller = authController else { + return + } + 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 + } + } + + @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 8a8b4072..f3419607 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