Так или иначе, все реже можно найти приложение, которое не требует создания аккаунта для полноценной работы. В связи с этим возникает необходимость в некоторого рода защищенном хранилище аутентификационных данных. В iOS для этих целей используется framework Security и его сервис KeyChain. Далее будет описан подход для работы с этим сервисом.
Данные пользователя
Как сказано в документации, хранилище используется для безопасного хранения небольших объемов данных. Поэтому требуется некоторый объект Credentials, содержащий информацию об аккаунте, и с которым впоследствии будет происходить работа.
public struct Credentials {
public var account: String
public var server: String
public var password: String?
public init(account: String, server: String, password: String? = nil) {
self.account = account
self.server = server
self.password = password
}
}
Сформируем минимальные необходимые требования к защищенному хранилищу.
Возможность сохранить данные.
Возможность загрузить данные.
Возможность удалить данные.
В то же время сформулируем основные ограничения к коду.
Объект должен формировать четкую и понятную абстракцию. Причем уровень абстракции должен соблюдаться и внутри методов.
Объект должен быть инкапсулирован, предоставляя другим объектам лишь несколько методов для вызова, скрывая внутреннюю реализацию.
В результате работы над данными требованиями и ограничениями был разработан класс KeyChain.
import Foundation
import Security
public final class KeyChain {
// MARK: - Types
public struct Credentials {
public var account: String
public var server: String
public var password: String?
public init(account: String, server: String, password: String? = nil) {
self.account = account
self.server = server
self.password = password
}
}
public enum Error: Swift.Error {
case encodingError
case decodingError
case errorStatus(message: String?)
}
// MARK: - Lifecycle
public init() {}
// MARK: - Methods
public func save(credentials: Credentials) throws {
let query = query(from: credentials)
if let password = credentials.password {
try setValue(password, query: query)
} else {
try remove(credentials: credentials)
}
}
public func load(credentials: inout Credentials) throws {
var query = query(from: credentials)
query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecReturnAttributes as String] = kCFBooleanTrue
query[kSecReturnData as String] = kCFBooleanTrue
credentials.password = try getValue(query: query)
}
public func remove(credentials: Credentials) throws {
let query = query(from: credentials)
try delete(query: query)
}
private func setValue(_ value: String, query: [String: Any]) throws {
guard let data = value.data(using: .utf8) else {
throw Error.encodingError
}
let status = SecItemCopyMatching(query as CFDictionary, nil)
switch status {
case errSecSuccess:
var attributesToUpdate: [String: Any] = [:]
attributesToUpdate[kSecValueData as String] = data
try update(query: query, attributesToUpdate: attributesToUpdate)
case errSecItemNotFound:
var query = query
query[kSecValueData as String] = data
try add(query: query)
default:
throw error(from: status)
}
}
private func getValue(query: [String: Any]) throws -> String? {
var searchResult: AnyObject?
let status = withUnsafeMutablePointer(to: &searchResult) {
SecItemCopyMatching(query as CFDictionary, $0)
}
switch status {
case errSecSuccess:
guard let queriedItem = searchResult as? [String: Any],
let data = queriedItem[kSecValueData as String] as? Data,
let value = String(data: data, encoding: .utf8)
else {
throw Error.decodingError
}
return value
case errSecItemNotFound:
return nil
default:
throw error(from: status)
}
}
private func update(query: [String: Any], attributesToUpdate: [String: Any]) throws {
let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary)
if status != errSecSuccess {
throw error(from: status)
}
}
private func add(query: [String: Any]) throws {
let status = SecItemAdd(query as CFDictionary, nil)
if status != errSecSuccess {
throw error(from: status)
}
}
private func delete(query: [String: Any]) throws {
let status = SecItemDelete(query as CFDictionary)
if !(status == errSecSuccess || status == errSecItemNotFound) {
throw error(from: status)
}
}
private func query(from credentials: Credentials) -> [String: Any] {
var query: [String: Any] = [:]
query[kSecClass as String] = kSecClassInternetPassword
query[kSecAttrAccount as String] = credentials.account
query[kSecAttrServer as String] = credentials.server
return query
}
private func error(from status: OSStatus) -> Error {
let message = SecCopyErrorMessageString(status, nil) as String?
return .errorStatus(message: message)
}
}
Как можно убедиться, требования к хранилищу реализованы с помощью следующего интерфейса.
init()
func save(credentials: Credentials) throws
func load(credentials: inout Credentials) throws
func remove(credentials: Credentials) throws
Все что касается ограничений, связанных с кодированием, опытный читатель может проанализировать самостоятельно.
Таким образом, при завершении срока действия токена авторизации и наличии в хранилище аккаунта пользователя, не будет необходимости в отображении экрана авторизации. Достаточно будет загрузить аккаунт из хранилища и авторизовать пользователя повторно.
class ViewController: UIViewController {
private let secureStore = KeyChain()
override func viewDidLoad() {
super.viewDidLoad()
var credentials = KeyChain.Credentials(account: "Test", server: "www.test.com")
do {
try secureStore.load(credentials: &credentials)
print(credentials)
} catch {
print(error)
}
}
}
Причем можно повторить подход, который используется в Apple - объявить общий экземпляр (singleton), либо передать объект в окружение (environmentObject), либо, как приводится в примере, создать экземпляр по необходимости.
Заключение
Хранение авторизационных данных - довольно частая задача, которая решается разработчиками, поскольку токен авторизации через определенное время утрачивает актуальность. Для того, чтобы пользовательский опыт был максимально положительным, нужно выполнять повторную авторизацию в "тихом" режиме, а не выбрасывать пользователя на экран ввода логина и пароля.
В любом случае, это один из наиболее распространенных вариантов использования защищенного хранилища. В качестве другого примера можно привести хранение "секретов" пользователя. Но об этом как-нибудь в другой раз.