LastPass clone - part 3

lastpass redesign Mar 15, 2020

Local authentication using CryptoKit, Keychain and UserDefaults or Biometrics (Face ID and Touch ID)

Last week (actually 3 weeks ago), I published part 2 of this ongoing series in which we created validations for the 2 authentication forms using the combine framework (login and sign up), if you haven’t read it, you should read it first, check it out here. In this one, we will perform local authentication using CryptoKit (the newly released native swift cryptographic library available from iOS 13), Keychain and the good old UserDefaults. We will also provide an alternative authentication method using Touch ID or Face ID depending on the device’s support.

You already know the drill. If you are subscribed, check your email inbox for the source code, otherwise click here to get it.

Import the KeychainSwift Package using Swift package manager

The native keychain library is a little bit overwhelming to work with that’s why we will need to use a third party wrapper to simplify the interaction with its API.

So in your Xcode project, select File -> Swift Package Manager -> Add Package Dependency like this:

Paste in this url (https://github.com/evgenyneu/keychain-swift.git), and click next, and click again next, once the package has been fetched, click the finished button. After that, you are ready to start working with the library.

Most of the work will be done in the AuthenticationManaager class. To the top and below all the existing properties, add the following code:

@Published var isLoggedIn = false
@Published var userAccount = User()
private var keychain = KeychainSwift()

All of those are pretty much self-explanatory.

There was an LAContext object already declared, here is apple’s definition of it.

laContext: A mechanism for evaluating authentication policies and access controls. You use an authentication context to evaluate the user’s identity, either with biometrics like Touch ID or Face ID, or by supplying the device passcode. We will basically use that to check whether the device support any biometric and whether we can use it to authenticate.

Create account and login

Before diving into creating the account, let’s take care of some stuff first. In the same folder, add a file named AuthKeys.swift containing the following code:

struct AuthKeys {
    static let isLoggedIn = "com.liquidcoder.isLoggedIn"
    static let hasAccount = "com.liquidcoder.hasAccount"
    static let email = "com.liquidcoder.email"
    static let password = "com.liquidcoder.password"
    static let salt = "com.liquidcoder.salt"
}

Those are just keys that we will use to store credentials in UserDefaults or Keychain.

Then create a file named User.swift in the Models folder containing the following struct:

import Foundation

struct User {
    var email = ""
    var password = ""
}

Now, add the following methods in the AuthenticationManager

     func hasAccount() -> Bool {
        keychain.get(AuthKeys.email) != nil
    }
    func createAccount()  {
        guard !hasAccount() else { return }
        let hashedPassword = hashPassword(password)
        keychain.set(email.lowercased(), forKey: AuthKeys.email,withAccess: .accessibleWhenPasscodeSetThisDeviceOnly)
        keychain.set(hashedPassword, forKey: AuthKeys.password,withAccess: .accessibleWhenPasscodeSetThisDeviceOnly)
        login()
    }

The first method checks whether this device already has a SinglePass account associated with it. We then use that method in the second method to check before creating a new account. So we will only create an account if there’s no existing account. So, step by step:

  • We get the hashed password from the hashPassword method which I’ll explain shortly.
  • We then save the email and password in the device’s keychain.
  • Last, we login which will also be created shortly.

Next, add the following function below the one above:

func login() {
    userDefaults.set(true, forKey: AuthKeys.isLoggedIn)
    self.isLoggedIn = true
}

Here we save a true boolean value in the UserDefaults to indicate that the user is logged in, and set the isLoggedIn published property to true to trigger the user interface to refresh and dismiss the AuthenticationView.

Next, add this below:

 private func hashPassword(\_ password: String) -> String {
	  var salt = ""
        
        if let savedSalt = keychain.get(AuthKeys.salt) {
            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()
}

Here is what we are doing here:

  • We generate the salt for each user that will be (prepended) or appended to whatever the user types as a password before generating the hash. we will save both the salt and the hashed password in the keychain. The salt is used to make the password stronger in case the user types in a weak or common password.
  • We append the salt to the password to make the password much stronger.
  • We then use CryptoKit to generate a hash value from the salted password which we will then save in the device’s keychain. You need to import CryptoKit before using it. Saving a hash instead of the real password will make the account more secure in the sense that no one will ever know the user’s password even if the keychain is compromised thanks to the salt.

Authenticate and logout

In order to login, some conditions must be met first, naming:

  1. There should be an account associated with the device
  2. The password and email must be the same as the ones saved in the device’s keychain.

So let’s do that now. Below the methods you’ve created, add the following:

func authenticate(username: String, password: String) -> Bool{
    if let savedEmail = keychain.get(AuthKeys.email), let savedPassword = keychain.get(AuthKeys.password){
        let hashedPassword = hashPassword(password)
        if savedEmail == email.lowercased() && hashedPassword == savedPassword{
            login()
            return true
        }
    }
    return false
}

Here is what we are doing above:

  • Retrieve the saved email and password.
  • Check whether the typed email is the same as the saved one and the typed password is equal to the hashed password because the hash function will always return the same hash for a given value. Hashes also have the property that if the input changes by even a tiny bit, the resulting hash is completely different.
  • If everything is the same, we login.

Signing out is the same as logging in. Just reset the user default value for isLoggedIn key to false and set the isLoggedIn to false to update the user interface. Here is the code for that:

 func logout() {
    userDefaults.set(false, forKey: AuthKeys.isLoggedIn)
    self.isLoggedIn = false
}

We will also give the user the ability to delete his account. Here is the method that will do just that:

     func deleteAccount()  {
        keychain.delete(AuthKeys.email)
        keychain.delete(AuthKeys.password)
        keychain.delete(AuthKeys.salt)
        logout()
    }

We will later delete all data associated with this device.

That’s done for now. Let’s move on to Biometrics authentication.

Touch ID, Face ID or Password

Before we go any further, we need to add a usage description key value in the info.plist file to allow the use of either Face ID or Touch ID, and also to prevent the app from crashing. To do that, open the info.plist file as source code like this:

And put the key and value (string) inside the dict tag like this:

...
	<key>NSFaceIDUsageDescription</key>
	<string>Replace with your description explaining why you want to use biometrics</string>
...

Replace the string value with the reason why you want to use either Touch ID or Face ID.

Now add the following method to the bottom of the AutheticationManager class:

 func canAuthenticate(error: NSErrorPointer) -> Bool{
       self.laContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: error)
    }

This checks whether we can authenticate using the given policy. There 4 policies that you can choose from. The one the we are using will allow the user to authenticate with either biometry, Apple Watch, or the device passcode.You can read more about those policies here.

Note : Delete the existing canAuthenticate function if there’s one.

After that condition has succeeded, we will then be able to evaluate the actual policy using this code :

Add the following to the top but below all other publishers.

  private lazy var biometryPublisher: Future<Bool, Never> = {
       Future<Bool, Never> {[unowned self] promise in
            let localizedReasonString = "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(success))
                }
            } else {
                print(authError ?? "")
                return promise(.success(false))
            }
        }
    }()
    

Here is what we are doing here:

  1. We are using combine’s Future to wrap the evaluation’s asynchronous call into a publisher to use the result in a combine pipeline.
  2. Inside the future block, we start by capturing self to avoid memory leaks.
  3. We first check whether we can evaluate, if yes, we proceed with the same evaluation policy we checked with. The localised string is the one that will be displayed in the authentication dialog presented to the user.
  4. The evaluation will give us a boolean flag indicating whether the evaluation succeeded or failed, and an error which we will just print to the console for simplicity (In production, you will need to handle errors properly).
  5. We then return a promise that was provided by Future containing the boolean flag which will be passed to the downstream publisher.
  6. If the device can not evaluate the policy, we return a promise with false.

Now add the following function below everything in the class:

   func authenticateWithBiometric()  {
        biometryPublisher
			.receive(on: DispatchQueue.main)
            .assign(to: \.isLoggedIn, on: self)
            .store(in: &self.cancellableSet)
    }

Here is what this code is doing:

  1. Using the biometryPublisher, we move the remaining operations to the main thread using the .receive operator.
  2. Now every operation will done on the main thread because you can not update the user interface on a background thread.
  3. We then use the assign operator to set the isLoggedIn property with the value that came from the pipeline to update the user interface accordingly.
  4. Last, we store yet another cancellable object in the set.
You can read more about the combine framework here. If you are following along with the part 2 project, there is an existing authenticateWithBiometric function, delete it please!

We will reuse the biometryPublisher later on to reset the user’s password in case that user has forgotten it. Before we continue, let’s first add the code that will check whether the device support Touch ID or Face ID.

At the top of the class, add the following property:

@Published var biometryType = LABiometryType.none

The LABiometryType enum is from the LocalAuthentication package by apple. So we create this property as published because we will use it to update the user interface.

Then below all of the functions, add the following code:

    func getBiometryType() {
        var authError: NSError?
        if canAuthenticate(error: &authError){
            self.biometryType = laContext.biometryType
        }
    }

You first need to call canEvaluatePolicy in order to get the biometry type. That is, if you're just doing LAContext().biometryType then you'll always get 'none' back. You would first need to call canEvaluatePolicy on that instance, and then biometryType should have a non-none value (assuming the device has biometry support, and the user has enabled it). Call that function in the init method to set the biometryType.

Now is the time to update the user interface with what we’ve just done above.

Updating the User interface

We will start with the account creation view. Now open the AccountCreationView.swift, and add the following code in the sign up action closure.

 self.authManager.createAccount()                  

The above code does not need much explanation, we are just calling the createAccount() method which will take care of saving user’s detail in the device’s keychain. And that’s all we need to do here. Let’s implement the remaining bit of the login view.

Open the LoginView.swift file, and replace the following button:

  Button(action: {
                
                    }) {
                        VStack {
                            Image(systemName: "faceid")
                            .resizable()
                                .aspectRatio(contentMode: .fit)
                            .frame(width: 40, height: 40)
                                .foregroundColor(Color.accent)
                            Text("Use face ID").foregroundColor(.accent)
                        }
                    }

With this:

 if self.authManager.hasAccount(){
                        Button(action: {
                            self.authManager.authenticateWithBiometric()
                        }) {
                            
                            if self.authManager.biometryType == LABiometryType.faceID{
                                VStack {
                                    Image(systemName: "faceid" )
                                    .resizable()
                                        .aspectRatio(contentMode: .fit)
                                    .frame(width: 40, height: 40)
                                    Text("Use face ID")
                                }.foregroundColor(Color.accent)
                            }
                            
                            if self.authManager.biometryType == LABiometryType.touchID{
                                VStack {
                                    Image("touchID" )
                                        .resizable()
                                        .aspectRatio(contentMode: .fit)
                                        .frame(width: 40, height: 40)
                                    Text("Use touch ID")
                                }.foregroundColor(Color.accent)
                            }
                        }
                    }

The above code will show either a face id button or touch id button or no button at all depending on the device’s support of biometric authentication. When a user clicks the button, he will be able to login using just touch id or face id without needing to type in the email and password. The biometric button will only show up if there’s an account associated with the device.

Let’s now implement the login function using the email and password. So add the following code inside the login button’s action closure:

 self.authManager.authenticate()

This code will only run when the button is enabled, meaning the inputs are valid and we can authenticate successfully. That’s it for the login and sign up functions. We are missing the password reset functionality and some other minor things that we will implement in the next post.

That’s it for this part folks. In the next one, we will improve the authentication manager before moving on to creating the main screen. Don’t forget to subscribe if you haven’t done so already, share and HAPPY CODING!!!

If you want to support my work, do so by becoming my patreon here

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