From 5e07a900a989bd213b233bed4e641d8afdf62176 Mon Sep 17 00:00:00 2001 From: dankito Date: Mon, 7 Sep 2020 00:07:43 +0200 Subject: [PATCH] Implemented securing app access with password, FaceID or TouchID --- .../BankingiOSApp.xcodeproj/project.pbxproj | 60 +++++ .../Base.lproj/Localizable.strings | 33 +++ ui/BankingiOSApp/BankingiOSApp/Info.plist | 2 + .../BankingiOSApp/SceneDelegate.swift | 26 +- .../Security/AuthenticationService.swift | 133 +++++++++++ .../Security/AuthenticationType.swift | 16 ++ .../BiometricAuthenticationService.swift | 80 +++++++ .../Security/KeychainPasswordItem.swift | 224 ++++++++++++++++++ .../BankingiOSApp/TabBarController.swift | 26 +- .../de.lproj/Localizable.strings | 33 +++ .../persistence/Extensions.swift | 28 ++- .../BankingiOSApp/ui/SwiftExtensions.swift | 49 ++-- .../BankingiOSApp/ui/SwiftUiRouter.swift | 8 +- .../BankingiOSApp/ui/UIKitExtensions.swift | 16 +- .../ui/views/AddAccountDialog.swift | 18 +- .../BankingiOSApp/ui/views/Divider.swift | 4 +- .../BankingiOSApp/ui/views/FaceIDButton.swift | 38 +++ .../BankingiOSApp/ui/views/LoginDialog.swift | 129 ++++++++++ .../ui/views/ProtectAppSettingsDialog.swift | 198 ++++++++++++++++ .../ui/views/SettingsDialog.swift | 62 ++++- .../ui/views/TouchIDButton.swift | 27 +++ 21 files changed, 1134 insertions(+), 76 deletions(-) create mode 100644 ui/BankingiOSApp/BankingiOSApp/Security/AuthenticationService.swift create mode 100644 ui/BankingiOSApp/BankingiOSApp/Security/AuthenticationType.swift create mode 100644 ui/BankingiOSApp/BankingiOSApp/Security/BiometricAuthenticationService.swift create mode 100644 ui/BankingiOSApp/BankingiOSApp/Security/KeychainPasswordItem.swift create mode 100644 ui/BankingiOSApp/BankingiOSApp/ui/views/FaceIDButton.swift create mode 100644 ui/BankingiOSApp/BankingiOSApp/ui/views/LoginDialog.swift create mode 100644 ui/BankingiOSApp/BankingiOSApp/ui/views/ProtectAppSettingsDialog.swift create mode 100644 ui/BankingiOSApp/BankingiOSApp/ui/views/TouchIDButton.swift diff --git a/ui/BankingiOSApp/BankingiOSApp.xcodeproj/project.pbxproj b/ui/BankingiOSApp/BankingiOSApp.xcodeproj/project.pbxproj index 1d9de3b4..0e3ad2eb 100644 --- a/ui/BankingiOSApp/BankingiOSApp.xcodeproj/project.pbxproj +++ b/ui/BankingiOSApp/BankingiOSApp.xcodeproj/project.pbxproj @@ -19,6 +19,9 @@ 360782D324F429F80098FEFE /* FlickerCodeStripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 360782D224F429F70098FEFE /* FlickerCodeStripe.swift */; }; 3608D6C224FBA9C6006C93A8 /* TrianglePointingDown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3608D6C124FBA9C6006C93A8 /* TrianglePointingDown.swift */; }; 3608D6C624FBAB41006C93A8 /* TanGeneratorPositionMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3608D6C524FBAB41006C93A8 /* TanGeneratorPositionMarker.swift */; }; + 361116A62505430500315620 /* KeychainPasswordItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 361116A52505430400315620 /* KeychainPasswordItem.swift */; }; + 361116A8250562BE00315620 /* FaceIDButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 361116A7250562BE00315620 /* FaceIDButton.swift */; }; + 361116AA250562CF00315620 /* TouchIDButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 361116A9250562CF00315620 /* TouchIDButton.swift */; }; 3642F00A2500F5AE005186FE /* Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3642F0092500F5AE005186FE /* Divider.swift */; }; 3642F00C25010021005186FE /* UIKitActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3642F00B25010021005186FE /* UIKitActivityIndicator.swift */; }; 3642F01425018BA9005186FE /* TabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3642F01325018BA9005186FE /* TabBarController.swift */; }; @@ -31,6 +34,14 @@ 366FA4E024C4924A0094F009 /* RemitteeListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 366FA4DF24C4924A0094F009 /* RemitteeListItem.swift */; }; 366FA4E224C4ED6C0094F009 /* EnterTanDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 366FA4E124C4ED6C0094F009 /* EnterTanDialog.swift */; }; 366FA4E624C6EBF40094F009 /* EnterTanState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 366FA4E524C6EBF40094F009 /* EnterTanState.swift */; }; + 36B8A4482503D12100C15359 /* ProtectAppSettingsDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36B8A4472503D12100C15359 /* ProtectAppSettingsDialog.swift */; }; + 36B8A44B2503D1E800C15359 /* BiometricAuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36B8A44A2503D1E800C15359 /* BiometricAuthenticationService.swift */; }; + 36B8A44D2503D96D00C15359 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36B8A44C2503D96D00C15359 /* AuthenticationService.swift */; }; + 36B8A44F2503D97D00C15359 /* AuthenticationType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36B8A44E2503D97D00C15359 /* AuthenticationType.swift */; }; + 36B8A4512503DE1800C15359 /* LoginDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36B8A4502503DE1800C15359 /* LoginDialog.swift */; }; + 36B8A4542503E93B00C15359 /* UIAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36B8A4532503E93B00C15359 /* UIAlert.swift */; }; + 36B8A4562503E9B200C15359 /* UIAlertBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36B8A4552503E9B200C15359 /* UIAlertBase.swift */; }; + 36B8A4582503EEB600C15359 /* ActionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36B8A4572503EEB600C15359 /* ActionSheet.swift */; }; 36BCF85424BA0C54005BEC29 /* BankList.json in Resources */ = {isa = PBXBuildFile; fileRef = 36BCF85324BA0C54005BEC29 /* BankList.json */; }; 36BCF85824BA4274005BEC29 /* BankingUiCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 36BCF85524BA41EE005BEC29 /* BankingUiCommon.framework */; }; 36BCF85924BA4274005BEC29 /* BankingUiCommon.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 36BCF85524BA41EE005BEC29 /* BankingUiCommon.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -155,6 +166,9 @@ 360782D224F429F70098FEFE /* FlickerCodeStripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlickerCodeStripe.swift; sourceTree = ""; }; 3608D6C124FBA9C6006C93A8 /* TrianglePointingDown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrianglePointingDown.swift; sourceTree = ""; }; 3608D6C524FBAB41006C93A8 /* TanGeneratorPositionMarker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TanGeneratorPositionMarker.swift; sourceTree = ""; }; + 361116A52505430400315620 /* KeychainPasswordItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainPasswordItem.swift; sourceTree = ""; }; + 361116A7250562BE00315620 /* FaceIDButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaceIDButton.swift; sourceTree = ""; }; + 361116A9250562CF00315620 /* TouchIDButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchIDButton.swift; sourceTree = ""; }; 3642F0092500F5AE005186FE /* Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Divider.swift; sourceTree = ""; }; 3642F00B25010021005186FE /* UIKitActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitActivityIndicator.swift; sourceTree = ""; }; 3642F01325018BA9005186FE /* TabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarController.swift; sourceTree = ""; }; @@ -167,6 +181,14 @@ 366FA4DF24C4924A0094F009 /* RemitteeListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemitteeListItem.swift; sourceTree = ""; }; 366FA4E124C4ED6C0094F009 /* EnterTanDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterTanDialog.swift; sourceTree = ""; }; 366FA4E524C6EBF40094F009 /* EnterTanState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterTanState.swift; sourceTree = ""; }; + 36B8A4472503D12100C15359 /* ProtectAppSettingsDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectAppSettingsDialog.swift; sourceTree = ""; }; + 36B8A44A2503D1E800C15359 /* BiometricAuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BiometricAuthenticationService.swift; sourceTree = ""; }; + 36B8A44C2503D96D00C15359 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; + 36B8A44E2503D97D00C15359 /* AuthenticationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationType.swift; sourceTree = ""; }; + 36B8A4502503DE1800C15359 /* LoginDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginDialog.swift; sourceTree = ""; }; + 36B8A4532503E93B00C15359 /* UIAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlert.swift; sourceTree = ""; }; + 36B8A4552503E9B200C15359 /* UIAlertBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertBase.swift; sourceTree = ""; }; + 36B8A4572503EEB600C15359 /* ActionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionSheet.swift; sourceTree = ""; }; 36BCF85324BA0C54005BEC29 /* BankList.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; name = BankList.json; path = ../../../tools/BankFinder/src/commonMain/resources/BankList.json; sourceTree = ""; }; 36BCF85524BA41EE005BEC29 /* BankingUiCommon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = BankingUiCommon.framework; path = "../BankingUiCommon/build/xcode-frameworks/BankingUiCommon.framework"; sourceTree = ""; }; 36BCF85D24BA4DA8005BEC29 /* MultiplatformUtils.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MultiplatformUtils.framework; path = "../../common/build/xcode-frameworks/MultiplatformUtils.framework"; sourceTree = ""; }; @@ -290,6 +312,27 @@ path = fints4k; sourceTree = ""; }; + 36B8A4492503D15300C15359 /* Security */ = { + isa = PBXGroup; + children = ( + 36B8A44A2503D1E800C15359 /* BiometricAuthenticationService.swift */, + 36B8A44C2503D96D00C15359 /* AuthenticationService.swift */, + 36B8A44E2503D97D00C15359 /* AuthenticationType.swift */, + 361116A52505430400315620 /* KeychainPasswordItem.swift */, + ); + path = Security; + sourceTree = ""; + }; + 36B8A4522503E92300C15359 /* UIKit */ = { + isa = PBXGroup; + children = ( + 36B8A4552503E9B200C15359 /* UIAlertBase.swift */, + 36B8A4532503E93B00C15359 /* UIAlert.swift */, + 36B8A4572503EEB600C15359 /* ActionSheet.swift */, + ); + path = UIKit; + sourceTree = ""; + }; 36BCF87924BFA679005BEC29 /* persistence */ = { isa = PBXGroup; children = ( @@ -350,6 +393,7 @@ children = ( 36FC92D424B3A389002B12E9 /* fints4k */, 36BCF87924BFA679005BEC29 /* persistence */, + 36B8A4492503D15300C15359 /* Security */, 36BE06B624D077B400CBBB68 /* BankIconFinder */, 36FC92D924B3A479002B12E9 /* ui */, 36FC929B24B39A05002B12E9 /* AppDelegate.swift */, @@ -425,6 +469,7 @@ 36FC92D924B3A479002B12E9 /* ui */ = { isa = PBXGroup; children = ( + 36B8A4522503E92300C15359 /* UIKit */, 36FC92DA24B3A485002B12E9 /* views */, 36E7BA1324B3D05C00757859 /* ViewExtensions.swift */, 36E21ECA24D88DF000649DC8 /* UIKitExtensions.swift */, @@ -453,6 +498,7 @@ 36FC92DA24B3A485002B12E9 /* views */ = { isa = PBXGroup; children = ( + 36B8A4502503DE1800C15359 /* LoginDialog.swift */, 36FC92DB24B3A4A0002B12E9 /* AccountsTab.swift */, 360782C024E18D5E0098FEFE /* AddAccountButtonView.swift */, 36FC92EE24B3BB81002B12E9 /* AddAccountDialog.swift */, @@ -473,6 +519,7 @@ 36E21ED024DC540400649DC8 /* SettingsDialog.swift */, 36E21ED424DC549800649DC8 /* BankSettingsDialog.swift */, 36E21ED624DC617200649DC8 /* BankAccountSettingsDialog.swift */, + 36B8A4472503D12100C15359 /* ProtectAppSettingsDialog.swift */, 36BE065624C9E04800CBBB68 /* UIKitImageView.swift */, 36BE065824CA3CAB00CBBB68 /* UIKitSearchBar.swift */, 36BE065A24CA4B3500CBBB68 /* SelectBankDialog.swift */, @@ -486,6 +533,8 @@ 3642F0092500F5AE005186FE /* Divider.swift */, 3642F00B25010021005186FE /* UIKitActivityIndicator.swift */, 3642F0172502723A005186FE /* UIKitButton.swift */, + 361116A7250562BE00315620 /* FaceIDButton.swift */, + 361116A9250562CF00315620 /* TouchIDButton.swift */, ); path = views; sourceTree = ""; @@ -662,6 +711,7 @@ 36E21ECB24D88DF000649DC8 /* UIKitExtensions.swift in Sources */, 360782C524E541970098FEFE /* ScaleImageView.swift in Sources */, 366FA4E224C4ED6C0094F009 /* EnterTanDialog.swift in Sources */, + 361116AA250562CF00315620 /* TouchIDButton.swift in Sources */, 36FC92DC24B3A4A0002B12E9 /* AccountsTab.swift in Sources */, 36BCF86E24BA691B005BEC29 /* DependencyInjector.swift in Sources */, 36BE06C224D07FB100CBBB68 /* Favicon.swift in Sources */, @@ -679,8 +729,10 @@ 36BE06BA24D0783900CBBB68 /* FaviconFinder.swift in Sources */, 36BCF89524C31F02005BEC29 /* AppData.swift in Sources */, 3642F01A2502931F005186FE /* InstantPaymentInfoView.swift in Sources */, + 36B8A44F2503D97D00C15359 /* AuthenticationType.swift in Sources */, 36E21EDD24DCA89100649DC8 /* TanProcedurePicker.swift in Sources */, 3608D6C624FBAB41006C93A8 /* TanGeneratorPositionMarker.swift in Sources */, + 36B8A4542503E93B00C15359 /* UIAlert.swift in Sources */, 36BE065B24CA4B3500CBBB68 /* SelectBankDialog.swift in Sources */, 36E21ED524DC549800649DC8 /* BankSettingsDialog.swift in Sources */, 36BE068924CE288800CBBB68 /* CollapsibleText.swift in Sources */, @@ -693,12 +745,16 @@ 36BCF88924C0A7D7005BEC29 /* Message.swift in Sources */, 366FA4E024C4924A0094F009 /* RemitteeListItem.swift in Sources */, 3608D6C224FBA9C6006C93A8 /* TrianglePointingDown.swift in Sources */, + 36B8A4582503EEB600C15359 /* ActionSheet.swift in Sources */, 36BE068B24CE3B0400CBBB68 /* SwiftExtensions.swift in Sources */, 360782C724E544170098FEFE /* FlickerCodeTanView.swift in Sources */, + 36B8A4482503D12100C15359 /* ProtectAppSettingsDialog.swift in Sources */, 36BE065D24CB08FC00CBBB68 /* LazyView.swift in Sources */, 360782C124E18D5E0098FEFE /* AddAccountButtonView.swift in Sources */, 36BCF86C24BA5E72005BEC29 /* DispatchQueueAsyncRunner.swift in Sources */, 36BCF86324BA5097005BEC29 /* SwiftUiRouter.swift in Sources */, + 36B8A44D2503D96D00C15359 /* AuthenticationService.swift in Sources */, + 36B8A4512503DE1800C15359 /* LoginDialog.swift in Sources */, 36FC929C24B39A05002B12E9 /* AppDelegate.swift in Sources */, 36BCF88B24C0BD2D005BEC29 /* AccountTransactionsDialog.swift in Sources */, 36BCF87624BF114F005BEC29 /* UrlSessionWebClient.swift in Sources */, @@ -707,6 +763,7 @@ 360782CF24F3D6610098FEFE /* InfoLabel.swift in Sources */, 3642F04B25031157005186FE /* SectionHeaderWithRightAlignedEditButton.swift in Sources */, 36C4009B24D2F9E4005227AD /* IconedTitleView.swift in Sources */, + 361116A62505430500315620 /* KeychainPasswordItem.swift in Sources */, 36BE065724C9E04800CBBB68 /* UIKitImageView.swift in Sources */, 3642F01625018DA1005186FE /* InterceptTabClickViewController.swift in Sources */, 36BCF88724C0A310005BEC29 /* PreviewData.swift in Sources */, @@ -717,13 +774,16 @@ 36BE069124CEF52800CBBB68 /* UpdateButton.swift in Sources */, 36E21ED124DC540400649DC8 /* SettingsDialog.swift in Sources */, 366FA4DC24C479120094F009 /* BankInfoListItem.swift in Sources */, + 36B8A44B2503D1E800C15359 /* BiometricAuthenticationService.swift in Sources */, 36FC929E24B39A05002B12E9 /* SceneDelegate.swift in Sources */, 3607829924E148D40098FEFE /* AdaptsToKeyboard.swift in Sources */, 36E21ECF24DA0EEE00649DC8 /* IconView.swift in Sources */, 3642F0182502723A005186FE /* UIKitButton.swift in Sources */, + 36B8A4562503E9B200C15359 /* UIAlertBase.swift in Sources */, 3642F01425018BA9005186FE /* TabBarController.swift in Sources */, 36BCF88524C098C8005BEC29 /* BankAccountListItem.swift in Sources */, 36FC92EF24B3BB81002B12E9 /* AddAccountDialog.swift in Sources */, + 361116A8250562BE00315620 /* FaceIDButton.swift in Sources */, 36C4009D24D3236B005227AD /* UrlUtil.swift in Sources */, 36BE066524CDE62800CBBB68 /* AccountTransactionListItem.swift in Sources */, ); diff --git a/ui/BankingiOSApp/BankingiOSApp/Base.lproj/Localizable.strings b/ui/BankingiOSApp/BankingiOSApp/Base.lproj/Localizable.strings index 97caefb1..c5cce6d8 100644 --- a/ui/BankingiOSApp/BankingiOSApp/Base.lproj/Localizable.strings +++ b/ui/BankingiOSApp/BankingiOSApp/Base.lproj/Localizable.strings @@ -27,6 +27,19 @@ "Settings" = "Settings"; +/* LoginDialog */ + +"Login Dialog title" = "Login"; +"Authenticate to change app protection settings" = "Authenticate to change app protection settings"; +"Authenticate with biometrics to unlock app reason" = "Authenticate to unlock your data"; +"To unlock app please authenticate with FaceID" = "To unlock app please authenticate with FaceID"; +"To unlock app please authenticate with TouchID" = "To unlock app please authenticate with TouchID"; +"Enter your password" = "Enter your password"; +"Login" = "Login"; +"Authentication failed" = "Authentication failed"; +"Incorrect password entered" = "Incorrect password entered"; + + /* SelectBankDialog */ "Select Bank Dialog Title" = "Select your bank ..."; @@ -52,6 +65,9 @@ "Could not add account" = "Could not add account"; "Error message from your bank %@" = "Error message from your bank:\n\n%@"; +"Secure data?" = "Secure data?"; +"Secure data with?" = "Adding account was successful.\n\nDo you want to secure your data and login passwords by password, TouchID or FaceID (if available)?\n\nYou can also do this later in the settings."; + /* AccountTransactionsDialog */ @@ -113,6 +129,11 @@ Unfortunately, Bankmeister cannot know whether a bank charges for instant paymen "Could not change TAN medium to %@. Error: %@." = "Could not change TAN medium to '%@'.\n\nError message from your bank:\n\n%@."; +/* SettingsDialog */ + +"Secure app data" = "Secure app data"; + + /* BankSettingsDialog */ "Credentials" = "Credentials"; @@ -141,3 +162,15 @@ Unfortunately, Bankmeister cannot know whether a bank charges for instant paymen "Supports Retrieving Account Transactions" = "Retrieve transactions"; "Supports Transferring Money" = "Transfer money"; "Supports Instant payment transfer" = "Instant payment transfer"; + + +/* ProtectAppSettingsDialog */ + +"Protect App Settings Dialog title" = "Security settings"; +"FaceID" = "FaceID"; +"TouchID" = "TouchID"; +"Password" = "Password"; +"Authenticate with TouchID" = "Authenticate with TouchID"; +"Enter new password" = "Enter new password"; +"Confirm password" = "Confirm password"; +"Confirm new password" = "Confirm new password"; diff --git a/ui/BankingiOSApp/BankingiOSApp/Info.plist b/ui/BankingiOSApp/BankingiOSApp/Info.plist index 67bcf139..80ba9484 100644 --- a/ui/BankingiOSApp/BankingiOSApp/Info.plist +++ b/ui/BankingiOSApp/BankingiOSApp/Info.plist @@ -20,6 +20,8 @@ 1 LSRequiresIPhoneOS + NSFaceIDUsageDescription + On user demand we use FaceID to unlock the app UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/ui/BankingiOSApp/BankingiOSApp/SceneDelegate.swift b/ui/BankingiOSApp/BankingiOSApp/SceneDelegate.swift index f808d3cf..12d6c222 100644 --- a/ui/BankingiOSApp/BankingiOSApp/SceneDelegate.swift +++ b/ui/BankingiOSApp/BankingiOSApp/SceneDelegate.swift @@ -19,13 +19,22 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let context = appDelegate.persistentContainer.viewContext setupBankingUi(context: context) + + let authenticationService = AuthenticationService() - // Create the SwiftUI view and set the context as the value for the managedObjectContext environment keyPath. - // Add `@Environment(\.managedObjectContext)` in the views that will need the context. if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) - window.rootViewController = UINavigationController(rootViewController: TabBarController()) self.window = window + + if authenticationService.needsAuthenticationToUnlockApp { + window.rootViewController = UIHostingController(rootView: LoginDialog(authenticationService) { _ in + self.showApplicationMainView(window: window) + } ) + } + else { + showApplicationMainView(window: window) + } + window.makeKeyAndVisible() } } @@ -44,6 +53,17 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { DependencyInjector.register(dependency: presenter) } + + func showApplicationMainView() { + if let window = window { + showApplicationMainView(window: window) + } + } + + private func showApplicationMainView(window: UIWindow) { + window.rootViewController = UINavigationController(rootViewController: TabBarController()) + } + func sceneDidDisconnect(_ scene: UIScene) { // Called as the scene is being released by the system. diff --git a/ui/BankingiOSApp/BankingiOSApp/Security/AuthenticationService.swift b/ui/BankingiOSApp/BankingiOSApp/Security/AuthenticationService.swift new file mode 100644 index 00000000..452b0696 --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/Security/AuthenticationService.swift @@ -0,0 +1,133 @@ +import SwiftUI + + +class AuthenticationService { + + static private let AuthenticationTypeUserDefaultsKey = "AuthenticationType" + + static private let KeychainAccountName = "LoginPassword" + + private let biometricAuthenticationService = BiometricAuthenticationService() + + + + var authenticationType: AuthenticationType { + let authenticationTypeString = UserDefaults.standard.string(forKey: Self.AuthenticationTypeUserDefaultsKey, defaultValue: AuthenticationType.unset.rawValue) + + return AuthenticationType.init(rawValue: authenticationTypeString) ?? .unset + } + + var needsAuthenticationToUnlockApp: Bool { + let authenticationType = self.authenticationType + + return authenticationType != .unset && authenticationType != .none + } + + var needsBiometricAuthenticationToUnlockApp: Bool { + let authenticationType = self.authenticationType + + return authenticationType == .faceID || authenticationType == .touchID + } + + var needsFaceIDToUnlockApp: Bool { + return self.authenticationType == .faceID + } + + var needsTouchIDToUnlockApp: Bool { + return self.authenticationType == .touchID + } + + var needsPasswordToUnlockApp: Bool { + return self.authenticationType == .password + } + + + var deviceSupportsFaceID: Bool { + return biometricAuthenticationService.isFaceIDSupported + } + + var deviceSupportsTouchID: Bool { + return biometricAuthenticationService.isTouchIDSupported + } + + + func setAuthenticationType(_ type: AuthenticationType) { + if type != .unset { // it's not allowed to unset authentication type + if needsPasswordToUnlockApp { + deleteLoginPassword() + } + + UserDefaults.standard.set(type.rawValue, forKey: Self.AuthenticationTypeUserDefaultsKey) + } + else { + // TODO: what to do in this case, throw an exception? + } + } + + func setAuthenticationTypeToPassword(_ newPassword: String) { + setAuthenticationType(.password) + + setLoginPassword(newPassword) + } + + @discardableResult + private func setLoginPassword(_ newPassword: String) -> Bool { + do { + let passwordItem = createKeychainPasswordItem() + + try passwordItem.savePassword(newPassword) + + return true + } catch { + NSLog("Could not save login password: \(error)") + } + + return false + } + + @discardableResult + private func deleteLoginPassword() -> Bool { + do { + let passwordItem = createKeychainPasswordItem() + + try passwordItem.deleteItem() + + return true + } catch { + NSLog("Could not delete login password: \(error)") + } + + return false + } + + private func retrieveLoginPassword() -> String? { + do { + let passwordItem = createKeychainPasswordItem() + + return try passwordItem.readPassword() + } catch { + NSLog("Could not read login password: \(error)") + } + + return nil + } + + private func createKeychainPasswordItem() -> KeychainPasswordItem { + return KeychainPasswordItem(Self.KeychainAccountName) + } + + + func loginWithBiometricAuthentication(_ authenticationResult: @escaping (Bool, String?) -> Void) { + biometricAuthenticationService.authenticate("Authenticate with biometrics to unlock app reason", authenticationResult) + } + + func loginWithPassword(_ enteredPassword: String, _ authenticationResult: @escaping (Bool, String?) -> Void) { + if retrieveLoginPassword() == enteredPassword { + authenticationResult(true, nil) + } + else { + authenticationResult(false, "Incorrect password entered".localize()) + } + } + +} diff --git a/ui/BankingiOSApp/BankingiOSApp/Security/AuthenticationType.swift b/ui/BankingiOSApp/BankingiOSApp/Security/AuthenticationType.swift new file mode 100644 index 00000000..f92f056a --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/Security/AuthenticationType.swift @@ -0,0 +1,16 @@ +import Foundation + + +enum AuthenticationType: String { + + case unset + + case none + + case password + + case touchID + + case faceID + +} diff --git a/ui/BankingiOSApp/BankingiOSApp/Security/BiometricAuthenticationService.swift b/ui/BankingiOSApp/BankingiOSApp/Security/BiometricAuthenticationService.swift new file mode 100644 index 00000000..f6cec415 --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/Security/BiometricAuthenticationService.swift @@ -0,0 +1,80 @@ +import LocalAuthentication + + +class BiometricAuthenticationService { + + private let localAuthenticationContext = LAContext() + + + var biometryType: LABiometryType { + localAuthenticationContext.biometryType + } + + var isFaceIDSupported: Bool { + biometryType == .faceID + } + + var isTouchIDSupported: Bool { + biometryType == .touchID + } + + var isBiometricAuthenticationAvailable: Bool { + var authorizationError: NSError? + + return localAuthenticationContext.canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: &authorizationError) + } + + + func authenticate(_ authenticationReason: String, _ authenticationResult: @escaping (Bool, String?) -> Void) { + localAuthenticationContext.evaluatePolicy(LAPolicy.deviceOwnerAuthentication, localizedReason: authenticationReason.localize()) { (success, evaluationError) in + var errorMessage: String? = nil + + if let errorObj = evaluationError { + let (showMessageToUser, untranslatedErrorMessage) = self.getErrorMessageKey(errorCode: errorObj._code) + if showMessageToUser { + errorMessage = untranslatedErrorMessage.localize() + } + } + + DispatchQueue.main.async { // handler is called off main thread, so dispatch result back to main thread + authenticationResult(success, errorMessage) + } + } + } + + // copied from https://www.appsdeveloperblog.com/touch-id-or-face-id-authentication-in-swift/ + func getErrorMessageKey(errorCode: Int) -> (Bool, String) { + + switch errorCode { + + case LAError.authenticationFailed.rawValue: + return (true, "Authentication was not successful, because user failed to provide valid credentials.") + + case LAError.appCancel.rawValue: + return (true, "Authentication was canceled by application (e.g. invalidate was called while authentication was in progress).") + + case LAError.invalidContext.rawValue: + return (true, "LAContext passed to this call has been previously invalidated.") + + case LAError.notInteractive.rawValue: + return (true, "Authentication failed, because it would require showing UI which has been forbidden by using interactionNotAllowed property.") + + case LAError.passcodeNotSet.rawValue: + return (true, "Authentication could not start, because passcode is not set on the device.") + + case LAError.systemCancel.rawValue: + return (true, "Authentication was canceled by system (e.g. another application went to foreground).") + + case LAError.userCancel.rawValue: + return (false, "Authentication was canceled by user (e.g. tapped Cancel button).") + + case LAError.userFallback.rawValue: + return (false, "Authentication was canceled, because the user tapped the fallback button (Enter Password).") + + default: + return (true, "Error code \(errorCode) not found") + } + + } + +} diff --git a/ui/BankingiOSApp/BankingiOSApp/Security/KeychainPasswordItem.swift b/ui/BankingiOSApp/BankingiOSApp/Security/KeychainPasswordItem.swift new file mode 100644 index 00000000..2d8f8c91 --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/Security/KeychainPasswordItem.swift @@ -0,0 +1,224 @@ +/* + Copyright (C) 2016 Apple Inc. All Rights Reserved. + See LICENSE.txt (https://developer.apple.com/library/archive/samplecode/GenericKeychain/Listings/LICENSE_txt.html#//apple_ref/doc/uid/DTS40007797-LICENSE_txt-DontLinkElementID_8) for this sample’s licensing information + + Abstract: + A struct for accessing generic password keychain items. + + License: + + Sample code project: GenericKeychain + Version: 4.0 + + IMPORTANT: This Apple software is supplied to you by Apple + Inc. ("Apple") in consideration of your agreement to the following + terms, and your use, installation, modification or redistribution of + this Apple software constitutes acceptance of these terms. If you do + not agree with these terms, please do not use, install, modify or + redistribute this Apple software. + + In consideration of your agreement to abide by the following terms, and + subject to these terms, Apple grants you a personal, non-exclusive + license, under Apple's copyrights in this original Apple software (the + "Apple Software"), to use, reproduce, modify and redistribute the Apple + Software, with or without modifications, in source and/or binary forms; + provided that if you redistribute the Apple Software in its entirety and + without modifications, you must retain this notice and the following + text and disclaimers in all such redistributions of the Apple Software. + Neither the name, trademarks, service marks or logos of Apple Inc. may + be used to endorse or promote products derived from the Apple Software + without specific prior written permission from Apple. Except as + expressly stated in this notice, no other rights or licenses, express or + implied, are granted by Apple herein, including but not limited to any + patent rights that may be infringed by your derivative works or by other + works in which the Apple Software may be incorporated. + + The Apple Software is provided by Apple on an "AS IS" basis. APPLE + MAKES NO WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION + THE IMPLIED WARRANTIES OF NON-INFRINGEMENT, MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE, REGARDING THE APPLE SOFTWARE OR ITS USE AND + OPERATION ALONE OR IN COMBINATION WITH YOUR PRODUCTS. + + IN NO EVENT SHALL APPLE BE LIABLE FOR ANY SPECIAL, INDIRECT, INCIDENTAL + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) ARISING IN ANY WAY OUT OF THE USE, REPRODUCTION, + MODIFICATION AND/OR DISTRIBUTION OF THE APPLE SOFTWARE, HOWEVER CAUSED + AND WHETHER UNDER THEORY OF CONTRACT, TORT (INCLUDING NEGLIGENCE), + STRICT LIABILITY OR OTHERWISE, EVEN IF APPLE HAS BEEN ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + Copyright (C) 2016 Apple Inc. All Rights Reserved. + +*/ + +import Foundation + +struct KeychainPasswordItem { + // MARK: Types + + enum KeychainError: Error { + case noPassword + case unexpectedPasswordData + case unexpectedItemData + case unhandledError(status: OSStatus) + } + + // MARK: Properties + + let service: String + + private(set) var account: String + + let accessGroup: String? + + // MARK: Intialization + + init(service: String, account: String, accessGroup: String? = nil) { + self.service = service + self.account = account + self.accessGroup = accessGroup + } + + // MARK: Keychain access + + func readPassword() throws -> String { + /* + Build a query to find the item that matches the service, account and + access group. + */ + var query = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup) + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecReturnAttributes as String] = kCFBooleanTrue + query[kSecReturnData as String] = kCFBooleanTrue + + // Try to fetch the existing keychain item that matches the query. + var queryResult: AnyObject? + let status = withUnsafeMutablePointer(to: &queryResult) { + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) + } + + // Check the return status and throw an error if appropriate. + guard status != errSecItemNotFound else { throw KeychainError.noPassword } + guard status == noErr else { throw KeychainError.unhandledError(status: status) } + + // Parse the password string from the query result. + guard let existingItem = queryResult as? [String : AnyObject], + let passwordData = existingItem[kSecValueData as String] as? Data, + let password = String(data: passwordData, encoding: String.Encoding.utf8) + else { + throw KeychainError.unexpectedPasswordData + } + + return password + } + + func savePassword(_ password: String) throws { + // Encode the password into an Data object. + let encodedPassword = password.data(using: String.Encoding.utf8)! + + do { + // Check for an existing item in the keychain. + try _ = readPassword() + + // Update the existing item with the new password. + var attributesToUpdate = [String : AnyObject]() + attributesToUpdate[kSecValueData as String] = encodedPassword as AnyObject? + + let query = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup) + let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) + + // Throw an error if an unexpected status was returned. + guard status == noErr else { throw KeychainError.unhandledError(status: status) } + } + catch KeychainError.noPassword { + /* + No password was found in the keychain. Create a dictionary to save + as a new keychain item. + */ + var newItem = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup) + newItem[kSecValueData as String] = encodedPassword as AnyObject? + + // Add a the new item to the keychain. + let status = SecItemAdd(newItem as CFDictionary, nil) + + // Throw an error if an unexpected status was returned. + guard status == noErr else { throw KeychainError.unhandledError(status: status) } + } + } + + mutating func renameAccount(_ newAccountName: String) throws { + // Try to update an existing item with the new account name. + var attributesToUpdate = [String : AnyObject]() + attributesToUpdate[kSecAttrAccount as String] = newAccountName as AnyObject? + + let query = KeychainPasswordItem.keychainQuery(withService: service, account: self.account, accessGroup: accessGroup) + let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) + + // Throw an error if an unexpected status was returned. + guard status == noErr || status == errSecItemNotFound else { throw KeychainError.unhandledError(status: status) } + + self.account = newAccountName + } + + func deleteItem() throws { + // Delete the existing item from the keychain. + let query = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup) + let status = SecItemDelete(query as CFDictionary) + + // Throw an error if an unexpected status was returned. + guard status == noErr || status == errSecItemNotFound else { throw KeychainError.unhandledError(status: status) } + } + + static func passwordItems(forService service: String, accessGroup: String? = nil) throws -> [KeychainPasswordItem] { + // Build a query for all items that match the service and access group. + var query = KeychainPasswordItem.keychainQuery(withService: service, accessGroup: accessGroup) + query[kSecMatchLimit as String] = kSecMatchLimitAll + query[kSecReturnAttributes as String] = kCFBooleanTrue + query[kSecReturnData as String] = kCFBooleanFalse + + // Fetch matching items from the keychain. + var queryResult: AnyObject? + let status = withUnsafeMutablePointer(to: &queryResult) { + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) + } + + // If no items were found, return an empty array. + guard status != errSecItemNotFound else { return [] } + + // Throw an error if an unexpected status was returned. + guard status == noErr else { throw KeychainError.unhandledError(status: status) } + + // Cast the query result to an array of dictionaries. + guard let resultData = queryResult as? [[String : AnyObject]] else { throw KeychainError.unexpectedItemData } + + // Create a `KeychainPasswordItem` for each dictionary in the query result. + var passwordItems = [KeychainPasswordItem]() + for result in resultData { + guard let account = result[kSecAttrAccount as String] as? String else { throw KeychainError.unexpectedItemData } + + let passwordItem = KeychainPasswordItem(service: service, account: account, accessGroup: accessGroup) + passwordItems.append(passwordItem) + } + + return passwordItems + } + + // MARK: Convenience + + private static func keychainQuery(withService service: String, account: String? = nil, accessGroup: String? = nil) -> [String : AnyObject] { + var query = [String : AnyObject]() + query[kSecClass as String] = kSecClassGenericPassword + query[kSecAttrService as String] = service as AnyObject? + + if let account = account { + query[kSecAttrAccount as String] = account as AnyObject? + } + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup as AnyObject? + } + + return query + } +} diff --git a/ui/BankingiOSApp/BankingiOSApp/TabBarController.swift b/ui/BankingiOSApp/BankingiOSApp/TabBarController.swift index 8109889b..e0d40749 100644 --- a/ui/BankingiOSApp/BankingiOSApp/TabBarController.swift +++ b/ui/BankingiOSApp/BankingiOSApp/TabBarController.swift @@ -88,27 +88,15 @@ class TabBarController : UITabBarController, UITabBarControllerDelegate { private func showNewOptionsActionSheet() { - let transferMoneyAction = UIAlertAction.default("Show transfer money dialog".localize()) { self.showView(TransferMoneyDialog()) } + let transferMoneyAction = UIAlertAction.default("Show transfer money dialog".localize()) { SceneDelegate.navigateToView(TransferMoneyDialog()) } transferMoneyAction.isEnabled = data.hasAccountsThatSupportTransferringMoney - alert.addAction(transferMoneyAction) - - alert.addAction(UIAlertAction(title: "Add account".localize(), style: .default, handler: { _ in self.showView(AddAccountDialog()) } )) - alert.addAction(UIAlertAction(title: "Cancel".localize(), style: .cancel, handler: nil)) - - if let popoverController = alert.popoverPresentationController { - popoverController.sourceView = self.tabBar - popoverController.sourceRect = CGRect(x: self.tabBar.bounds.midX, y: 0, width: 0, height: 0) - } - self.present(alert, animated: true, completion: nil) - } - - private func showView(_ view: Content) { - showViewController(UIHostingController(rootView: view)) - } - - private func showViewController(_ viewController: UIViewController) { - self.navigationController?.pushViewController(viewController, animated: true) + ActionSheet( + nil, + transferMoneyAction, + UIAlertAction.default("Add account") { SceneDelegate.navigateToView(AddAccountDialog()) }, + UIAlertAction.cancel() + ).show(self.tabBar, self.tabBar.bounds.midX, 0) } } diff --git a/ui/BankingiOSApp/BankingiOSApp/de.lproj/Localizable.strings b/ui/BankingiOSApp/BankingiOSApp/de.lproj/Localizable.strings index 941e9a85..e1348315 100644 --- a/ui/BankingiOSApp/BankingiOSApp/de.lproj/Localizable.strings +++ b/ui/BankingiOSApp/BankingiOSApp/de.lproj/Localizable.strings @@ -27,6 +27,19 @@ "Settings" = "Einstellungen"; +/* LoginDialog */ + +"Login Dialog title" = "Anmelden"; +"Authenticate to change app protection settings" = "Authentifizieren Sie sich um die Einstellungen zum Schutz der Appdaten zu ändern"; +"Authenticate with biometrics to unlock app reason" = "Authentifizieren Sie sich bitte um ihre Daten zu entsperren"; +"To unlock app please authenticate with FaceID" = "Authentifizieren Sich sich bitte mit FaceID um die App zu entsperren"; +"To unlock app please authenticate with TouchID" = "Authentifizieren Sich sich bitte mit TouchID um die App zu entsperren"; +"Enter your password" = "Geben Sie Ihr Passwort ein"; +"Login" = "Anmelden"; +"Authentication failed" = "Authentifizierung fehlgeschlagen"; +"Incorrect password entered" = "Falsches Passwort eingegeben"; + + /* SelectBankDialog */ "Select Bank Dialog Title" = "Bank auswählen"; @@ -52,6 +65,9 @@ "Could not add account" = "Konto konnte nicht hinzugefügt werden."; "Error message from your bank %@" = "Fehlermeldung Ihrer Bank:\n\n%@"; +"Secure data?" = "Daten schützen?"; +"Secure data with %@?" = "Hinuzufügen war erfolgreich.\n\nMöchten Sie Ihre Daten und Anmeldekennwörter durch Passwort, TouchID oder FaceID (falls vorhanden= schützen?\n\nSie können dies auch später in den Einstellungen vornehmen."; + /* AccountTransactionsDialog */ @@ -113,6 +129,11 @@ Ob eine Bank Gebühren für Echtzeitüberweisungen erhebt, kann Bankmeister leid "Could not change TAN medium to %@. Error: %@." = "TAN medium konnte nicht zu '%@' geändert werden.\n\nFehlermeldung Ihrer Bank:\n\n%@."; +/* SettingsDialog */ + +"Secure app data" = "Appdaten schützen"; + + /* BankSettingsDialog */ "Credentials" = "Zugangsdaten"; @@ -141,3 +162,15 @@ Ob eine Bank Gebühren für Echtzeitüberweisungen erhebt, kann Bankmeister leid "Supports Retrieving Account Transactions" = "Kontoumsätze abrufen"; "Supports Transferring Money" = "Überweisen"; "Supports Instant payment transfer" = "Echtzeitüberweisung"; + + +/* ProtectAppSettingsDialog */ + +"Protect App Settings Dialog title" = "Sicherheitseinstellungen"; // TODO: find a better title +"FaceID" = "FaceID"; +"TouchID" = "TouchID"; +"Password" = "Passwort"; +"Authenticate with TouchID" = "Mit TouchID authentifizieren"; +"Enter new password" = "Neues Passwort eingeben"; +"Confirm password" = "Bestätigen"; +"Confirm new password" = "Neues Passwort bestätigen"; diff --git a/ui/BankingiOSApp/BankingiOSApp/persistence/Extensions.swift b/ui/BankingiOSApp/BankingiOSApp/persistence/Extensions.swift index ee9cede1..b1fab28f 100644 --- a/ui/BankingiOSApp/BankingiOSApp/persistence/Extensions.swift +++ b/ui/BankingiOSApp/BankingiOSApp/persistence/Extensions.swift @@ -11,14 +11,16 @@ extension AppDelegate { extension SceneDelegate { - public static var currentWindow: UIWindow? { - UIApplication.shared.windows.first(where: { (window) -> Bool in window.isKeyWindow}) + public static var currentWindow: UIWindow { + UIApplication.shared.windows.first(where: { (window) -> Bool in window.isKeyWindow})! } - public static var currentScene: UIWindowScene? { currentWindow?.windowScene } + public static var currentScene: UIWindowScene { currentWindow.windowScene! } + + public static var current: SceneDelegate { currentScene.delegate as! SceneDelegate } public static var rootViewController: UIViewController? { - currentWindow?.rootViewController + currentWindow.rootViewController } public static var rootNavigationController: UINavigationController? { @@ -42,10 +44,28 @@ extension SceneDelegate { public static var currentNavigationItem: UINavigationItem? { currentViewController?.navigationItem } + + + public static func navigateToView(_ view: Content) { + navigateToViewController(UIHostingController(rootView: view)) + } + + public static func navigateToViewController(_ viewController: UIViewController) { + rootNavigationController?.pushViewController(viewController, animated: true) + } } +extension KeychainPasswordItem { + + init(_ accountName: String) { + self.init(service: "Bankmeister", account: accountName, accessGroup: nil) + } + +} + + extension Customer : Identifiable { public var id: UUID { UUID() } diff --git a/ui/BankingiOSApp/BankingiOSApp/ui/SwiftExtensions.swift b/ui/BankingiOSApp/BankingiOSApp/ui/SwiftExtensions.swift index c1c3e10c..bd47f793 100644 --- a/ui/BankingiOSApp/BankingiOSApp/ui/SwiftExtensions.swift +++ b/ui/BankingiOSApp/BankingiOSApp/ui/SwiftExtensions.swift @@ -2,7 +2,7 @@ import SwiftUI import CoreData -extension String { +extension StringProtocol { var isNotEmpty: Bool { return !isEmpty @@ -16,24 +16,6 @@ extension String { return !isBlank } - - func localize() -> String { - return NSLocalizedString(self, comment: "") - } - - // TODO: implement passing multiple arguments to localize() -// func localize(_ arguments: CVarArg...) -> String { -// return localize(arguments) -// } -// -// func localize(_ arguments: [CVarArg]) -> String { -// return String(format: NSLocalizedString(self, comment: ""), arguments) -// } - - func localize(_ arguments: CVarArg) -> String { - return String(format: NSLocalizedString(self, comment: ""), arguments) - } - subscript(_ i: Int) -> String { let idx1 = index(startIndex, offsetBy: i) @@ -54,6 +36,11 @@ extension String { } + var firstLetterUppercased: String { + prefix(1).uppercased() + dropFirst() + } + + /// /// Be aware that this crashes when used in a View's body method. So use it only e.g. in init() method /// @@ -79,6 +66,30 @@ extension String { } +extension String { + + + func localize() -> String { + return NSLocalizedString(self, comment: "") + } + + // TODO: implement passing multiple arguments to localize() + // func localize(_ arguments: CVarArg...) -> String { + // return localize(arguments) + // } + // + // func localize(_ arguments: [CVarArg]) -> String { + // return String(format: NSLocalizedString(self, comment: ""), arguments) + // return String(format: NSLocalizedString(self, comment: ""), getVaList(arguments)) + // } + + func localize(_ arguments: CVarArg) -> String { + return String(format: NSLocalizedString(self, comment: ""), arguments) + } + +} + + extension Optional where Wrapped == String { var isNullOrEmpty: Bool { diff --git a/ui/BankingiOSApp/BankingiOSApp/ui/SwiftUiRouter.swift b/ui/BankingiOSApp/BankingiOSApp/ui/SwiftUiRouter.swift index 887a3d7b..0c16df94 100644 --- a/ui/BankingiOSApp/BankingiOSApp/ui/SwiftUiRouter.swift +++ b/ui/BankingiOSApp/BankingiOSApp/ui/SwiftUiRouter.swift @@ -10,13 +10,9 @@ class SwiftUiRouter : IRouter { } func getTanFromUserFromNonUiThread(customer: Customer, tanChallenge: TanChallenge, presenter: BankingPresenter, callback: @escaping (EnterTanResult) -> Void) { - if let rootViewController = UIApplication.shared.windows.first(where: { (window) -> Bool in window.isKeyWindow})?.rootViewController as? UINavigationController { - let enterTanState = EnterTanState(customer, tanChallenge, callback) + let enterTanState = EnterTanState(customer, tanChallenge, callback) - let enterTanDialogController = UIHostingController(rootView: EnterTanDialog(enterTanState)) - - rootViewController.pushViewController(enterTanDialogController, animated: true) - } + SceneDelegate.navigateToView(EnterTanDialog(enterTanState)) } func getAtcFromUserFromNonUiThread(tanMedium: TanGeneratorTanMedium, callback: @escaping (EnterTanGeneratorAtcResult) -> Void) { diff --git a/ui/BankingiOSApp/BankingiOSApp/ui/UIKitExtensions.swift b/ui/BankingiOSApp/BankingiOSApp/ui/UIKitExtensions.swift index 48907b3b..b3f3d2b9 100644 --- a/ui/BankingiOSApp/BankingiOSApp/ui/UIKitExtensions.swift +++ b/ui/BankingiOSApp/BankingiOSApp/ui/UIKitExtensions.swift @@ -30,28 +30,20 @@ extension UIResponder { extension UIDevice { static var deviceType: UIUserInterfaceIdiom { - get { - return UIDevice.current.deviceType - } + UIDevice.current.deviceType } var deviceType: UIUserInterfaceIdiom { - get { - return self.userInterfaceIdiom - } + self.userInterfaceIdiom } static var isRunningOniPad: Bool { - get { - return UIDevice.current.userInterfaceIdiom == .pad - } + UIDevice.current.userInterfaceIdiom == .pad } var isRunningOniPad: Bool { - get { - return self.userInterfaceIdiom == .pad - } + self.userInterfaceIdiom == .pad } } diff --git a/ui/BankingiOSApp/BankingiOSApp/ui/views/AddAccountDialog.swift b/ui/BankingiOSApp/BankingiOSApp/ui/views/AddAccountDialog.swift index 29c54420..255755c8 100644 --- a/ui/BankingiOSApp/BankingiOSApp/ui/views/AddAccountDialog.swift +++ b/ui/BankingiOSApp/BankingiOSApp/ui/views/AddAccountDialog.swift @@ -100,13 +100,29 @@ struct AddAccountDialog: View { if (response.isSuccessful) { DispatchQueue.main.async { // dispatch async as may EnterTanDialog is still displayed so dismiss() won't dismiss this view - self.presentation.wrappedValue.dismiss() + self.closeDialog() + + DispatchQueue.main.async { + let authenticationService = AuthenticationService() + if self.presenter.customers.count == 1 && authenticationService.authenticationType == .unset { + authenticationService.setAuthenticationType(.none) + + UIAlert("Secure data?", "Secure data with?", + UIAlertAction.ok { SceneDelegate.navigateToView(ProtectAppSettingsDialog()) }, + UIAlertAction.cancel(self.closeDialog)) + .show() + } + } } } else { self.errorMessage = Message(title: Text("Could not add account"), message: Text("Error message from your bank \(response.errorToShowToUser ?? "")")) } } + + private func closeDialog() { + self.presentation.wrappedValue.dismiss() + } } diff --git a/ui/BankingiOSApp/BankingiOSApp/ui/views/Divider.swift b/ui/BankingiOSApp/BankingiOSApp/ui/views/Divider.swift index 8d5275e3..acf3922c 100644 --- a/ui/BankingiOSApp/BankingiOSApp/ui/views/Divider.swift +++ b/ui/BankingiOSApp/BankingiOSApp/ui/views/Divider.swift @@ -3,9 +3,9 @@ import SwiftUI struct Divider: View { - let height: CGFloat = 1 + var height: CGFloat = 1 - let color: Color = Color.black + var color: Color = Color.black var body: some View { diff --git a/ui/BankingiOSApp/BankingiOSApp/ui/views/FaceIDButton.swift b/ui/BankingiOSApp/BankingiOSApp/ui/views/FaceIDButton.swift new file mode 100644 index 00000000..a20ab8cc --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/ui/views/FaceIDButton.swift @@ -0,0 +1,38 @@ +import SwiftUI + + +struct FaceIDButton: View { + + private let widthAndHeight: CGFloat + + private let action: () -> Void + + + init(_ action: @escaping () -> Void) { + self.init(34, action) + } + + init(_ widthAndHeight: CGFloat, _ action: @escaping () -> Void) { + self.widthAndHeight = widthAndHeight + self.action = action + } + + + var body: some View { + Button(action: self.action) { + Image(systemName: "faceid") + .resizable() + .frame(width: widthAndHeight, height: widthAndHeight) + } + } + +} + + +struct FaceIDButton_Previews: PreviewProvider { + + static var previews: some View { + FaceIDButton { } + } + +} diff --git a/ui/BankingiOSApp/BankingiOSApp/ui/views/LoginDialog.swift b/ui/BankingiOSApp/BankingiOSApp/ui/views/LoginDialog.swift new file mode 100644 index 00000000..cf01997e --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/ui/views/LoginDialog.swift @@ -0,0 +1,129 @@ +import SwiftUI + + +struct LoginDialog: View { + + private let authenticationService: AuthenticationService + + private let allowCancellingLogin: Bool + + private let loginReason: LocalizedStringKey? + + private let loginResult: (Bool) -> Void + + + @State private var enteredPassword: String = "" + + + init(_ authenticationService: AuthenticationService = AuthenticationService(), allowCancellingLogin: Bool = false, loginReason: LocalizedStringKey? = nil, loginResult: @escaping (Bool) -> Void) { + + self.authenticationService = authenticationService + self.allowCancellingLogin = allowCancellingLogin + self.loginReason = loginReason + self.loginResult = loginResult + + if authenticationService.needsBiometricAuthenticationToUnlockApp { + self.loginWithBiometricAuthentication() + } + } + + + var body: some View { + VStack { + if authenticationService.needsFaceIDToUnlockApp { + VStack { + Spacer() + + Text(loginReason ?? "To unlock app please authenticate with FaceID") + .multilineTextAlignment(.center) + .padding() + .padding(.bottom, 30) + + FaceIDButton(50, self.loginWithBiometricAuthentication) + + Spacer() + } + } + else if authenticationService.needsTouchIDToUnlockApp { + VStack { + Spacer() + + Text(loginReason ?? "To unlock app please authenticate with TouchID") + .multilineTextAlignment(.center) + .padding() + .padding(.bottom, 35) + + TouchIDButton(self.loginWithBiometricAuthentication) + + Spacer() + } + } + else { + loginReason.map { loginReason in + Text(loginReason) + .multilineTextAlignment(.center) + .padding() + .padding(.bottom, 0) + } + + Form { + Section { + LabelledUIKitTextField(label: "Password", text: $enteredPassword, placeholder: "Enter your password", isPasswordField: true, focusOnStart: true, + actionOnReturnKeyPress: { self.loginWithPasswordOnReturnKeyPress() }) + } + + Section { + Button("Login") { self.loginWithPassword() } + .alignVertically(.center) + } + } + } + } + .navigationBarTitle("Login Dialog title") + .navigationBarItems(leading: allowCancellingLogin == false ? nil : createCancelButton { + self.closeDialogAndDispatchLoginResult(false) + }) + } + + + private func loginWithBiometricAuthentication() { + authenticationService.loginWithBiometricAuthentication(self.handleAuthenticationResult) + } + + private func loginWithPasswordOnReturnKeyPress() -> Bool { + loginWithPassword() + + return true + } + + private func loginWithPassword() { + authenticationService.loginWithPassword(enteredPassword, self.handleAuthenticationResult) + } + + private func handleAuthenticationResult(success: Bool, errorMessage: String?) { + // as .alert() didn't work (why SwiftUI?), displaying Alert now manually + if let errorMessage = errorMessage { + UIAlert("Authentication failed", errorMessage, UIAlertAction.ok()) + .show() + } + else if success { + closeDialogAndDispatchLoginResult(true) + } + } + + private func closeDialogAndDispatchLoginResult(_ authenticationSuccess: Bool) { + SceneDelegate.rootNavigationController?.popViewController(animated: false) + + self.loginResult(authenticationSuccess) + } + +} + + +struct LoginDialog_Previews: PreviewProvider { + + static var previews: some View { + LoginDialog { _ in } + } + +} diff --git a/ui/BankingiOSApp/BankingiOSApp/ui/views/ProtectAppSettingsDialog.swift b/ui/BankingiOSApp/BankingiOSApp/ui/views/ProtectAppSettingsDialog.swift new file mode 100644 index 00000000..0d49e59d --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/ui/views/ProtectAppSettingsDialog.swift @@ -0,0 +1,198 @@ +import SwiftUI + + +struct ProtectAppSettingsDialog: View { + + @Environment(\.presentationMode) var presentation + + + private let authenticationService = AuthenticationService() + + private let supportedAuthenticationTypes: [AuthenticationType] + + @State private var isFaceIDSelected: Bool = false + + @State private var isTouchIDSelected: Bool = false + + @State private var isPasswordSelected: Bool = false + + @State private var selectedAuthenticationTypeIndex = 0 + + private var selectedAuthenticationTypeIndexBinding: Binding { + Binding( + get: { self.selectedAuthenticationTypeIndex }, + set: { + if (self.selectedAuthenticationTypeIndex != $0) { // only if authentication type really changed + self.selectedAuthenticationTypeIndex = $0 + self.selectedAuthenticationTypeChanged(self.supportedAuthenticationTypes[$0]) + } + }) + } + + + @State private var newPassword: String = "" + + @State private var confirmedNewPassword: String = "" + + @State private var successfullyAuthenticatedWithBiometricAuthentication = false + + @State private var successfullyAuthenticatedWithPassword = false + + + init() { + var authenticationTypes = [AuthenticationType]() + + if authenticationService.deviceSupportsFaceID { + authenticationTypes.append(.faceID) + } + if authenticationService.deviceSupportsTouchID { + authenticationTypes.append(.touchID) + } + + authenticationTypes.append(.password) + + self.supportedAuthenticationTypes = authenticationTypes + + + let currentAuthenticationType = authenticationService.authenticationType + if currentAuthenticationType == .faceID || (currentAuthenticationType != .password && authenticationService.deviceSupportsFaceID) { + _isFaceIDSelected = State(initialValue: true) + _selectedAuthenticationTypeIndex = State(initialValue: 0) + } + else if currentAuthenticationType == .touchID || (currentAuthenticationType != .password && authenticationService.deviceSupportsTouchID) { + _isTouchIDSelected = State(initialValue: true) + _selectedAuthenticationTypeIndex = State(initialValue: 0) + } + else { + _isPasswordSelected = State(initialValue: true) + _selectedAuthenticationTypeIndex = State(initialValue: supportedAuthenticationTypes.firstIndex(of: .password)!) + } + } + + + var body: some View { + Form { + if supportedAuthenticationTypes.count > 1 { + Section { + Picker("", selection: selectedAuthenticationTypeIndexBinding) { + ForEach(0.. Bool { + if authenticatedWithNewAuthenticationType { + self.setAuthenticationType() + + return true + } + + return false + } + + private func setAuthenticationType() { + if isFaceIDSelected { + authenticationService.setAuthenticationType(.faceID) + } + else if isTouchIDSelected { + authenticationService.setAuthenticationType(.touchID) + } + else { + authenticationService.setAuthenticationTypeToPassword(newPassword) + } + + presentation.wrappedValue.dismiss() + } + +} + + +struct ProtectAppSettingsDIalog_Previews: PreviewProvider { + + static var previews: some View { + ProtectAppSettingsDialog() + } + +} diff --git a/ui/BankingiOSApp/BankingiOSApp/ui/views/SettingsDialog.swift b/ui/BankingiOSApp/BankingiOSApp/ui/views/SettingsDialog.swift index 24f926df..8bec675b 100644 --- a/ui/BankingiOSApp/BankingiOSApp/ui/views/SettingsDialog.swift +++ b/ui/BankingiOSApp/BankingiOSApp/ui/views/SettingsDialog.swift @@ -3,6 +3,8 @@ import BankingUiSwift struct SettingsDialog: View { + + @Environment(\.editMode) var editMode @ObservedObject var data: AppData @@ -24,6 +26,17 @@ struct SettingsDialog: View { .onMove(perform: reorderBanks) .onDelete(perform: deleteBanks) } + + Section { + NavigationLink(destination: EmptyView(), isActive: .constant(false)) { // we need custom navigation handling, so disable that NavigationLink takes care of navigating + Text("Secure app data") + .frame(maxWidth: .infinity, alignment: .leading) // stretch over full width + .background(Color.systemBackground) // make background tapable + } + } + .onTapGesture { + self.navigateToProtectAppSettingsDialog() + } } .alert(item: $askToDeleteAccountMessage) { message in Alert(title: message.title, message: message.message, primaryButton: message.primaryButton, secondaryButton: message.secondaryButton!) @@ -32,13 +45,11 @@ struct SettingsDialog: View { } private var footer: some View { - get { - HStack { - Spacer() - - NavigationLink(destination: LazyView(AddAccountDialog())) { - Text("Add") - } + HStack { + Spacer() + + NavigationLink(destination: LazyView(AddAccountDialog())) { + Text("Add") } } } @@ -60,13 +71,44 @@ struct SettingsDialog: View { } func askUserToDeleteAccount(_ bankToDelete: Customer) { - self.askToDeleteAccountMessage = Message.createAskUserToDeleteAccountMessage(bankToDelete, self.deleteAccount) + self.askToDeleteAccountMessage = Message.createAskUserToDeleteAccountMessage(bankToDelete, self.deleteAccountWithSecurityChecks) } - func deleteAccount(_ bankToDelete: Customer) { + func deleteAccountWithSecurityChecks(_ bankToDelete: Customer) { // don't know why but when deleting last bank application crashes if we don't delete bank async DispatchQueue.main.async { - self.presenter.deleteAccount(customer: bankToDelete) + if self.presenter.customers.count == 1 { + self.editMode?.wrappedValue = .inactive + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.deleteAccount(bankToDelete) + } + } + else { + self.deleteAccount(bankToDelete) + } + } + } + + private func deleteAccount(_ bankToDelete: Customer) { + self.presenter.deleteAccount(customer: bankToDelete) + } + + + private func navigateToProtectAppSettingsDialog() { + let authenticationService = AuthenticationService() + + if authenticationService.needsAuthenticationToUnlockApp == false { + SceneDelegate.navigateToView(ProtectAppSettingsDialog()) + } + else { + let loginDialog = LoginDialog(authenticationService, allowCancellingLogin: true, loginReason: "Authenticate to change app protection settings") { authenticationSuccess in + if authenticationSuccess { + SceneDelegate.navigateToView(ProtectAppSettingsDialog()) + } + } + + SceneDelegate.navigateToView(loginDialog) } } diff --git a/ui/BankingiOSApp/BankingiOSApp/ui/views/TouchIDButton.swift b/ui/BankingiOSApp/BankingiOSApp/ui/views/TouchIDButton.swift new file mode 100644 index 00000000..627edc1e --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/ui/views/TouchIDButton.swift @@ -0,0 +1,27 @@ +import SwiftUI + + +struct TouchIDButton: View { + + private let action: () -> Void + + + init(_ action: @escaping () -> Void) { + self.action = action + } + + + var body: some View { + Button("Authenticate with TouchID", action: self.action) + } + +} + + +struct TouchIDButton_Previews: PreviewProvider { + + static var previews: some View { + TouchIDButton { } + } + +}