Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Local Authentication #304

Open
wants to merge 25 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4eda5c7
Prototype of using privacy screen and LocalAuthentication
beaucollins Oct 27, 2017
40ba192
A less abrasive prototype UI
beaucollins Oct 27, 2017
3970da4
Merge remote-tracking branch 'origin/develop' into feature/local-auth
beaucollins Sep 27, 2018
5c9d8a9
Conforms to changed protocol update(with: ActionType) method
beaucollins Sep 27, 2018
3cc736a
Merge develop into feature/local-auth
mattrubin Dec 24, 2018
9e456c0
Move the Auth component to a dedicated file
mattrubin Dec 24, 2018
c1b1f9b
Clean up several lint issues
mattrubin Dec 24, 2018
a6322d8
Nest the Auth view model type inside the component type
mattrubin Dec 24, 2018
f1a056e
Refactor checkForLocalAuth()
mattrubin Dec 24, 2018
c04dd39
Add an NSFaceIDUsageDescription
mattrubin Dec 24, 2018
94015a8
Delegate LocalAuthentication challenge to the AppController
mattrubin Dec 24, 2018
eade8b7
Re-route authentication events
mattrubin Dec 24, 2018
5331864
Delete unused Auth Effects
mattrubin Dec 24, 2018
e9a9ad7
Refactor Auth actions
mattrubin Dec 24, 2018
5f2235f
Lock the screen on Event.applicationDidEnterBackground
mattrubin Dec 24, 2018
d4fee61
Lock the screen earlier, in applicationWillResignActive
mattrubin Dec 24, 2018
f1b291b
Automatically prompt for authentication on applicationDidBecomeActive
mattrubin Dec 25, 2018
1aff3cc
Add an internal State enum to Auth
mattrubin Dec 25, 2018
ca5319b
Don't automatically re-attempt failed or cancelled authentication
mattrubin Dec 25, 2018
06def31
Add a localizedReason for the password entry screen
mattrubin Mar 9, 2019
11f51dc
Lock the app on launch if local auth is available
mattrubin Mar 14, 2019
cc905b8
Remove auth availability from screen lock state
mattrubin Mar 14, 2019
bbd82fd
Add a FIXME for the initial screen lock UI
mattrubin Mar 14, 2019
f2a0055
Rename the ScreenLock component
mattrubin Mar 14, 2019
a06a3f5
Refactor for a more future-proof handling of applicationDidBecomeActive
mattrubin Mar 14, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Authenticator.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -141,6 +142,7 @@
C959A63E190A69E60042DEC0 /* GenerateIcons.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = GenerateIcons.sh; sourceTree = "<group>"; };
C968D1141CB4C639004ED7BB /* DisplayTime.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DisplayTime.swift; sourceTree = "<group>"; };
C96E60561DBC5F1B00484823 /* .codecov.yml */ = {isa = PBXFileReference; lastKnownFileType = text; path = .codecov.yml; sourceTree = "<group>"; };
C97350D321D07AE9004F5118 /* ScreenLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLock.swift; sourceTree = "<group>"; };
C9776E3318518801003D53CB /* LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE.txt; sourceTree = "<group>"; };
C97CDF041BEE927200D64406 /* AUTHORS */ = {isa = PBXFileReference; lastKnownFileType = text; path = AUTHORS; sourceTree = "<group>"; };
C97CDF241BEEA49300D64406 /* SVProgressHUD.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = SVProgressHUD.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -387,6 +389,7 @@
isa = PBXGroup;
children = (
C93BD6221C167CD100FFFB8F /* Root.swift */,
C97350D321D07AE9004F5118 /* ScreenLock.swift */,
C93BD6281C168EBF00FFFB8F /* RootViewModel.swift */,
C93BD6241C16841D00FFFB8F /* RootViewController.swift */,
);
Expand Down Expand Up @@ -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 */,
Expand Down
2 changes: 2 additions & 0 deletions Authenticator/Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
<true/>
<key>NSCameraUsageDescription</key>
<string>Authenticator can import tokens by scanning QR codes.</string>
<key>NSFaceIDUsageDescription</key>
<string>Authenticator uses Face ID to protect your tokens.</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIStatusBarStyle</key>
Expand Down
37 changes: 36 additions & 1 deletion Authenticator/Source/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import UIKit
import SafariServices
import OneTimePassword
import SVProgressHUD
import LocalAuthentication

class AppController {
private let store: TokenStore
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."
Expand Down
8 changes: 8 additions & 0 deletions Authenticator/Source/OTPAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
43 changes: 41 additions & 2 deletions Authenticator/Source/Root.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
}

Expand Down Expand Up @@ -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 {
Expand Down
41 changes: 41 additions & 0 deletions Authenticator/Source/RootViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -65,6 +66,8 @@ class RootViewController: OpaqueNavigationController {

super.init(nibName: nil, bundle: nil)
self.viewControllers = [tokenListViewController]

updateWithScreenLockViewModel(viewModel.screenLock)
}

@available(*, unavailable)
Expand Down Expand Up @@ -174,6 +177,8 @@ extension RootViewController {
actionTransform: compose(Menu.Action.infoListEffect, Root.Action.menuAction))
}
}
updateWithScreenLockViewModel(viewModel.screenLock)

currentViewModel = viewModel
}

Expand Down Expand Up @@ -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<A, B, C>(_ transform: @escaping (A) -> B, _ handler: @escaping (B) -> C) -> (A) -> C {
Expand Down
1 change: 1 addition & 0 deletions Authenticator/Source/RootViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
struct RootViewModel {
let tokenList: TokenList.ViewModel
let modal: ModalViewModel
let screenLock: ScreenLock.ViewModel

enum ModalViewModel {
case none
Expand Down
Loading