LastPass clone - part 4

SwiftUI Apr 06, 2020

Hey guys! Last week’s post, we implemented local authentication using Cryptokit , Touch ID and Face ID. In this one, we will improve the authentication manager, and finalise all authentication related views.

Preparation

You should already have the source code link in your email if you are subscribed, otherwise subscribe here to download it.

Authentication manager improvement.

There are a few things we need to do in order to allow the said user to login or sign up.

  1. On the sign up view, if there’s already an account associated with the device, we need to show a message telling the user that there’s an existing account.
  2. On the login view, we need to tell the user that the provided details are not correct upon clicking the login button.
  3. We need to clear the inputs when switching from login view to sign up and vice-versa.
  4. We will also allow the user to reset the password.
  5. Right now if you delete the app from your phone, all existing credentials will not be deleted from keychain. This behaviour will give the user headaches when he re-installs the app in the future having used the app in the app. So, we will need to find a way of getting around that.

That’s our agenda for this article. You should try figuring out how to do this by yourself first, and then comeback to see how I tackled the aforementioned problems. Let’s get started.

Sign up

Let’s start with the sign up view. In the Authentication Manager class, replace the createAccount method with the following:

     func createAccount() -> Bool {
        guard !hasAccount() else { return false }
        let hashedPassword = hashPassword(password)
        let emailResult = keychain.set(email.lowercased(), forKey: AuthKeys.email, withAccess: .accessibleWhenPasscodeSetThisDeviceOnly)
        let passwordResult = keychain.set(hashedPassword, forKey: AuthKeys.password, withAccess: .accessibleWhenPasscodeSetThisDeviceOnly)
        if emailResult && passwordResult {
            login()
            return true
        }
        return false
    }

What we’ve just done here is returning a boolean value indicating whether the account creation process has been successful or a failure. We will modify the AccountCreationView next.

Open the AccountCreationView, and add the following property to the top of the struct.

@State private var showAlert = false

Then replace the line inside the sign up button’s action like this:

self.showAlert = !self.authManager.createAccount()

Now, directly after the .disabledmodifier, add the following:

.alert(isPresented: self.$showAlert) {
                        Alert(title: Text("Error") , message: Text("Oops! Seems like there's already an account associated with this device. You need to login instead.") , dismissButton: .default(Text("Ok")) )
                    }

We will show an alert telling the user that there is already an account associated with the device. And with that, we say bye bye to the AccountCreationView .

Login View

Before we start modifying the LoginView , let’s first take care of small things in the AuthenticationManager. So, in the authenticate method, add the following condition before everything:

       if !hasAccount(){
            return false
        }

Here, we first check if the user is already registered, otherwise we return false because there’s no need to proceed any further if there is no account.

Open the LoginView.swift, and add the following property to the top of the struct.

@State private var showAlert = false

Then replace the line inside the login button’s action like this:

self.showAlert = !self.authManager.authenticate()

Now, directly after the .disabledmodifier, add the following:

 .alert(isPresented: self.$showAlert) {
                        if self.authManager.hasAccount(){
                            return Alert(title: Text("Error") , message: Text("Oops!The credentials you've provided are not correct, try again.") , dismissButton: Alert.Button.default(Text("Ok")) )
                        }
                            return Alert(title: Text("Error") , message: Text("Oops! You don't have an account yet, sign uo instead.") , dismissButton: Alert.Button.default(Text("Ok")) )
  }

Run the project and test both the signup and login views. You should be testing the app on a real device.

Clear inputs when switching sign up and login views.

Right now, when you write something in LoginView, and then switch to the CreateAccountView without clearing input fields, you will still see the same values.

In the AccountCreationView, add the following in the goToLoginButton’s action block:

self.authManager.email = ""
self.authManager.password = ""
self.authManager.confirmedPassword = ""

Then in the LoginView, add this in the createAccountButton’s action block:

self.authManager.email = ""
self.authManager.password = ""

That will clear all input fields.

Reset password

  1. AuthenticationManager

First of all, let’s make some changes in the AuthenticationManager class.

Open the AuthenticationManager class and rename the authenticateWithBiometric function to loginWithBiometric.

We need 3 states when authenticating with biometric, add this enum inside the AuthenticationManager class that will hold those states:

    enum BiometricResult {
        case success
        case failure
        case none
    }

Before the authentication process begins, the initial state will be none, then on success and failure, we will return the remaining 2.

Now find the biometryPublisher, and replace it with the following:

    private lazy var biometryPublisher: Future<BiometricResult, Never> = {
          Future<BiometricResult, Never> {[unowned self] promise in
               let myLocalizedReasonString = "Replace with your description explaining why you want to use biometrics"
               var authError: NSError?
               self.laContext.localizedFallbackTitle = "Please use your Passcode"
               if self.canAuthenticate(error: &authError) {
                   self.laContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: myLocalizedReasonString) { success, evaluateError in
                    return  promise(.success(BiometricResult.success))
                   }
               } else {
                   print(authError ?? "")
                return promise(.success(BiometricResult.failure))
               }
           }
       }()

What we’ve just done here is replacing the Bool return type with BiometricResult return type, and change all places affected with that change inside the block.

We will need to change the content of the loginWithBiometric method , so find it, and replace the content with the following:

guard hasAccount() else { return }
        biometryPublisher
            .receive(on: DispatchQueue.main)
            .sink(receiveValue: { result in
                switch result{
                case .success:
                    self.isLoggedIn = true
                case .failure:
					self.isLoggedIn = false
                    print("Error authenticating with biometrics")
                case .none:
                    break
                }
            })
            .store(in: &self.cancellableSet)

We replace the assign operator with the sink, which are both used to subscribe to publishers, because we want to set the isLoggedIn manually using the BiometricResult states passed down the publisher pipeline. The rest of the code is pretty self-explanatory .

Here are apple’s definitions of sink and assign:

sink(receiveCompletion:receiveValue:) takes two closures. The first closure executes when it receives Subscribers.Completion, which is an enumeration that indicates whether the publisher finished normally or failed with an error. The second closure executes when it receives an element from the publisher.
assign(to:on:) immediately assigns every element it receives to a property of a given object, using a key path to indicate the property.

Now let’s implement the method that will be used in the popup. It does the same thing as the one we’ve just worked on, except that we are still using the assign operator. So add the following below the loginWithBiometric function:

    func authenticateWithBiometric()  {
        guard hasAccount() else { return }
        biometryPublisher
            .receive(on: DispatchQueue.main)
            .assign(to: \.biometricResult, on: self)
            .store(in: &self.cancellableSet)
        
    }
    

We don’t have the biometricResult property yet, add it to the top like this:

    @Published var biometricResult = BiometricResult.none

We set the initial value to .`none like I told you earlier. We assign a new value coming from the publisher’s pipeline whether it’s success or failure. We will use it in the Loginview shortly.

We need to create a resetPasswordPublisher that will listen and validate the reset password entered by the user. Let’s just do that:

Add the following to the top:

@Published var resetPassword = ""
@Published var resetPasswordValidation = FormValidation()

Next add this below the passwordPublisher:

 private var resetPasswordPublisher: AnyPublisher<FormValidation, Never> {
         self.$resetPassword.debounce(for: 0.2, scheduler: RunLoop.main)
             .removeDuplicates()
             .map { password in
                 
                 if password.isEmpty{
                     return FormValidation(success: false, message: "")
                 }
                 if password.count < Config.recommendedLength{
                     return FormValidation(success: false, message: "The password length must be greater than \(Config.recommendedLength) ")
                 }
                 
                 
                 if !Config.passwordPredicate.evaluate(with: password){
                     return FormValidation(success: false, message: "The password is must contain numbers, uppercase and special characters")
                 }
                 
                 return FormValidation(success: true, message: "")
         }.eraseToAnyPublisher()
     }
    

This property is the exact copy of the passwordPublisher Check out this post to know more about I have done above.

Then add the following in the init function to subscribe to the above publisher:

      resetPasswordPublisher
               .assign(to: \.resetPasswordValidation, on: self)
               .store(in: &self.cancellableSet)

Replace the hashPassword with this one:

    private func hashPassword(_ password: String, reset: Bool = false) -> String {
        var salt = ""
        
        if let savedSalt = keychain.get(AuthKeys.salt), !reset{
            salt = savedSalt
        } else {
            let key = SymmetricKey(size: .bits256)
            salt = key.withUnsafeBytes({ Data(Array($0)).base64EncodedString() })
            keychain.set(salt, forKey: AuthKeys.salt)
        }

        guard let data = "\(password)\(salt)".data(using: .utf8) else { return "" }
        let digest = SHA256.hash(data: data)
        return digest.map{String(format: "%02hhx", $0)}.joined()
    }

The function is still the same, we’ve just added a new boolean parameter to check whether the function is being used for password reset or not. We don’t want to re-use the same salt for password reset. It is safer to generate a new salt for each password reset.

Next add the following function below :

    func saveResetPassword() -> Bool {
        let hashedPassword = hashPassword(resetPassword, reset: true)
        let passwordResult = keychain.set(hashedPassword, forKey: AuthKeys.password, withAccess: .accessibleWhenPasscodeSetThisDeviceOnly)
        return passwordResult
    }

This one will be used to save the new hashed password in Keychain.

  1. Views

Now is the time to create a reset password mechanism. In the LoginView struct, below the createAccountButton function, add the following:

    fileprivate func createResetPasswordButton() -> some View {
           return Button(action: {
               withAnimation(.spring()) {
                   self.showCreateAccount.toggle()
               }
           }) {
               HStack {
                   Image(systemName: "arrow.counterclockwise")
                   .resizable()
                       .aspectRatio(contentMode: .fit)
                   .frame(height: 20)
                       .foregroundColor(Color.darkerAccent)
                   Text("Reset Password")
                       .accentColor(Color.darkerAccent)
               }
           }
       }

This function just creates a button similar to the one above.

Then add the following in the createContent function, directly below create the call to createAccountButton():

 if self.authManager.hasAccount(){
                Text("OR").bold()
                createResetPasswordButton()
    }

We will only show the reset button if there is already an account

Let’s now create the popupView that we will show the user. First, add the following function below the createResetPasswordButton() function:

      private func createBiometricButton(action: @escaping () -> Void) -> some View {
        Button(action: action) {
            
            if self.authManager.biometryType == LABiometryType.faceID{
                BiometricButtonLabel(icon: "faceid", text: "Use face ID")
            }
            
            if self.authManager.biometryType == LABiometryType.touchID{
                BiometricButtonLabel()
            }
        }
    }

This function just recreates the biometric button in order to use it in multiple places. You still don’t have the BiometricButtonLabel view. Add a swift ui file named BiometricButtonLabel.swift in the Views folder, and put the following code inside:

import SwiftUI

struct BiometricButtonLabel: View {
    var icon = "touchID"
    var text = "Use touch ID"
    
    var body: some View {
        VStack {
            Image("touchID" )
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 40, height: 40)
            Text("Use touch ID")
        }.foregroundColor(Color.accent)
    }
}


struct BiometricButtonLabel_Previews: PreviewProvider {
    static var previews: some View {
        BiometricButtonLabel()
    }
}

Now replace the entire button inside the hasAccount condition with the following:

createBiometricButton {
                             self.authManager.loginWithBiometric()
                        }

The condition should look like this:

if self.authManager.hasAccount(){
                        createBiometricButton {
                             self.authManager.loginWithBiometric()
                        }
                    }

Before creating the popup views, add this to the top:

    @State private var showResetPasswordPopup = false

This is a flag that will be used to show and hide the popup.

Next, add the following function below the one you’ve just added:

       private func biometricPopupView() -> some View {
        ZStack(alignment: .topTrailing) {
            VStack(spacing: 20) {
                Text(self.authManager.biometricResult == .failure ? "Failed" : "Authentication required")
                    .bold()
                    .foregroundColor(self.authManager.biometricResult == .failure ? .red : .accent)
                createBiometricButton {
                     self.authManager.authenticateWithBiometric()
                }
            }.frame(width: 250, height: 200)
                .background(Color.background)
                .cornerRadius(20)
            
            Button(action: {
                self.showResetPasswordPopup.toggle()
            }) {
                Image(systemName: "xmark")
                    .imageScale(.large)
                    .padding()
                    .foregroundColor(.gray)
                    .opacity(0.6)
            }
        }
    }

This one create a popup view that presents the user with a biometric button with which he can authenticate. If the authentication is successful, he will then be presented with yet another popup view containing a password reset textfield.

Add the following below to create the password reset textfield:

       private func resetPassordPopupView() -> some View {
        ZStack(alignment: .topTrailing) {
            VStack(spacing: 40) {
                Text("Set your new password")
                    .bold()
                    .foregroundColor(.accent)
                PasswordField(value: self.$authManager.resetPassword, header: "New Password", placeholder: "New password goes here...", errorMessage: authManager.resetPasswordValidation.message , isSecure: true)
                
                LCButton(text: "Save", backgroundColor: self.authManager.resetPasswordValidation.success ? Color.accent : Color.gray) {
                    self.showResetPasswordPopup = !self.authManager.saveResetPassword()
                }.disabled(!self.authManager.resetPasswordValidation.success)
            }.padding(.horizontal).frame(maxWidth: .infinity, maxHeight: 280)
                .background(Color.background)
                .cornerRadius(20)
            
            Button(action: {
                self.showResetPasswordPopup.toggle()
            }) {
                Image(systemName: "xmark")
                .imageScale(.large)
                .padding()
                .foregroundColor(.gray)
            }
            
        }
    }

This creates the pop view with a reset password textfield.

Next, add the following function:

    func showPopups() -> some View {
        switch self.authManager.biometricResult {
        case .success:
            return AnyView(resetPassordPopupView().padding())
        default:
            return AnyView(biometricPopupView())
        }
    }

This is a simple function that shows an appropriate popup based on the biometric result.

The last change we will make is to replace the body with the following:

   ZStack {
            SubscriptionView(content: createContent(), publisher: NotificationCenter.keyboardPublisher) { frame in
                withAnimation {
                    self.formOffset = frame.height > 0 ? -150 : 0
                }
            }
            
            VStack{
                if showResetPasswordPopup{
                    showPopups()
                     .offset(y: self.formOffset)
                        .transition(.scale)
                }
            }.frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(Color.black.opacity(0.6))
                .opacity(showResetPasswordPopup ? 1 : 0)
                .animation(.spring())

           
        }

This code will animate in the popup view.

Delete keychain items when the app is deleted from device

Items saved in keychain will remain in it unless you explicitly remove them even if the app is completely deleted from the device. Depending on the functionality of your app, this behaviour may cause big problems. To delete the items from the keychain, we will need to check whether the app has just been installed or re-installed so that we can delete the items from the device’s keychain before initial use.

Now, open AuthKeys.swift file , and the following line inside the struct:

 static let initialLaunchKey = "com.liquidcoder.initialLaunch"

Then open the SceneDelegate.swift file. Inside the first method, the scene(_, willConnectTo, options) one, add the following inside:

     if !UserDefaults.standard.bool(forKey: AuthKeys.initialLaunchKey) {
            authManager.deleteAccount()
            UserDefaults.standard.set(true, forKey: AuthKeys.initialLaunchKey)
      }
            

What we are doing here is checking whether the app has just been launched for the first, then we delete the items from the keychain, and last we set the initialLaunchKey to true in UserDefaults. Contrary to keychain’s items, UserDefaults items will be deleted automatically whenever the app is deleted from the device.

Log out when the app enters the background.

In order to protect the data in the app, we will need log out as soon as the app enters the background. Entering background means the app got interrupted by an incoming call or you’ve have opened another app while the current is still opened in the background.

In the SceneDelegate.swift , add the following line in sceneWillResignActive and sceneDidEnterBackground:

self.authManager.logout()

That’s it folks. In the next article, we will start working on the home screen. Feel free to share this article, subscribe if you haven’t done so already, and HAPPY CODING!!!

John K

I am a software developer and code enthusiast. Do you want to work with me, have a suggestion or a request? Feel free to contact me at [email protected] or https://twitter.com/liquidcoder