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

Сформируем минимальные необходимые требования к защищенному хранилищу.

  1. Возможность сохранить данные.

  2. Возможность загрузить данные.

  3. Возможность удалить данные.

В то же время сформулируем основные ограничения к коду.

  1. Объект должен формировать четкую и понятную абстракцию. Причем уровень абстракции должен соблюдаться и внутри методов.

  2. Объект должен быть инкапсулирован, предоставляя другим объектам лишь несколько методов для вызова, скрывая внутреннюю реализацию.

В результате работы над данными требованиями и ограничениями был разработан класс 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), либо, как приводится в примере, создать экземпляр по необходимости.

Заключение

Хранение авторизационных данных - довольно частая задача, которая решается разработчиками, поскольку токен авторизации через определенное время утрачивает актуальность. Для того, чтобы пользовательский опыт был максимально положительным, нужно выполнять повторную авторизацию в "тихом" режиме, а не выбрасывать пользователя на экран ввода логина и пароля.

В любом случае, это один из наиболее распространенных вариантов использования защищенного хранилища. В качестве другого примера можно привести хранение "секретов" пользователя. Но об этом как-нибудь в другой раз.

  1. Keychain Services

  2. Security

Комментарии (0)