개발자의 삽질
[iOS] 키체인에 대해서 (서버에서 받은 token을 저장해보자) 본문
https://developer.apple.com/documentation/security/keychain_services
어떤 이유로 키체인에 대해 알아야 했을까?
현재 만들고 있는 앱은 서버에서 제공해준 카카오 OAuth을 통해 로그인하고 token을 받게 된다.
처음에는 이를 UserDefault를 이용해 구현하려 했으나, 이는 보안상 좋은 방법이 아니라는 것을 알게되었고, 따라서 Keychain을 공부하게 되었다.
Keychain Services 에 대해서 알아보자
종종 사용자는 비밀 데이터를 안전하게 저장하고 싶어한다. 이 때 Keychain Services API가 도움을 줄 수 있다.
사용자는 자신의 비밀 데이터를 저장하고 싶을 때, Keychain이라 불리는 암호화된 데이터베이스에 정보를 저장할 수 있다.
Keychain은 비밀번호에만 국한되지 않는다.
아래의 그림과 같이
- 비밀번호
- 암호화된 키
- 인증서
- 짧은 메모
이렇게 4가지의 경우를 저장할 수 있습니다.
Keychain Items
Keychain에 비밀번호와 같은 정보를 저장하기 위해서는 keychain item을 사용해야 한다.
Keychain item을 사용할때는,
저장하려고 하는 정보와 함께 item에 접근성을 제어하고 검색 가능하게끔 하는 공개된 여러 특성들을 함께 제공해야 한다.
Item = Data + Attributes
Attributes 는 뒤에서 다시 설명이 나온다.
아래의 그림을 참고하자.
사용자의 비밀정보를 다루기 위해 사용하는 Keychain
Keychain의 가장 주된 목적 중 하나는, 비밀정보를 keychain에 저장함으로서 사용자가 일일히 기억하지 않게끔 도와주는 것이다.
For Good User Experience
아래는 Keychain을 이용해 인터넷 비밀번호를 저장하는 과정이다.
KeyChain CRUD
1. Create: 키체인에 비밀정보 추가하기
func SecItemAdd(_ attributes: CFDictionary,
_ result: UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus
2. Read: 키체인에 저장된 Keychain Item 찾기
func SecItemCopyMatching(_ query: CFDictionary,
_ result: UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus
3. Update: 키체인에 저장된 정보 변경하기
func SecItemUpdate(_ query: CFDictionary,
_ attributesToUpdate: CFDictionary) -> OSStatus
4. Delete: 키체인에 저장된 정보 삭제하기
func SecItemDelete(_ query: CFDictionary) -> OSStatus
자세히 들어가기 전에 먼저 Attributes에 대해서 다루어야 한다.
내가 생각하기에 Attributes 는 크게 3개의 정보를 갖고 있다.
- 어떤 종류의 비밀정보인가? (위에서 언급한 4가지의 경우) - Item Class Keys and Values
- Item은 어떠한 속성을 갖고 있는가? - Item Attribute Keys and Values
- 저장해둔 Keychain을 다시 찾기 위해서 어떤 고유값을 가져야 하는가? - Item Attribute Keys and Values
물론 크게 3개의 정보를 갖고 있다고 '제가' 생각을 하는 것이지 이는 해석에 따라 달라질 수 있다.
먼저 어떤 종류를 가지는지는 여러 특성키를 통해 나타내게 됩니다.
- kSecClassGenericPassword - 비밀번호
- kSecClassInternetPassword - 인터넷 비밀번호
- kSecClassCertificate - 인증서
- kSecClassIdentity - ID
- kSecClassKey - 암호화된 키
이 5개가 위에서 내가 말한 어떤 종류의 비밀정보인가를 나타내게 된다.
나머지 2개, 즉 속성과 고유값 관련된 부분은 Item Attribute Keys and Values 링크를 따라가서 확인해보길 바란다.
양이 많아서 일일히 다루기는 비효율적인 것 같다.
아래 예시에서는
- kSecClassGenericPassword
- kSecAttrService - 서비스 명시
- kSecAttrAccount - 계정 명시
- kSecValueData - 데이터 저장(type: Data)
- kSecMatchLimit - 하나만 갖고 오기
- kSecReturnData - 데이터 반환
- kSecReturnAttributes - Attributes도 반환
를 사용한다.
Keychain Create
static let serviceName = "서비스이름"
static let account = "계정이름"
@discardableResult
static func create(token: String) -> Bool {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: account,
kSecValueData as String: token.data(using: .utf8) as Any]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
print("Keychain create Error")
return false
}
return true
}
Keychain Read
static func read() -> String? {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
kSecReturnAttributes as String: true]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status != errSecItemNotFound else {
print("Keychain item not found")
return nil
}
guard status == errSecSuccess else {
print("Keychain read Error")
return nil
}
guard let existingItem = item as? [String: Any],
let tokenData = existingItem[kSecValueData as String] as? Data,
let token = String(data: tokenData, encoding: .utf8)
else {
print("Keychain get token Failed")
return nil
}
return token
}
Keychain Update
@discardableResult
static func update(token: String) -> Bool{
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword]
let attributes: [String: Any] = [kSecAttrService as String: serviceName,
kSecAttrAccount as String: account,
kSecValueData as String: token.data(using: .utf8) as Any]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard status != errSecItemNotFound else {
print("Keychain item not found")
return false
}
guard status == errSecSuccess else {
print("Keychain update Error")
return false
}
return true
}
Keychain Delete
@discardableResult
static func delete() -> Bool{
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: account]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
print("Keychain delete Error")
return false
}
return true
}
전체코드
import Foundation
import Security
class KeyChain {
static let serviceName = "서비스이름"
static let account = "계정이름"
@discardableResult
static func create(token: String) -> Bool {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: account,
kSecValueData as String: token.data(using: .utf8) as Any]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
print("Keychain create Error")
return false
}
return true
}
static func read() -> String? {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
kSecReturnAttributes as String: true]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status != errSecItemNotFound else {
print("Keychain item not found")
return nil
}
guard status == errSecSuccess else {
print("Keychain read Error")
return nil
}
guard let existingItem = item as? [String: Any],
let tokenData = existingItem[kSecValueData as String] as? Data,
let token = String(data: tokenData, encoding: .utf8)
else {
print("Keychain get token Failed")
return nil
}
return token
}
@discardableResult
static func update(token: String) -> Bool {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword]
let attributes: [String: Any] = [kSecAttrService as String: serviceName,
kSecAttrAccount as String: account,
kSecValueData as String: token.data(using: .utf8) as Any]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard status != errSecItemNotFound else {
print("Keychain item not found")
return false
}
guard status == errSecSuccess else {
print("Keychain update Error")
return false
}
return true
}
@discardableResult
static func delete() -> Bool {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: account]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
print("Keychain delete Error")
return false
}
return true
}
}
참고로 여기서는 kSecAttrService, kSecAttrAccount 를 이용해 구별을 했지만, 다른 방법을 사용해서 구별해도 되며,
하나의 앱에는 로그인 후에 하나의 user token을 사용하기 때문에 사실 유일하다.
따라서 위와 같이 따로 구별하지 않아도 될 것 같다.
오류처리
바로 위에서 delete 메서의 코드를 보면 status 변수가 있다.
let status = SecItemDelete(query as CFDictionary)
status 변수는 OSStatus 타입을 갖게 되는데, 에러 발생시 이를 그냥 출력해보면 정수만 나와서 어떤 에러가 나오는지 제대로 알수가 없다.
이 때, 아래의 함수를 사용한다면 유의미한 에러메시지를 보여준다. (print 를 이용해서 메시지를 보자)
SecCopyErrorMessageString(status, nil)
'iOS' 카테고리의 다른 글
[iOS, Firebase] Firebase 이용해서 푸시 알림 받기! (0) | 2022.05.04 |
---|---|
[iOS] TabBarController에 있는 여러 ViewController을 다른 스토리보드로 분리하자! Storyboard Reference (0) | 2022.02.25 |
[iOS] 네트워크 연결 상태 확인하기! (0) | 2022.02.13 |
[iOS] ISO8601DateFormatter 를 이용해 날짜 데이터를 문자로 바꾸어보자! (0) | 2022.02.09 |
[iOS] "Class ViewController has no initializers" 에러 고치기 (0) | 2022.02.07 |