Skip to content

Prototype of using privacy screen and LocalAuthentication #219

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

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions 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 @@ -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."
Expand Down Expand Up @@ -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))
}
}
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.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()
Expand Down
76 changes: 75 additions & 1 deletion 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 auth: Auth

fileprivate enum Modal {
case none
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand All @@ -96,6 +157,7 @@ extension Root {
case dismissDisplayOptions

case addTokenFromURL(Token)
case authAction(Auth.Action)
}

enum Event {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
47 changes: 47 additions & 0 deletions Authenticator/Source/RootViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
//

import UIKit
import LocalAuthentication

class OpaqueNavigationController: UINavigationController {
override func viewDidLoad() {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -173,6 +175,8 @@ extension RootViewController {
actionTransform: Root.Action.infoListEffect)
}
}
updateWithAuthViewModel(viewModel.privacy)

currentViewModel = viewModel
}

Expand Down Expand Up @@ -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
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This localizedReason seems completely correct to me.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This currently only triggers the passcode-screen, although Face ID is available. Per docs it should try Face ID / Touch ID first. Did you notice this as well?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strange! On my phone, it triggers FaceID.
Each app that wants to use FaceID shows a permission prompt the first time it tries to authenticate with FaceID. If permission is denied, it falls back to using a password. If you check in the system Settings.app, does Authenticator have permission to use FaceID?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not prompt for this permission and is not visible in the settings but I can check again!

Having this pull request merged brings this project to a new level of privacy, so thanks everyone contributing to this! 🙂

DispatchQueue.main.async {
self.dispatchAction(.authAction(.authResult(reply: reply, error: error)))
}
}
}
}

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 privacy: Auth.ViewModel

enum ModalViewModel {
case none
Expand Down