프로젝트를 진행하면서 구현했던 것들 중 다른 프로젝트들에서도 사용할 수 있을만한 유용한 것들을 정리하려한다.
그 첫번째로 Apple에서 제공하는 Keychain에 대해서 정리해보자.
예를들어 access token, refresh token을 서버로부터 발급 받으면 이를 UserDefault에 저장하여 사용하곤 했다.
public func getAccessToken() -> String? {
UserDefaults.standard.string(forKey: "accessToken")
}
public func getRefreshToken() -> String? {
UserDefaults.standard.string(forKey: "refreshToken")
}
public func saveAccessToken(_ accessToken: String) {
UserDefaults.standard.set(accessToken, forKey: "accessToken")
}
public func saveRefreshToken(_ refreshToken: String) {
UserDefaults.standard.set(refreshToken, forKey: "refreshToken")
}
UserDefault를 사용하면 아주 간단히 구현할 수 있지만, 보안상 좋지 않은 방법이다.
우선 다른 블로그 혹은 문서를 보기 전에 공식 문서를 먼저 읽어보고...
Keychain을 사용하면 사용자의 비밀번호에 국한하지 않고, 신용카드 정보 혹은 짧은 메모 등도 사용자가 암호화하고 싶은 것이라면 Keychain 데이터베이스에 암호화하여 저장할 수 있다.
Keychain에 들어가는 친구들은 Item이란 애들인데, Item은 다음과 같이 2개를 갖는다.
여기서 attributes는 Item의 접근성을 설정하고 검색할 수 있게 하는 public한 친구이다.
또 중요한 Keychain의 특징들이 있다.
Keychain을 활용한 사용자의 프로세스는 다음과 같이 나타낼 수 있다.
앱을 처음 실행했다면 Keychain에 어떠한 정보도 저장되어있지 않은 상태이다. 따라서 keychain에서 검색을 하면 correspoding한 결과를 찾을 수 없기 때문에 위 그림의 오른쪽 flow를 진행하게 된다.
오른쪽 flow에서 사용자가 인증을 성공한 credential을 제공하면 SecItemAdd(_:_:)
메소드를 사용하여 해당 정보를 저장한다.
앱을 처음 실행한 경우가 아닌 대부분의 경우에는 위 다이어그램의 중간 flow를 진행하게 된다.
SecItemCopyMatching(_:_:)
의 결과로 해당 데이터를 찾고 인증까지 성공하면 사용자는 앱 서비스를 사용할 수 있게 된다.
사용자는 자신이 저장한 암호화된 데이터를 바꿀 수 있다. 예를 들어, 앱을 사용하다가 해당 서비스의 웹사이트에서 비밀번호를 바꾼 상황을 생각해보자.
비밀번호가 바뀐 이후에는 위 그림에서 keychain에서 해당 데이터를 찾아도 인증에서 실패하게 된다. 이 때 왼쪽 flow를 타게 된다.
여기서는 SecItemUpdate(_:_:)
메소드를 통해 현재 저장된 value를 새롭게 인증한 credential의 value로 변경하게 된다.
마지막으로 SecItemDelete(_:)
을 통해 keychain에서 데이터를 삭제할 수 있다.
Keychain에 데이터를 저장할 때, 데이터의 종류에 따라 Item class를 구분할 수 있다.
그리고 해당하는 Item class에 따라 적용할 수 있는 attribues들이 달라진다.
데이터 종류에 따른 Item class는 아래와 같고, 각 class마다 적용할 수 있는 attributes는 너무 많기 때문에 사용할 때 문서를 참고해서 적용하면 될 듯 하다.
Item Attibutes Keys and Values
struct Credentials {
var username: String
var password: String
}
enum KeychainError: Error {
case noPassword
case unexpectedPasswordData
case unhandledError(status: OSStatus)
}
let account = "username"
let password = "password".data(using: String.Encoding.utf8)!
let server = "www.example.com"
// 1. Query 작성
var query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: account,
kSecAttrServer as String: server,
kSecValueData as String: password]
// 2. SecItemAdd(_:_:) 메소드 호출
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
공식 문서에서는 위와 같이 SecItemAdd(_:_:)
메소드에서 2번째 인자를 nil로 설정하여 결과를 사용하지 않고있다. 그리고KeychainError
를 만들어 에러 발생 시 throw를 해주는데, 이 상황과 같이 결과를 받지 않아도 return status를 검사하여 성공했는지 검사를 항상 하라고 말하고 있다.
SecItemAdd(::) 메소드는 인자로 attributes를 CFDictionary
형태로 받고, result를 받는다.
지금 프로젝트의 토큰을 저장하는 경우에는 다음과 같이 작성하면 될 듯 하다.
public func addToken(key: String, value token: String) -> Bool {
let query: [String : Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: token.data(using: .utf8) as Any]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
#if DEBUG
print("🔥Keychain create Error : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
return false
}
#if DEBUG
print("✨Keychain create success")
#endif
return true
}
SecItemAdd(:_:_)
메소드 이전에SecItemDelete(_:)
메소드를 호출해서 중복되는 키체인을 사용하면 update를 create로 대체할 수도 있을 것 같긴 하다..!
// 1. Query 설정
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: server,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true]
// 2. SecItemCopyMatching(_:_:) 메소드 호출 (errSecItemNotFound 에러 처리하기)
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status != errSecItemNotFound else { throw KeychainError.noPassword }
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
// 3. result 처리하기
guard let existingItem = item as? [String : Any],
let passwordData = existingItem[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: String.Encoding.utf8),
let account = existingItem[kSecAttrAccount as String] as? String
else {
throw KeychainError.unexpectedPasswordData
}
let credentials = Credentials(username: account, password: password)
공식 문서에서 보여주는 예시는 위와 같다. Attributes들을 살펴보면 Internet Password에 해당하는 키체인이고, 이전에 추가한 server attribute에 해당하는 Item을 검색한다.
kSecMatchLimit
을 보면 kSecMatchLimitOne
으로 설정되어 결과를 1개로 제한하고 있다. (default임)
마지막으로 attribute와 data 모두 return할 수 있도록 설정한다.
그 다음 SecItemCopyMatcing(::)를 호출하여 검색을 실행할 수 있다.
검색을 실시하고 errSecItemNotFound
에러를 통해 아직 키체인에 등록되지 않은 상황을 처리할 수 있다.
검색에 성공하면 parameter를 어떻게 설정했는지에 따라 반환값이 나오게 된다. 반환 값에는 여러 타입들이 동시에 있기 때문에 반환 Dictionary에서 kSecAttrValueData
를 사용하여 원하는 결과들을 추출해야한다.
토큰을 읽는 지금의 경우 아래와 같이 작성하면 되겠다.
public func readToken(key: String) -> String? {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnAttributes as String: true,
kSecReturnData as String: true]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status != errSecItemNotFound else {
#if DEBUG
print("🔥Keychain item for \(key) not found : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
return nil
}
guard status == errSecSuccess else {
#if DEBUG
print("🔥Keychain read Error : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
return nil
}
guard let item = item as? [String: Any],
let tokenData = item[kSecValueData as String] as? Data,
let token = String(data: tokenData, encoding: .utf8) else {
#if DEBUG
print("🔥Keychain get token failed : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
return nil
}
#if DEBUG
print("✨Keychain read success : \(token)")
#endif
return token
}
update를 진행하기 위해서는 우선 키체인에서 해당 키체인을 찾아야한다.
따라서 read할 때와 같이 query를 먼저 작성한다. (찾기만 하고 return을 통해 어떠한 것을 안하기 때문에 다른 attributes들은 필요 없다)
그 다음 새롭게 들어갈 친구를 만들고 SecItemUpdate(::)을 실행하면 된다.
// 1. Query 작성 (return과 관련한 attributes는 필요 없음)
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: server]
// 2. update될 내용 작성
let account = credentials.username
let password = credentials.password.data(using: String.Encoding.utf8)!
let attributes: [String: Any] = [kSecAttrAccount as String: account,
kSecValueData as String: password]
// 3. SetItemUpdate(_:_:) 메소드 호출
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard status != errSecItemNotFound else { throw KeychainError.noPassword }
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
프로젝트에서는 아래와 같이 코드를 작성했다.
@discardableResult
public func updateToken(key: String, value token: String) -> Bool {
let prevQuery: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key]
let updateQuery: [String: Any] = [kSecValueData as String: token.data(using: .utf8) as Any]
let status = SecItemUpdate(prevQuery as CFDictionary, updateQuery as CFDictionary)
guard status != errSecItemNotFound else {
#if DEBUG
print("🔥Keychain item for \(key) not found : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
return false
}
guard status == errSecSuccess else {
#if DEBUG
print("🔥Keychain update Error : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
return false
}
#if DEBUG
print("✨Keychain update success")
#endif
return true
}
사용자가 로그아웃을 할 때, 키체인에서 password item을 지우면 된다.
delete는 update와 되게 비슷한데, 타겟이 되는 친구만 SecItemDelete(_:) 메소드에게 넘겨주면 된다.
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError.unhandledError(status: status) }
프로젝트에서는 아래와 같이 작성하였다.
@discardableResult
public func deleteToken(key: String) -> Bool {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key]
let status = SecItemDelete(query as CFDictionary)
guard status != errSecItemNotFound else {
#if DEBUG
print("🔥Keychain item for \(key) not found : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
return false
}
guard status == errSecSuccess else {
#if DEBUG
print("🔥Keychain delete Error : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
return false
}
#if DEBUG
print("✨Keychain delete success")
#endif
return true
}
CRUD 메소드의 반환 타입은 OSStatus
타입이다.
에러 발생 시 출력을 하면 정수만 나오게 되어서 무슨 에러인지 알기가 어려울 때 아래와 같이 자세한 에러를 확인할 수 있다.
let status = SecItemDelete(query as CFDictionary) // CRUD 메소드
SecCopyErrorMessageString(status, nil)
Apple Keychain 공식 문서
[iOS] 토큰 데이터 저장 공간을 Keychain으로 바꿔보자
[iOS] 키체인에 대해서 (서버에서 받은 token을 저장해보자)
[iOS] 키체인 (Keychain Service)