iOS數據持久化——KeyChain

語言: CN / TW / HK

在我們開發iOS應用的時候,很多時候,我們都需要將敏感數據(password, accessToken, secretKey等)存儲到本地。對於初級程序員來講,首先映入腦海的可能是使用UserDefaults。然而,眾所周知,使用UserDefaults來存儲敏感信息簡直是low的不能再low的主意了。因為我們一般存儲到UserDefaults中的數據都是未經過編碼處理的,這樣是非常不安全的。

為了能安全的在本地存儲敏感信息,我們應當使用蘋果提供的KeyChain服務。這個framework已經相當老了,所以,我們在後面閲讀的時候,會覺得它提供的API並不像當下的framework那麼快捷。

在本文中,將為你展示如何創建一個通用的同時適用於iOS、MacOS的keyChain輔助類,對數據進行增刪改查操作。開始吧!!!

保存數據到KeyChain

final class KeyChainHelper { static let standard = KeyChainHelper() private init(){} }

我們必須巧妙使用SecItemAdd(_:_:)方法,這個方法會接收一個CFDictionary類型的query對象。

這個主意是為了創建一個query對象,這個對象包含了我們想要存儲最主要的數據鍵值對。然後,將query對象傳入SecItemAdd(_:_:)方法中來執行保存操作。

``` func save(_ data: Data, service: String, account: String) {

// Create query
let query = [
    kSecValueData: data,
    kSecClass: kSecClassGenericPassword,
    kSecAttrService: service,
    kSecAttrAccount: account,
] as CFDictionary

// Add data in query to keychain
let status = SecItemAdd(query, nil)

if status != errSecSuccess {
    // Print out the error
    print("Error: (status)")
}

} ```

回看上述代碼片段,query對象由4個鍵值對組成: - kSecValueData: 這個鍵代表着數據已經被存儲到了keyChain中 - kSecClass: 這個鍵代表着數據已經被存儲到了keyChain中。我們將它的值設為了kSecClassGenericPassword,這代表着我們所保存的數據是一個通用的密碼項 - kSecAttrServicekSecAttrAccount: 當kSecClass被設置為kSecClassGenericPassword的時候,kSecAttrServicekSecAttrAccount這兩個鍵是必須要有的。這兩個鍵所對應的值將作為所保存數據的關鍵key,換句話説,我們將使用他們從keyChain中讀取所保存的值。

對於kSecAttrServicekSecAttrAccount所對應的值的定義並沒有什麼難的。推薦使用字符串。例如:如果我們想存儲Facebook的accesToken,我們需要將kSecAttrService設置成”access-token“,將kSecAttrAccount設置成”facebook“

創建完query對象之後,我們可以調用SecItemAdd(_:_:)方法來保存數據到keyChain。SecItemAdd(_:_:)方法會返回一個OSStatus來代表存儲狀態。如果我們得到的是errSecSuccess狀態,則意味着數據已經被成功保存到keyChain中

下面是save(_:service:account:)方法的使用

let accessToken = "dummy-access-token" let data = Data(accessToken.utf8) KeychainHelper.standard.save(data, service: "access-token", account: "facebook")

keyChain不能在playground中使用,所以,上述代碼必須寫在Controller中。

更新KeyChain中已有的數據

現在我們有了save(_:service:account:)方法,讓我們用相同的kSecAttrServicekSecAttrAccount所對應的值來存儲其他token

let accessToken = "another-dummy-access-token" let data = Data(accessToken.utf8) KeychainHelper.standard.save(data, service: "access-token", account: "facebook")

這時候,我們就無法將accessToken保存到keyChain中了。同時,我們會得到一個Error: -25299的報錯。該錯誤碼代表的是存儲失敗。因為我們所使用的keys已經存在於keyChain當中了。

為了解決這個問題,我們需要檢查這個錯誤碼(相當於errSecDuplicateItem),然後使用SecItemUpdate(_:_:)方法來更新keyChain。一起看看並更新我們前述的save(_:service:account:)方法吧:

``` func save(_ data: Data, service: String, account: String) {

// ... ...
// ... ...

if status == errSecDuplicateItem {
    // Item already exist, thus update it.
    let query = [
        kSecAttrService: service,
        kSecAttrAccount: account,
        kSecClass: kSecClassGenericPassword,
    ] as CFDictionary

    let attributesToUpdate = [kSecValueData: data] as CFDictionary

    // Update existing item
    SecItemUpdate(query, attributesToUpdate)
}

} ```

跟保存操作相似的是,我們需要先創建一個query對象,這個對象包含kSecAttrServicekSecAttrAccount。但是這次,我們將會創建另外一個包含kSecValueData的字典,並將它傳給SecItemUpdate(_:_:)方法。

這樣的話,我們就可以讓save(_:service:account:)方法來更新keyChain中已有的數據了。

從KeyChain中讀取數據

從keyChain中讀取數據的方式和保存的方式非常相似。我們首先要做的是創建一個query對象,然後調用一個keyChain方法:

``` func read(service: String, account: String) -> Data? {

let query = [
    kSecAttrService: service,
    kSecAttrAccount: account,
    kSecClass: kSecClassGenericPassword,
    kSecReturnData: true
] as CFDictionary

var result: AnyObject?
SecItemCopyMatching(query, &result)

return (result as? Data)

} ```

跟之前一樣,我們需要設置query對象的kSecAttrService and kSecAttrAccount的值。在這之前,我們需要為query對象添加一個新的鍵kSecReturnData,其值為true,代表的是我們希望query返回對應項的數據。

之後,我們將利用 SecItemCopyMatching(_:_:) 方法並通過引用傳入 AnyObject 類型的result對象。SecItemCopyMatching(_:_:)方法同樣返回一個OSStatus類型的值,代表讀取操作狀態。但是如果讀取失敗了,這裏我們不做任何校驗,並返回nil

讓keyChain支持讀取的操作就這麼多了,看一下他是怎麼工作的吧

let data = KeychainHelper.standard.read(service: "access-token", account: "facebook")! let accessToken = String(data: data, encoding: .utf8)! print(accessToken)

從KeyChain中刪除數據

如果沒有刪除操作,我們的KeyChainHelper類並不算完成。一起看看下面的代碼片段吧

``` func delete(service: String, account: String) {

let query = [
    kSecAttrService: service,
    kSecAttrAccount: account,
    kSecClass: kSecClassGenericPassword,
    ] as CFDictionary

// Delete item from keychain
SecItemDelete(query)

} ```

如果你全程都在看的話,上述代碼可能對你來説非常熟悉,那是相當的”自解釋“了,需要注意的是,這裏我們使用了SecItemDelete(_:)方法來刪除KeyChain中的數據了。

創建一個通用的KeyChainHelper 類

存儲 ``` func save(_ item: T, service: String, account: String) where T : Codable {

do {
    // Encode as JSON data and save in keychain
    let data = try JSONEncoder().encode(item)
    save(data, service: service, account: account)

} catch {
    assertionFailure("Fail to encode item for keychain: (error)")
}

} 讀取 func read(service: String, account: String, type: T.Type) -> T? where T : Codable {

// Read item data from keychain
guard let data = read(service: service, account: account) else {
    return nil
}

// Decode JSON data to object
do {
    let item = try JSONDecoder().decode(type, from: data)
    return item
} catch {
    assertionFailure("Fail to decode item for keychain: \(error)")
    return nil
}

} 使用 struct Auth: Codable { let accessToken: String let refreshToken: String }

// Create an object to save let auth = Auth(accessToken: "dummy-access-token", refreshToken: "dummy-refresh-token")

let account = "domain.com" let service = "token"

// Save auth to keychain KeychainHelper.standard.save(auth, service: service, account: account)

// Read auth from keychain let result = KeychainHelper.standard.read(service: service, account: account, type: Auth.self)!

print(result.accessToken) // Output: "dummy-access-token" print(result.refreshToken) // Output: "dummy-refresh-token" ```