건빵 - 키체인 적용기

Adding a Password to the Keychain | Apple Developer Documentation

<aside> 💡 키체인이 필요한 이유

accessToken 과 refreshToken 을 저장해 둘 곳이 필요하기 때문 ! 다만 UserDefaults 를 사용하기에는 보안이 아쉬움 …

</aside>

Header 뜯어서 토큰 받아오기

로그인하면 서버에서 헤더로 accessToken 이랑 refreshToken 을 내려주는데, 지금은 아래 코드를 통해 UserDefaults 에 저장해서 사용 중

func onSucceed(_ response: Response, target: TargetType, isFromError: Bool) {
    
    let request = response.request
    let url = request?.url?.absoluteString ?? "nil"
    let statusCode = response.statusCode
    var log = "------------------- 네트워크 통신 성공 -------------------"
    log.append("\\n[\\(statusCode)] \\(url)\\n----------------------------------------------------\\n")
    
    if let authorization = response.response?.value(forHTTPHeaderField: "Authorization") {
        UserDefaults.standard.setValue(authorization, forKey: "Authorization")
        print("❤️‍🔥authorization:\\(authorization)")
    }
    if let refresh = response.response?.value(forHTTPHeaderField: "Authorization-refresh") {
        UserDefaults.standard.setValue(refresh, forKey: "refresh")
        print("❤️‍🔥autorization-refresh:\\(refresh)")
    }
    
}

여기에서 KeyChain 에 저장해둬야 함

Keychain 관련 Enum 구성

먼저 오류 발생 시 처리해 줄 KeychainError Enum 생성

enum KeychainError: Error {
		case noData
		case unexpectedData
		case unexpectedError(status: OSStatus)
}

그리고 Key 값에 접근을 좀 더 쉽게 하기 위해 KeychainKey Enum 생성

enum KeychainKey {
    case access
    case refresh
    case accessRefreshPair
}

accessRefreshPair 는 둘 다 한꺼번에 갱신하거나 사용해야 할 때 ( 가능한가 ? )

Query 생성

그 후 저장할 데이터를 [키체인 속성: 데이터] 형태의 딕셔너리로 만들어서 쿼리 생성 후 데이터 저장

지금 건빵에서는 accessToken 과 refreshToken 을 저장할 것이기 때문에, kSecClass 값으로 kSecClassGenericPassword 를 선택했는데 … 사실 자세히 알아서 했다기보다는 일단은 제일 보편적으로 쓰이는걸로 선택 !

그렇게 선택한 query 의 키 값들은 아래와 같음

let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: serviceName,
    kSecAttrLabel as String: key.label,
    kSecValueData as String: value.data(using: .utf8) as Any
]

createKeychain()

static func createKeychain(of key: KeychainKey, with value: String) {
        
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: serviceName,
        kSecAttrLabel as String: key.label,
        kSecValueData as String: value.data(using: .utf8) as Any
    ]
    
    let status = SecItemAdd(query as CFDictionary, nil)
    #if DEBUG
    switch status {
    case errSecSuccess:
        print("🔒   Keychain created successfully   🔒")
    case errSecDuplicateItem:
        print("❌ Keychain item already exists ❌")
    default:
        print("❌ Unknown error: \\(SecCopyErrorMessageString(status, nil).debugDescription) ❌")
    }
    #endif
}

query 구성은 위에서 설명한 바와 같이 진행

그 후 status 라는 변수에 SecItemAddkeychain 추가 후 결과인 OSStatus 타입을 저장해두고, 디버깅 시 OSStatus 결과에 따라 print 문을 통해 표시해두는 코드도 추가해 뒀다

readKeychain()

static func readKeychain(of key: KeychainKey) -> String {
        
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: serviceName,
        kSecAttrLabel as String: key.label,
        kSecMatchLimit as String: kSecMatchLimitOne,
        kSecReturnData as String: true,
        kSecReturnAttributes as String: true
    ]
    
    var item: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &item)
    #if DEBUG
    switch status {
    case errSecSuccess:
        print("🔒    Keychain read successfully     🔒")
    case errSecItemNotFound:
        print("❌ Keychain item not found ❌")
        return ""
    default:
        print("❌ Unknown error: \\(SecCopyErrorMessageString(status, nil).debugDescription) ❌")
        return ""
    }
    #endif
    
    guard let decodedItem = item as? [String: Any],
          let tokenData = decodedItem[kSecValueData as String] as? Data,
          let token = String(data: tokenData, encoding: .utf8)
    else {
        print("❌ Keychain decoding failed ❌")
        return ""
    }
    
    return token
}

여기도 query 구성은 비슷한데, 추가된 부분이 몇 가지 있음

그 후 똑같이 #if DEBUG ~ #endif 문으로 키체인 사용 시 로그 찍어두었음

그리고 지금은 읽는 과정이고, 그 과정에서 디코딩도 필요하기 때문에, 디코딩 과정이 추가된다

<aside> 🤔 여기 구성하면서 고민하던 부분이, 버그가 발생했을 때 switch - case 문으로 status 에 따라 return “” 를 해뒀는데, 원래는 nil 값을 반환하려다가 키체인을 사용하는 지점에서 KeychainService.readKeychain() 을 실행시키면 올바른 토큰이 내려오더라도 Optional(”~~”) 로 내려오게 돼서 적절한 통신이 되지 않는 경우가 발생했다 ….

그래서 요 부분 어떻게 할지 고민 중 ….

</aside>

updateKeychain()

키체인이 좀 킹받는 부분 중 하나가, 이미 동일 키체인이 있으면 SecItemAdd() 를 했을 때 기존 것 위에 덮어씌워지는게 아니라, 중복된 값이라며 저장을 못하게 한다

그래서 키체인을 업데이트 하는 과정이 필요한데, 그건 SecItemUpdate() 으로 구현한다

static func updateKeychain(of key: KeychainKey, to value: String) {
        
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword
    ]
    let attributes: [String: Any] = [
        kSecAttrService as String: serviceName,
        kSecAttrLabel as String: key.label,
        kSecValueData as String: value.data(using: .utf8) as Any
    ]
    
    let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
    #if DEBUG
    switch status {
    case errSecSuccess:
        print("🔒  Keychain updated successfully  🔒")
    case errSecItemNotFound:
        print("❌ Keychain item not found ❌")
    default:
        print("❌ Unknown error: \\(SecCopyErrorMessageString(status, nil).debugDescription) ❌")
    }
    #endif
}

코드는 이전의 메소드들과 비슷비슷 ~~

deleteKeychain()