Biometric Authentication on iOS

Biometric Authentication on iOS

Authentication via some kind of credentials like email, username phone number and password is classic login. Entering this data every time can be annoying. As developers, we can save them to keychain, retrieve on login attempt, make HTTP request in background, update token or do any other authentication process your app has. But sometimes your app may contain very sensitive or private data that user wishes to keep secure.

Apple takes care of keeping user data protected as much as possible. In 2013 they made the first iPhone with Touch ID – fingerprint scanner. They decided to go further and create an even more fascinating technology called Face ID, presented in 2017 for iPhone X generation and on.

These options, based on your iPhone model, offer a quick and easy opportunity for implementing biometric authentication. Let’s do it!

Tutorial

Start by creating a protocol to describe methods our biometric authenticator has to implement.

 

protocol Authenticator {
    func canAuthenticate() -> Bool
    func faceIDAvailable() -> Bool
    func touchIDAvailable() -> Bool
    
    func authenticateWithBioMetrics(reason: String, fallbackTitle: String?, cancelTitle: String?, completion: @escaping (Result<Bool, AuthenticationError>) -> Void)
    func authenticateWithPasscode(reason: String, cancelTitle: String?, completion: @escaping (Result<Bool, AuthenticationError>) -> ())
}

 

The first function is necessary as we should check if the device supports biometric authentication. The next two provide Bool value with availability of sensors Face ID or Touch ID accordingly. We will focus on authenticate functions precisely during their implementation.

The next class we create will be BiometricAuthenticator and will conform to our Authenticator protocol. To have access for leverage biometric or passcode mechanisms we should import LocalAuthentication framework into the project. The last preparation step before writing actual code is to include the NSFaceIDUsageDescription key in your app’s Info.plist file, otherwise, authorization requests may fail.

Start from adding variables:

 

class BiometricAuthenticator {
    
    // MARK: - Private
    //1
    private(set) var state: AuthenticationState = .notAuthenticated
    //2
    private var context = LAContext()

    // MARK: - Public
    //3
    public var allowableReuseDuration: TimeInterval? = nil {
        didSet {
            guard let duration = allowableReuseDuration else {
                return
            }
            self.context.touchIDAuthenticationAllowableReuseDuration = duration
        }
    }
}

 

  1. Enum with authentication states we define above the class.

 

enum AuthenticationState {
    case notAuthenticated
    case inProgress
    case authenticated
    case canceled
    
    func isAuthenticatable() -> Bool {
        return [AuthenticationState.notAuthenticated, .authenticated].contains(self)
    }
}

 

  1. Context is the main object in our class. It will provide a UI for evaluating the authentication policies and perform requests.
  2. Variable that wraps context’s value of duration for which Touch ID authentication reuse is allowable. FYI, there is no similar value set for Face ID.

Next, we are going to implement each method of Authenticator. In canAuthenticate() we check 2 things – first, can our context evaluate biometric policy. If no, an error will be returned. Second, can user authenticate due to the current state. This is very important to mention as starting new authentication while current is in progress may create bugs and unexpected behavior.

 

func canAuthenticate() -> Bool {
        var isBiometricAuthenticationAvailable = false
        var error: NSError? = nil
        
        if context.canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: &error) {
            isBiometricAuthenticationAvailable = (error == nil)
        }
        return isBiometricAuthenticationAvailable && state.isAuthenticatable()
    }

 

Then implement 2 simple functions to check if the device has Touch ID or Face ID. FYI, we will use these methods for creating a login button with the correct icon. Also, worth to mention that there is an option for authentication via Watch, but it won’t be shown in this example.

 

func faceIDAvailable() -> Bool {
        if #available(iOS 11.0, *) {
            return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) && context.biometryType == .faceID
        }
        return false
    }
    
func touchIDAvailable() -> Bool {
        if #available(iOS 11.0, *) {
            return context.canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: nil) && context.biometryType == .touchID
        }
        return false
    }

 

As we defined in Authenticator protocol, we can perform authentication via biometric or by a passcode. All we need to do is configure context, call evaluatePolicy() and handle its result. But keep in mind that block result of this method may not be in the main thread, so if you are going to update UI, execute everything inside DispatchQueue.main.async{}

Lets check implementation of both authenticate functions together since they have the same steps.

 

func authenticateWithBioMetrics(reason: String = "", fallbackTitle: String? = "", cancelTitle: String? = "", completion: @escaping (Result<Bool, AuthenticationError>) -> Void) {
        //1
        let reasonString = reason.isEmpty ? defaultBiometricAuthenticationReason() : reason
        
        //2
        context.localizedFallbackTitle = fallbackTitle
        context.localizedCancelTitle = cancelTitle
        
        //3
        self.evaluate(policy: .deviceOwnerAuthenticationWithBiometrics, with: context, reason: reasonString, completion: completion)
    }
    
    func authenticateWithPasscode(reason: String, cancelTitle: String? = "", completion: @escaping (Result<Bool, AuthenticationError>) -> ()) {
        //1
        let reasonString = reason.isEmpty ? defaultPasscodeAuthenticationReason() : reason
        
        //2
        context.localizedCancelTitle = cancelTitle
        
        //3
        if #available(iOS 9.0, *) {
            self.evaluate(policy: .deviceOwnerAuthentication, with: context, reason: reasonString, completion: completion)
        } else {
            self.evaluate(policy: .deviceOwnerAuthenticationWithBiometrics, with: context, reason: reasonString, completion: completion)
        }
    }

 

Step 1. Define a string variable for reason. In case you have not provided it in call, we should implement some default value. This string will be shown on authentication dialog and explain to the user why he should login. Apple emphasizes to keep it short and clear.

Step 2. Configure context with localized titles. For biometric, we can set fallbackTitle or leave it empty, in that case framework’s default value will be used. The text is used for alternative option of authentication, if the user fails at least one attempt. cancelTitle is self-explanatory 🙂 For passcode, there are no alternative options, so if the user fails several times – iPhone will go to disabled state for a certain amount of time.

Step 3. Call evaluate on context. While code is the same for both, better to move it into a separate method. Its body is the following:

 

private func evaluate(policy: LAPolicy, with context: LAContext, reason: String, completion: @escaping (Result<Bool, AuthenticationError>) -> ()) {
        state = .inProgress
        context.evaluatePolicy(policy, localizedReason: reason) { [unowned self] (success, err) in
            DispatchQueue.main.async {
                if success {
                    self.state = .authenticated
                    self.setLastLoginDate()
                    completion(.success(success))
                } else {
                    guard let errorType = err as? LAError else {
                        completion(.failure(AuthenticationError(code: .systemCancel)))
                        return
                    }
                    let error = AuthenticationError(code: errorType.code)
                    self.state = error.isCanceled() ? .canceled : .notAuthenticated
                    if error.isCanceled() {
                        self.state = .canceled
                        self.cleanup()
                    } else {
                        self.state = .notAuthenticated
                    }
                    completion(.failure(error))
                }
            }
        }
    }

 

Steps of how to create view controller, connect IBOutlet of button touch event and further invocation of authentication are skipped here. For demonstrating it as a real app, 2 screens were implemented using VIPER design pattern (app pattern, obviously, has no relation to topic itself, it’s just our vision of good application architecture).

First module is the login where based on biometric type Face ID or Touch ID button is shown correspondingly. Second is the one you can reach only after successful authentication.

Download full code of this example from our REPOSITORY.

That’s all Folks! (Don’t lie that music have not played in your head)

 

Bonus

Imagine your app contains private data that should not be accessed by any other user. What do you think about making an authentication each time the user opens the app? Yeap, that may be a bit overkill, but you can configure it based on requirements or personal preferences. We will show how to extend current implementation. First, save date of last successful authentication and delete it on fail. Second, add one more method to a protocol, and do the following implementation:

 

func needReAuthentication() -> Bool {
     return state.isAuthenticatable() && isExpired()
}

 

Where isExpired() is a private function that returns true, if user allows reuse past authentication or last successful login time interval is less than expire time (which is 0 by default, meaning no expiration).

 

private func isExpired() -> Bool {
        var isExpired = false
        if expireTime == 0 {
            //if no expire time, but user allow reuse previous biometric login for some time interval
            return allowableReuseDuration != nil
        } else if let lastLoginDate = UserDefaults.standard.object(forKey: Key.lastLoginDate) as? Date {
            isExpired = Date().timeIntervalSince(lastLoginDate) > expireTime
        }
        return isExpired
    }

 

If you want to show authentication UI once app become active, implement applicationDidBecomeActive in AppDelegate and do the following:

 

func applicationDidBecomeActive(_ application: UIApplication) {
        if authenticator.canAuthenticate() && authenticator.needReAuthentication() {
            let top = application.getTopViewController()
            if !isLogin(top) {
                top?.present(loginController(), animated: true, completion: nil)
            }
        }
    }

 

Well done!

 

Conclusion

Biometric authentication can be your good friend for login flow. Usually it should go in pair with classic username/password forms and make re-authentication faster, but that’s not obligatory. There is no actual need to talk about how much it’s secure, otherwise it won’t be the main option to unlock the user’s device.

Think about it like Gandalf in a scene where he allows to move forward only for his friends and blocks the Balrog by shouting “You shall not pass!”. Be like Gandalf – allow only authenticated user access your app data or services.