[Из песочницы] Встраиваем Touch ID в iOS приложение
ВступлениеНачиная с iOS 8 Apple открывает доступ к возможности использования технологии Touch ID (аутентификации с помощью сканера отпечатков пальцев, встроенного в iPhone 5s) в сторонних приложениях. В связи с этим я хотел бы поделиться с вами подробной информацией о том, что же именно стало доступно разработчикам, как это встроить в свое приложение, каким поведением это обладает, а также поделиться удобной «оберткой», которая реализует наиболее, на мой взгляд, вероятный сценарий использования Touch ID.Необходимый API представлен в новом фреймворке LocalAuthentication. На данный момент его функционал ограничивается взаимодействием со сканером отпечатков пальцев, но судя по более общему названию его набор возможностей, вероятно, в будущем расширится. Фреймворк не предоставляет никаких данных о пользователе (что в общем-то логично), а только позволяет предложить пользователю выполнить аутентификацию с помощью средств биометрии (на данный момент это встроенный сканер отпечатков пальцев;, но конкретно о сканере во фреймворке речи не идет, используется более общее слово Biometrics). На выходе мы получаем статус: либо аутентификация прошла успешно, либо что-то пошло не так. По сути, почти в любой момент времени можно определить действительно ли тот, кто пользуется устройством, является его владельцем.
Это наводит на мысль об использовании Touch ID в качестве дополнительной защиты при выполнении каких-либо важных операций. Например, при подтверждении перевода денежных средств, изменении каких-либо важных настроек, инициализации защищенного чата и т.д., то есть там, где приложение должно быть максимально уверено, что смартфон не оказался в руках злоумышленника.
Для того, чтобы пост был не только читабельным, но и реюзабельным, я решил описать интеграцию с Touch ID в виде «обертки», которая реализует выше описанный сценарий, что в будущем может вам сэкономить несколько часов рабочего времени. Описание представлено в виде «задача-решение», чтобы было ясно, что делается и для чего. И так, приступим.
Задача При выполнении важных операций в приложении необходимо иметь возможность запрашивать аутентификацию пользователя с помощью встроенных средств биометрии. Необходимость запроса такой аутентификации должна быть настраиваемой самим пользователем. Также нужно учитывать, что приложение может работать на более ранних версиях операцинной системы и на устройствах, в которых отсутствуют средства биометрии.Решение Решение будет представлено в классе BiometricAuthenticationFacade.Прежде всего рассмотрим самое главное — взаимодействие с фреймворком LocalAuthentication. Эта часть скрыта от пользователя и не доступна из интерфейса класса.В расширении класса объявим свойство для хранения контекста:
@interface BiometricAuthenticationFacade ()
@property (nonatomic, strong) LAContext *authenticationContext;
@end Выполним инициализацию свойства с учетом доступности API: — (instancetype)init { self = [super init]; if (self) { if (self.isIOS8AndLater) { self.authenticationContext = [[LAContext alloc] init]; } } return self; } Далее определим метод, который будет возвращать доступность использования локальной аутентификации: — (BOOL)isPassByBiometricsAvailable { return [self.authenticationContext canEvaluatePolicy: LAPolicyDeviceOwnerAuthenticationWithBiometrics error: NULL]; } В качестве параметра метод canEvaluatePolicy: error: принимает тип локальной аутентификации. На данный момент объявлен только один тип LAPolicyDeviceOwnerAuthenticationWithBiometrics, который говорит сам за себя. Использование биометрии может быть недоступно в случае, если устройство физически не поддерживает такую возможность либо, если пользователь не включил эту возможность в настройках смартфона.Запрос на выполнение сканирования отпечатка пальца пользователя опишем следующим образом:
— (void)passByBiometricsWithReason:(NSString *)reason succesBlock:(void (^)())successBlock failureBlock:(void (^)(NSError *error))failureBlock { [self.authenticationContext evaluatePolicy: LAPolicyDeviceOwnerAuthenticationWithBiometrics localizedReason: reason reply:^(BOOL success, NSError *error) { dispatch_async (dispatch_get_main_queue (), ^{ if (success) { successBlock (); } else { failureBlock (error); } }); }]; } В качестве параметров метод evaluatePolicy: localizedReason: reply: принимает выше описанный тип локальной аутентификации, сообщение, которое должно кратко описывать причину запроса и блок, который асинхронно выполнится после завершения всей процедуры.Обратите внимание, что выполнение блока reply на главном потоке не гарантировано (по факту вызывается не на главном), поэтому добавлен вызов dispatch_async. Можно было бы оставить как есть, но большинство разработчиков предполагают, что блок, который передается в метод, вызванный на главном потоке, также будет вызван на главном потоке, и не ставят дополнительную проверку. Так уж сложилось исторически.
При вызове выше описанного метода система отобразит диалог: 
В заголовке используется название приложения (CFBundleDisplayName); Строка, указанная в качестве параметра localizedReason; С этим полем не все так просто. При его нажатии диалог для ввода пароля не появится, как вы могли подумать, а вместо этого вызовется блок reply с ошибкой. Код ошибки задокументирован: LAErrorUserFallbackAuthentication was canceled because the user tapped the fallback button (Enter Password).
То есть так и было задумано. Честно говоря, эту логику я так и не понял;
Кнопка для отмены запроса. В результате вызовется блок reply с соответствующей ошибкой LAErrorUserCancel.
Если сканирование прошло успешно, то вызовется блок reply с положительным результатом.Необходимо отметить, что диалог для сканирования отображается не при каждом вызове метода evaluatePolicy: localizedReason: reply:. То есть успешность последнего сканирования обладает некоторым временем жизни. Повторная попытка аутентификации в течение нескольких минут приведет к мгновенному вызову блока reply с положительным результатом.Если же воспользоваться не тем пальцем и попытаться его отсканировать 5 раз подряд, то система предложит ввести пароль, указанный в настройках смартфона:
Для ясности уточню, что невозможно включить сканер в настройках смартфона, при этом не создав пароль.После того, как пользователь введет верный пароль, ему снова будет предложено сканирование отпечатка пальца. То есть знать пароль недостаточно.
На этом взаимодействие с LocalAuthentication завершено.Перейдем к реализации интерфейса нашего фасада.
Метод, позволяющий узнать доступность аутентификации. Результат определяется доступностью API и сканера:
— (BOOL)isAuthenticationAvailable { return self.isIOS8AndLater && self.isPassByBiometricsAvailable; } Метод, позволяющий определить включена ли аутентификация для той или иной операции: — (BOOL)isAuthenticationEnabledForFeature:(NSString *)featureName { return self.isAuthenticationAvailable && [self loadIsAuthenticationEnabledForFeature: featureName]; } Примером операции может быть доступ к настройкам, выполнение денежной транзакции и т.д. Состояние включения хранится в NSUserDefaults. Ниже будет представлена реализация метода loadIsAuthenticationEnabledForFeature:.Метод включения аутентификации для определенной операции:
— (void)enableAuthenticationForFeature:(NSString *)featureName succesBlock:(void (^)())successBlock failureBlock:(void (^)(NSError *error))failureBlock { if (self.isAuthenticationAvailable) { if ([self isAuthenticationEnabledForFeature: featureName]) { successBlock (); } else { [self saveIsAuthenticationEnabled: YES forFeature: featureName]; successBlock (); } } else { failureBlock (self.authenticationUnavailabilityError); } } Метод необходим для того, чтобы пользователь приложения имел возможность самостоятельно определять операции, для которых необходима дополнительная проверка.Состояние включения сохраняется в NSUserDefaults. Ниже будет представлена реализация метода saveIsAuthenticationEnabled: forFeatureМетод выключения аутентификации для определенной операции:
— (void)disableAuthenticationForFeature:(NSString *)featureName withReason:(NSString *)reason succesBlock:(void (^)())successBlock failureBlock:(void (^)(NSError *error))failureBlock { if (self.isAuthenticationAvailable) { if ([self isAuthenticationEnabledForFeature: featureName]) { [self passByBiometricsWithReason: reason succesBlock:^{ [self saveIsAuthenticationEnabled: NO forFeature: featureName]; successBlock (); } failureBlock: failureBlock]; } else { successBlock (); } } else { failureBlock (self.authenticationUnavailabilityError); } } Как видите, для выключения необходимо убедиться, что мы имеем дело с владельцем смартфона, а не злоумышленником.Метод запроса аутентификации пользователя для доступа к операции:
— (void)authenticateForAccessToFeature:(NSString *)featureName withReason:(NSString *)reason succesBlock:(void (^)())successBlock failureBlock:(void (^)(NSError *error))failureBlock { if (self.isAuthenticationAvailable) { if ([self isAuthenticationEnabledForFeature: featureName]) { [self passByBiometricsWithReason: reason succesBlock: successBlock failureBlock: failureBlock]; } else { successBlock (); } } else { failureBlock (self.authenticationUnavailabilityError); } } Методы для сохранения и получения информации о необходимости аутентификации пользователя для доступа к операции (не доступны из интерфейса класса): — (void)saveIsAuthenticationEnabled:(BOOL)isAuthenticationEnabled forFeature:(NSString *)featureName { NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults]; NSMutableDictionary *featuresDictionary = nil; NSDictionary *currentDictionary = [userDefaults valueForKey: kFeaturesDictionaryKey]; if (currentDictionary == nil) { featuresDictionary = [NSMutableDictionary dictionary]; } else { featuresDictionary = [NSMutableDictionary dictionaryWithDictionary: currentDictionary]; } [featuresDictionary setValue:@(isAuthenticationEnabled) forKey: featureName]; [userDefaults setValue: featuresDictionary forKey: kFeaturesDictionaryKey]; [userDefaults synchronize]; }
— (BOOL)loadIsAuthenticationEnabledForFeature:(NSString *)featureName { NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults]; NSDictionary *featuresDictionary = [userDefaults valueForKey: kFeaturesDictionaryKey]; return [[featuresDictionary valueForKey: featureName] boolValue]; } В качестве хранилища используется NSUserDefaults. Все данные хранятся в отдельном словаре, чтобы снизить вероятность появления конфликтов в названиях ключей.На этом основная реализация фасада завершается.Концовка И напоследок, для тех, кто осилил дочитать до конца, несколько интересных фактов о сканере в iPhone 5s: Вероятность ложного пропуска, т.е. того, что отпечаток случайного человека будет распознан как Ваш, равна 1 на 50 000; Система позволяет выполнить 5 попыток сканирования перед тем, как будет затребован пароль пользователя. Таким образом атака типа brute-force не может быть выполнена, а вероятность того, что сканер может быть взломан злоумышленником равна ≈0.0001; Сканер снимает растровое изображение размером в 88×88 пикселей и плотностью 500 ppi. Полученное растровое изображение преобразуется в векторное и подвергается дополнительному анализу; Полученные данные отпечатка хранятся в зашифрованном виде в специальной области (Secure Enclave) на процессоре A7. Данные шифруются приватным ключом, который генерируется и записывается в Secure Enclave во время производства процессора на фабрике. Apple утверждает, что ни зашифрованные даные, ни приватный ключ не покидают мобильное устройство и неизвестны третьим лицам, в том числе и самой компании Apple. Источник интересных фактов: iOS SecurityПолная версия исходного кода доступна на GitHub: BiometricAuthenticationFacade
