
Некоторое время назад была опубликована статья на тему хранения данных пользователя. В этой статье было представлено решение, которое подходит для хранения общих данных, таких как настройки уведомлений, рассылки, возраста, имени и т.п. Но если требуется хранить важные или персональные данные, такие как ключ авторизации, пароль аккаунта, логин аккаунта и т.п., то для этих целей потребуется своего рода безопасное и защищенное хранилище. Далее речь пойдет о хранилище данных Keychain и способах его программирования.
Реализация
Сформируем минимальные необходимые требования к защищенному хранилищу.
Возможность сохранить данные.
Возможность загрузить данные.
Возможность удалить данные.
Данными могут быть ��ак пароли или токены, так и аккаунты, т.е. комбинации логин-пароль.
Ядром для работы с хранилищем будет SecureStorage, который удовлетворяет первым 3 требованиям.
import Foundation import Security public final class SecureStorage { enum KeychainError: Error { case itemAlreadyExist case itemNotFound case errorStatus(String?) init(status: OSStatus) { switch status { case errSecDuplicateItem: self = .itemAlreadyExist case errSecItemNotFound: self = .itemNotFound default: let message = SecCopyErrorMessageString(status, nil) as String? self = .errorStatus(message) } } } func addItem(query: [CFString: Any]) throws { let status = SecItemAdd(query as CFDictionary, nil) if status != errSecSuccess { throw KeychainError(status: status) } } func findItem(query: [CFString: Any]) throws -> [CFString: Any]? { var query = query query[kSecReturnAttributes] = kCFBooleanTrue query[kSecReturnData] = kCFBooleanTrue var searchResult: AnyObject? let status = withUnsafeMutablePointer(to: &searchResult) { SecItemCopyMatching(query as CFDictionary, $0) } if status != errSecSuccess { throw KeychainError(status: status) } else { return searchResult as? [CFString: Any] } } func updateItem(query: [CFString: Any], attributesToUpdate: [CFString: Any]) throws { let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) if status != errSecSuccess { throw KeychainError(status: status) } } func deleteItem(query: [CFString: Any]) throws { let status = SecItemDelete(query as CFDictionary) if status != errSecSuccess { throw KeychainError(status: status) } } }
Далее, аккаунты. Как сказано в документации, хранилище используется для безопасного хранения небольших объемов данных. Поэтому для аккаунтов требуется некоторая модель Credentials, с которой впоследствии будет происходить работа.
Естественно, для того чтобы использовать Credentials модель при вызове, потребуется набор новых методов, принимающих в качестве аргумента эту самую модель.
import Foundation public extension SecureStorage { struct Credentials { public var login: String public var password: String public init(login: String, password: String) { self.login = login self.password = password } } func addCredentials(_ credentials: Credentials, with label: String) { var query: [CFString: Any] = [:] query[kSecClass] = kSecClassGenericPassword query[kSecAttrLabel] = label query[kSecAttrAccount] = credentials.login query[kSecValueData] = credentials.password.data(using: .utf8) do { try addItem(query: query) } catch { return } } func updateCredentials(_ credentials: Credentials, with label: String) { deleteCredentials(with: label) addCredentials(credentials, with: label) } func getCredentials(with label: String) -> Credentials? { var query: [CFString: Any] = [:] query[kSecClass] = kSecClassGenericPassword query[kSecAttrLabel] = label var result: [CFString: Any]? do { result = try findItem(query: query) } catch { return nil } if let account = result?[kSecAttrAccount] as? String, let data = result?[kSecValueData] as? Data, let password = String(data: data, encoding: .utf8) { return Credentials(login: account, password: password) } else { return nil } } func deleteCredentials(with label: String) { var query: [CFString: Any] = [:] query[kSecClass] = kSecClassGenericPassword query[kSecAttrLabel] = label do { try deleteItem(query: query) } catch { return } } }
Работа с паролями несколько легче программируется, поскольку в этом случае хранится только пароль, а не комбинация логин-пароль, и в качестве типа данных используется String.
import Foundation public extension SecureStorage { func addPassword(_ password: String, for account: String) { var query: [CFString: Any] = [:] query[kSecClass] = kSecClassGenericPassword query[kSecAttrAccount] = account query[kSecValueData] = password.data(using: .utf8) do { try addItem(query: query) } catch { return } } func updatePassword(_ password: String, for account: String) { guard let _ = getPassword(for: account) else { addPassword(password, for: account) return } var query: [CFString: Any] = [:] query[kSecClass] = kSecClassGenericPassword query[kSecAttrAccount] = account var attributesToUpdate: [CFString: Any] = [:] attributesToUpdate[kSecValueData] = password.data(using: .utf8) do { try updateItem(query: query, attributesToUpdate: attributesToUpdate) } catch { return } } func getPassword(for account: String) -> String? { var query: [CFString: Any] = [:] query[kSecClass] = kSecClassGenericPassword query[kSecAttrAccount] = account var result: [CFString: Any]? do { result = try findItem(query: query) } catch { return nil } if let data = result?[kSecValueData] as? Data { return String(data: data, encoding: .utf8) } else { return nil } } func deletePassword(for account: String) { var query: [CFString: Any] = [:] query[kSecClass] = kSecClassGenericPassword query[kSecAttrAccount] = account do { try deleteItem(query: query) } catch { return } } }
Таким образом, для использования такого хранилища можно обновить computed property с get и set методами, но предлагается поступить иначе и вместо computed property использовать @propertyWrapper.
import SwiftUI @propertyWrapper public struct Credentials: DynamicProperty { private let label: String private let storage = SecureStorage() public init(_ label: String) { self.label = label } public var wrappedValue: SecureStorage.Credentials? { get { storage.getCredentials(with: label) } nonmutating set { if let newValue = newValue { storage.updateCredentials(newValue, with: label) } else { storage.deleteCredentials(with: label) } } } } @propertyWrapper public struct Password: DynamicProperty { private let key: String private let storage = SecureStorage() public init(_ key: String) { self.key = key } public var wrappedValue: String? { get { storage.getPassword(for: key) } nonmutating set { if let newValue { storage.updatePassword(newValue, for: key) } else { storage.deletePassword(for: key) } } } }
Обратить внимание нужно на ключевое слово nonmutating в сетере каждой структуры. Без этого при использовании предложенных property wrapper в SwiftUI компилятор бы выдавал ошибку "Cannot assign to property: 'self' is immutable". Также в угоду совместимости со SwiftUI была добавлена реализация протокола DynamicProperty.
В рез��льтате, класс, содержащий защищенные данные выглядит максимально просто, в то время как удовлетворяет поставленным требованиям.
final class SecureSettings { @Credentials("account") var account: SecureStorage.Credentials? @Credentials("subAccount") var subAccount: SecureStorage.Credentials? @Password("authToken") var authToken: String? @Password("refreshToken") var refreshToken: String? func clear() { account = nil subAccount = nil accessToken = nil refreshToken = nil } }
Причем можно повторить подход, который используется в Apple - объявить общий экземпляр (singleton), либо передать объект в окружение (environmentObject), либо, создать экземпляр по необходимости.
Заключение
Хранение авторизационных данных - довольно частая задача, которая решается разработчиками, поскольку токен авторизации через определенное время утрачивает актуальность. Для того, чтобы пользовательский опыт был максимально положительным, нужно выполнять повторную авторизацию в "тихом" режиме, а не выбрасывать пользователя на экран ввода логина и пароля.
В любом случае, это один из наиболее распространенных вариантов использования защищенного хранилища. В качестве другого примера можно привести хранение комбинации authToken-refreshToken. Но об этом как-нибудь в другой раз.