diff --git a/Authenticator.xcodeproj/project.pbxproj b/Authenticator.xcodeproj/project.pbxproj index e5e11933..7c506d13 100644 --- a/Authenticator.xcodeproj/project.pbxproj +++ b/Authenticator.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ C944A5571A7ECB0800E08B1E /* OneTimePassword.framework in Copy Frameworks */ = {isa = PBXBuildFile; fileRef = C944A5561A7ECB0800E08B1E /* OneTimePassword.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C944A5591A7ECB3100E08B1E /* Base32.framework in Copy Frameworks */ = {isa = PBXBuildFile; fileRef = C944A5581A7ECB3100E08B1E /* Base32.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C968D1151CB4C639004ED7BB /* DisplayTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = C968D1141CB4C639004ED7BB /* DisplayTime.swift */; }; + C97350D421D07AE9004F5118 /* ScreenLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97350D321D07AE9004F5118 /* ScreenLock.swift */; }; C97CDF261BEEA66A00D64406 /* SVProgressHUD.framework in Copy Frameworks */ = {isa = PBXBuildFile; fileRef = C97CDF241BEEA49300D64406 /* SVProgressHUD.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C97CDF2C1BEEC90100D64406 /* QRScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97CDF2B1BEEC90100D64406 /* QRScanner.swift */; }; C983AB74197F98FC00975003 /* OTPAuthenticatorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = C983AB73197F98FC00975003 /* OTPAuthenticatorTests.m */; }; @@ -141,6 +142,7 @@ C959A63E190A69E60042DEC0 /* GenerateIcons.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = GenerateIcons.sh; sourceTree = ""; }; C968D1141CB4C639004ED7BB /* DisplayTime.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DisplayTime.swift; sourceTree = ""; }; C96E60561DBC5F1B00484823 /* .codecov.yml */ = {isa = PBXFileReference; lastKnownFileType = text; path = .codecov.yml; sourceTree = ""; }; + C97350D321D07AE9004F5118 /* ScreenLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLock.swift; sourceTree = ""; }; C9776E3318518801003D53CB /* LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE.txt; sourceTree = ""; }; C97CDF041BEE927200D64406 /* AUTHORS */ = {isa = PBXFileReference; lastKnownFileType = text; path = AUTHORS; sourceTree = ""; }; C97CDF241BEEA49300D64406 /* SVProgressHUD.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = SVProgressHUD.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -387,6 +389,7 @@ isa = PBXGroup; children = ( C93BD6221C167CD100FFFB8F /* Root.swift */, + C97350D321D07AE9004F5118 /* ScreenLock.swift */, C93BD6281C168EBF00FFFB8F /* RootViewModel.swift */, C93BD6241C16841D00FFFB8F /* RootViewController.swift */, ); @@ -706,6 +709,7 @@ C9E3FB9A1E281CBC00EFA8BB /* TokenScanner.swift in Sources */, C93BD6291C168EBF00FFFB8F /* RootViewModel.swift in Sources */, C9F7A8611C4D90B50082E5AE /* TokenStore.swift in Sources */, + C97350D421D07AE9004F5118 /* ScreenLock.swift in Sources */, C9CC09511BA903B7008C54FE /* TokenFormViewController.swift in Sources */, C98B1ECC1AB3CF0700C59E53 /* TokenRowCell.swift in Sources */, C910ADC11BF0315A00C988F5 /* TokenList.swift in Sources */, diff --git a/Authenticator/Resources/Info.plist b/Authenticator/Resources/Info.plist index 7ca5c289..6a620d41 100644 --- a/Authenticator/Resources/Info.plist +++ b/Authenticator/Resources/Info.plist @@ -39,6 +39,8 @@ NSCameraUsageDescription Authenticator can import tokens by scanning QR codes. + NSFaceIDUsageDescription + Authenticator uses Face ID to protect your tokens. UILaunchStoryboardName LaunchScreen UIStatusBarStyle diff --git a/Authenticator/Source/AppController.swift b/Authenticator/Source/AppController.swift index 493f2f8b..c756ad8b 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 @@ -74,7 +75,8 @@ class AppController { // If this is a demo, show the scanner even in the simulator. let deviceCanScan = QRScanner.deviceCanScan || CommandLine.isDemo - component = Root(deviceCanScan: deviceCanScan) + let isScreenLockEnabled = AppController.canUseLocalAuth() + component = Root(deviceCanScan: deviceCanScan, screenLockEnabled: isScreenLockEnabled) } @objc @@ -155,6 +157,9 @@ class AppController { case let .deletePersistentToken(persistentToken, failure): confirmDeletion(of: persistentToken, failure: failure) + case let .authenticateUser(success, failure): + authenticateUser(success: success, failure: failure) + case let .showErrorMessage(message): SVProgressHUD.showError(withStatus: message) generateHapticFeedback(for: .error) @@ -210,6 +215,36 @@ class AppController { handleAction(.addTokenFromURL(token)) } + static func canUseLocalAuth() -> Bool { + let context = LAContext() + return context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) + } + + func applicationDidBecomeActive() { + handleEvent(.applicationDidBecomeActive) + } + + func applicationWillResignActive() { + handleEvent(.applicationWillResignActive) + } + + private func authenticateUser(success successEvent: Root.Event, failure: @escaping (Error) -> Root.Event) { + let context = LAContext() + let localizedReason = "The Authenticator screen is locked to protect your tokens." + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: localizedReason) { (success, error) in + DispatchQueue.main.async { [weak self] in + if let error = error { + assert(success == false) + let failureEvent = failure(error) + self?.handleEvent(failureEvent) + } else { + assert(success == true) + self?.handleEvent(successEvent) + } + } + } + } + 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." diff --git a/Authenticator/Source/OTPAppDelegate.swift b/Authenticator/Source/OTPAppDelegate.swift index 1d46636a..f30ee0c1 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.applicationDidBecomeActive() + } + + func applicationWillResignActive(_ application: UIApplication) { + app.applicationWillResignActive() + } + 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 7a4eb6a7..321ee1cf 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 screenLock: ScreenLock fileprivate enum Modal { case none @@ -54,9 +55,10 @@ struct Root: Component { } } - init(deviceCanScan: Bool) { + init(deviceCanScan: Bool, screenLockEnabled: Bool) { tokenList = TokenList() modal = .none + screenLock = ScreenLock(screenLockEnabled: screenLockEnabled) self.deviceCanScan = deviceCanScan } } @@ -74,7 +76,8 @@ extension Root { ) let viewModel = ViewModel( tokenList: tokenListViewModel, - modal: modal.viewModel(digitGroupSize: digitGroupSize) + modal: modal.viewModel(digitGroupSize: digitGroupSize), + screenLock: screenLock.viewModel ) return (viewModel: viewModel, nextRefreshTime: nextRefreshTime) } @@ -89,6 +92,7 @@ extension Root { case tokenEditFormAction(TokenEditForm.Action) case tokenScannerAction(TokenScanner.Action) case menuAction(Menu.Action) + case screenLockAction(ScreenLock.Action) case addTokenFromURL(Token) } @@ -102,6 +106,10 @@ extension Root { case updateTokenFailed(Error) case moveTokenFailed(Error) case deleteTokenFailed(Error) + + case applicationDidBecomeActive + case applicationWillResignActive + case screenLockEvent(ScreenLock.Event) } enum Effect { @@ -122,6 +130,8 @@ extension Root { case deletePersistentToken(PersistentToken, failure: (Error) -> Event) + case authenticateUser(success: Event, failure: (Error) -> Event) + case showErrorMessage(String) case showSuccessMessage(String) case showApplicationSettings @@ -166,6 +176,9 @@ extension Root { return .addToken(token, success: Event.addTokenFromURLSucceeded, failure: Event.addTokenFailed) + + case .screenLockAction(let action): + return try screenLock.update(with: action).flatMap { handleScreenLockEffect($0) } } } catch { throw ComponentError(underlyingError: error, action: action, component: self) @@ -192,6 +205,24 @@ extension Root { return .showErrorMessage("Failed to move token.") case .deleteTokenFailed: return .showErrorMessage("Failed to delete token.") + + case .applicationDidBecomeActive: + let effect = screenLock.update(with: .applicationDidBecomeActive) + return effect.flatMap { effect in + handleScreenLockEffect(effect) + } + + case .applicationWillResignActive: + let effect = screenLock.update(with: .applicationWillResignActive) + return effect.flatMap { effect in + handleScreenLockEffect(effect) + } + + case .screenLockEvent(let screenLockEvent): + let effect = screenLock.update(with: screenLockEvent) + return effect.flatMap { effect in + handleScreenLockEffect(effect) + } } } @@ -322,6 +353,14 @@ extension Root { return .setDigitGroupSize(digitGroupSize) } } + + private mutating func handleScreenLockEffect(_ effect: ScreenLock.Effect) -> Effect? { + switch effect { + case let .authenticateUser(success, failure): + return .authenticateUser(success: .screenLockEvent(success), + failure: { .screenLockEvent(failure($0)) }) + } + } } private extension Root.Modal { diff --git a/Authenticator/Source/RootViewController.swift b/Authenticator/Source/RootViewController.swift index aed90711..a9b3f290 100644 --- a/Authenticator/Source/RootViewController.swift +++ b/Authenticator/Source/RootViewController.swift @@ -52,6 +52,7 @@ class RootViewController: OpaqueNavigationController { fileprivate var tokenListViewController: TokenListViewController fileprivate var modalNavController: UINavigationController? + fileprivate var authController: UIViewController? fileprivate let dispatchAction: (Root.Action) -> Void @@ -65,6 +66,8 @@ class RootViewController: OpaqueNavigationController { super.init(nibName: nil, bundle: nil) self.viewControllers = [tokenListViewController] + + updateWithScreenLockViewModel(viewModel.screenLock) } @available(*, unavailable) @@ -174,6 +177,8 @@ extension RootViewController { actionTransform: compose(Menu.Action.infoListEffect, Root.Action.menuAction)) } } + updateWithScreenLockViewModel(viewModel.screenLock) + currentViewModel = viewModel } @@ -202,6 +207,42 @@ extension RootViewController { ) presentViewControllers([viewControllerA, viewControllerB]) } + + private func updateWithScreenLockViewModel(_ viewModel: ScreenLock.ViewModel) { + if viewModel.enabled && authController?.presentingViewController == nil { + 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(tryToUnlock), for: .touchUpInside) + button.sizeToFit() + authController?.view.addSubview(button) + button.center = authController!.view.center + authController?.modalPresentationStyle = .overFullScreen + } + + guard let controller = authController else { + return + } + // FIXME: This fails to present a model over a VC that has not yet been added to the window. + if let presented = presentedViewController { + presented.present(controller, animated: false) + return + } else { + present(controller, animated: false) + } + } else if !viewModel.enabled && authController?.presentingViewController != nil { + authController?.presentingViewController?.dismiss(animated: true) + authController = nil + } + } + + @objc + private func tryToUnlock() { + dispatchAction(.screenLockAction(.tryToUnlock)) + } } 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..85b516ec 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 screenLock: ScreenLock.ViewModel enum ModalViewModel { case none diff --git a/Authenticator/Source/ScreenLock.swift b/Authenticator/Source/ScreenLock.swift new file mode 100644 index 00000000..4d221c25 --- /dev/null +++ b/Authenticator/Source/ScreenLock.swift @@ -0,0 +1,119 @@ +// +// ScreenLock.swift +// Authenticator +// +// Copyright (c) 2017-2018 Authenticator authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +struct ScreenLock: Component { + private var state: State + + private enum State: Equatable { + case unlocked + case locked(shouldAuthenticateAutomatically: Bool) + + var isLocked: Bool { + switch self { + case .unlocked: + return false + case .locked: + return true + } + } + } + + init(screenLockEnabled: Bool) { + if screenLockEnabled { + state = .locked(shouldAuthenticateAutomatically: true) + } else { + state = .unlocked + } + } + + // MARK: View + + struct ViewModel { + var enabled: Bool + } + + var viewModel: ViewModel { + return ViewModel(enabled: state.isLocked) + } + + // MARK: Update + + enum Action { + case tryToUnlock + } + + enum Effect { + case authenticateUser(success: Event, failure: (Error) -> Event) + } + + enum Event { + case applicationDidBecomeActive + case applicationWillResignActive + + case authenticationSucceeded + case authenticationFailed(Error) + } + + mutating func update(with action: Action) throws -> Effect? { + switch action { + case .tryToUnlock: + return .authenticateUser(success: .authenticationSucceeded, + failure: Event.authenticationFailed) + } + } + + mutating func update(with event: Event) -> Effect? { + switch event { + case .applicationDidBecomeActive: + switch state { + case .locked(shouldAuthenticateAutomatically: true): + return .authenticateUser(success: .authenticationSucceeded, + failure: Event.authenticationFailed) + case .unlocked, + .locked(shouldAuthenticateAutomatically: false): + return nil + } + + case .applicationWillResignActive: + // When the app becomes inactive, regardless of the current state, the app should lock and prepare to + // automatically authenticate when the app becomes active. + state = .locked(shouldAuthenticateAutomatically: true) + return nil + + case .authenticationSucceeded: + state = .unlocked + return nil + + case .authenticationFailed(let error): + // If automatic authentication fails, the app should remain locked, but should not automatically try to + // authenticate again. + if case .locked(shouldAuthenticateAutomatically: true) = state { + state = .locked(shouldAuthenticateAutomatically: false) + } + print(error) // TODO: Improve error handling + return nil + } + } +}