Некоторое время назад была опубликована статья на тему хранения данных пользователя. В этой статье было представлено решение, которое подходит для хранения общих данных, таких как настройки уведомлений, рассылки, возраста, имени и т.п. Но если требуется хранить важные или персональные данные, такие как ключ авторизации, пароль аккаунта, логин аккаунта и т.п., то для этих целей потребуется своего рода безопасное и защищенное хранилище. Далее речь пойдет о хранилище данных 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. Но об этом как-нибудь в другой раз.