Swift: Keychain Introduction (2022) – iOS
Keychain
구현 목표
구현 태스크
- 키체인 서비스를 담당할 싱글턴 클래스 구현
- 등록한 서비스, 계정, 비밀번호를 담당할 UI 및 핸들링 버튼 구현
핵심 코드
func save(service: String, account: String, password: Data) throws {
let query: [String: AnyObject] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service as AnyObject,
kSecAttrAccount as String: account as AnyObject,
kSecValueData as String: password as AnyObject
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status != errSecDuplicateItem else {
throw KeychainError.duplicateEntry
}
guard status == errSecSuccess else {
throw KeychainError.unknown(status)
}
print("Successfully Data Saved")
}
SecItemAdd
를 통해 키체인에 데이터 등록
kSecValueData
를 통해 보관할 데이터를 등록
- 중복 데이터를 거르거나 특정 에러가 생길 때 핸들링
func get(service: String, account: String) throws -> Data? {
let query: [String: AnyObject] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service as AnyObject,
kSecAttrAccount as String: account as AnyObject,
kSecReturnData as String: kCFBooleanTrue,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
print("returned Status: \(status)")
return result as? Data
}
SecItemCopyMatching
을 통해 키체인에 등록되어 있는 데이터와 매칭할 데이터를 비교, AnyObject
옵셔널로 선언된 result
변수의 주솟값을 넘김으로써 쿼리에 해당하는 데이터를 리턴
소스 코드
final class KeychainManager {
enum KeychainError: LocalizedError {
case duplicateEntry
case unknown(OSStatus)
var errorDescription: String? {
switch self {
case .duplicateEntry: return "This is Duplicated Entry"
case .unknown(let status): return "Unknown Error: StatusCode: \(status.description)"
}
}
}
static let shared = KeychainManager()
private init() {}
func save(service: String, account: String, password: Data) throws {
let query: [String: AnyObject] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service as AnyObject,
kSecAttrAccount as String: account as AnyObject,
kSecValueData as String: password as AnyObject
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status != errSecDuplicateItem else {
throw KeychainError.duplicateEntry
}
guard status == errSecSuccess else {
throw KeychainError.unknown(status)
}
print("Successfully Data Saved")
}
func get(service: String, account: String) throws -> Data? {
let query: [String: AnyObject] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service as AnyObject,
kSecAttrAccount as String: account as AnyObject,
kSecReturnData as String: kCFBooleanTrue,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
print("returned Status: \(status)")
return result as? Data
}
}
OSStatusCode
의 종류를 캐치하기 어렵기 때문에 커스텀 에러를 통해 판별
import SwiftUI
import UIKit
final class KeychainViewController: UIViewController {
private let services: [String] = ["google.com", "facebook.com", "twitter.com", "apple.com", "naver.com"]
private let serviceLabel: UILabel = {
let label = UILabel()
label.textAlignment = .left
label.font = .preferredFont(forTextStyle: .headline)
label.textColor = .systemGray
label.text = "Pick your Service"
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var servicePickerView: UIPickerView = {
let pickerView = UIPickerView()
pickerView.translatesAutoresizingMaskIntoConstraints = false
pickerView.delegate = self
pickerView.dataSource = self
return pickerView
}()
private lazy var selectedService: String = services[0]
private let emailTextField: UITextField = {
let textField = UITextField()
textField.borderStyle = .roundedRect
textField.placeholder = "Enter Email"
textField.keyboardType = .emailAddress
textField.translatesAutoresizingMaskIntoConstraints = false
return textField
}()
private lazy var passwordTextField: UITextField = {
let textField = UITextField()
textField.borderStyle = .roundedRect
textField.placeholder = "Enter Password"
textField.textContentType = .oneTimeCode
textField.translatesAutoresizingMaskIntoConstraints = false
textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
return textField
}()
private lazy var saveButton: UIButton = {
let button = UIButton()
var config = UIButton.Configuration.filled()
config.baseForegroundColor = .white
config.baseBackgroundColor = .systemGreen
config.title = "SAVE"
button.configuration = config
button.addTarget(self, action: #selector(didTapSave), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
private lazy var getButton: UIButton = {
let button = UIButton()
var config = UIButton.Configuration.filled()
config.baseForegroundColor = .white
config.baseBackgroundColor = .systemPink
config.title = "GET"
button.configuration = config
button.addTarget(self, action: #selector(didTapGet), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
private let resultLabel: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.font = .preferredFont(forTextStyle: .body)
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private let keychainManager = KeychainManager.shared
override func viewDidLoad() {
super.viewDidLoad()
setUI()
}
private func setUI() {
view.backgroundColor = .systemBackground
title = "Keychain"
navigationItem.largeTitleDisplayMode = .always
navigationController?.navigationBar.prefersLargeTitles = true
view.addSubview(serviceLabel)
view.addSubview(servicePickerView)
view.addSubview(emailTextField)
view.addSubview(passwordTextField)
view.addSubview(saveButton)
view.addSubview(getButton)
view.addSubview(resultLabel)
applyConstraints()
}
@objc private func textFieldDidChange(_ textField: UITextField) {
textField.isSecureTextEntry = true
}
@objc private func didTapSave() {
guard
let email = emailTextField.text,
!email.isEmpty,
let password = passwordTextField.text,
!password.isEmpty else {
resultLabel.text = "Check your text inputs..."
return
}
save(service: selectedService, account: email, password: password)
}
@objc private func didTapGet() {
guard
let email = emailTextField.text,
!email.isEmpty else { return }
get(service: selectedService, account: email)
}
private func applyConstraints() {
let serviceLabelConstraints = [
serviceLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 50),
serviceLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10),
serviceLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
serviceLabel.heightAnchor.constraint(equalToConstant: 50)
]
NSLayoutConstraint.activate(serviceLabelConstraints)
let servicePickerViewConstraints = [
servicePickerView.topAnchor.constraint(equalTo: serviceLabel.bottomAnchor, constant: 10),
servicePickerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10),
servicePickerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
servicePickerView.heightAnchor.constraint(equalToConstant: 100)
]
NSLayoutConstraint.activate(servicePickerViewConstraints)
let textFieldHeight: CGFloat = 50
let emailTextFieldConstraints = [
emailTextField.topAnchor.constraint(equalTo: servicePickerView.bottomAnchor, constant: 10),
emailTextField.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10),
emailTextField.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
emailTextField.heightAnchor.constraint(equalToConstant: textFieldHeight)
]
NSLayoutConstraint.activate(emailTextFieldConstraints)
let passwordTextFieldConstriants = [
passwordTextField.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 10),
passwordTextField.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10),
passwordTextField.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
passwordTextField.heightAnchor.constraint(equalToConstant: textFieldHeight)
]
NSLayoutConstraint.activate(passwordTextFieldConstriants)
let buttonHeight: CGFloat = 40
let saveButtonConstraints = [
saveButton.topAnchor.constraint(equalTo: passwordTextField.bottomAnchor, constant: 10),
saveButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10),
saveButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
saveButton.heightAnchor.constraint(equalToConstant: buttonHeight)
]
NSLayoutConstraint.activate(saveButtonConstraints)
let getButtonConstraints = [
getButton.topAnchor.constraint(equalTo: saveButton.bottomAnchor, constant: 10),
getButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10),
getButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
getButton.heightAnchor.constraint(equalToConstant: buttonHeight)
]
NSLayoutConstraint.activate(getButtonConstraints)
let resultLabelConstraints = [
resultLabel.topAnchor.constraint(equalTo: getButton.bottomAnchor, constant: 10),
resultLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10),
resultLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
resultLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
]
NSLayoutConstraint.activate(resultLabelConstraints)
}
private func save(service: String, account: String, password: String) {
guard let passwordData = password.data(using: .utf8) else {
resultLabel.text = "Password Encoding Failed"
return
}
do {
try keychainManager.save(service: service, account: account, password: passwordData)
resultLabel.text = "Successfully Saved!"
emailTextField.text = ""
passwordTextField.text = ""
} catch {
resultLabel.text = error.localizedDescription
}
}
private func get(service: String, account: String) {
do {
let data = try keychainManager.get(service: service, account: account)
if let data = data {
let password = String(decoding: data, as: UTF8.self)
resultLabel.text = "Returned Password: \(password)"
} else {
resultLabel.text = "Returned Data is empty"
}
} catch {
resultLabel.text = error.localizedDescription
}
}
}
- 서비스, 이메일, 패스워드를 입력한 뒤 저장
- 서비스, 이메일을 통해 패스워드를 리턴
extension KeychainViewController: UIPickerViewDelegate {
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
return services[row]
}
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
selectedService = services[row]
}
}
extension KeychainViewController: UIPickerViewDataSource {
func numberOfComponents(in pickerView: UIPickerView) -> Int {
return 1
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
return services.count
}
}
- 서비스 종류를 고르기 위한 픽커를 구성하는 델리게이트 및 데이터 소스 함수
구현 화면