KeychainAccess
KeychainAccess とは Keychain に簡単にアクセスすることができるライブラリのこと。
ユーザのパスワードのような気密性の高いデータは UserDefaults や DB ではなく Keychain に保存することが推奨されている。
その Keychain は使いにくいことで有名だったのだが、KeychainAccess を使うことで簡単に利用することができる。
前回の記事では KeychainAccess を使ってデータ書き込みや読み込みを簡単にするための Extension について解説しました。
Service と Server
KeychainAccess ではインスタンス生成時に引数をつけることでServerかServiceかのどちらかを選択することができます。
ちなみに何もつけなかった場合にはアプリのバンドル ID がそのまま使われるみたいです
今までは慣習的にServerを利用していたのですが、Serviceとの違いは何なのでしょうか?
また、場合によっては一つのサービスについて複数のアカウント情報を保持し、ログイン時などにどちらのアカウントを選択するかユーザに選ばせたいような場合もあります。そのような複数アカウント機能を KeychainAccess で実装するにはどうすればよいでしょうか。
Server
公式ドキュメントにもあるようにウェブサイトでのパスコードの保存に使う。
例えば、以下のようなコードを書いたとしよう。
var keychain: Keychainkeychain = Keychain(server: "AAA", protocolType: .https)keychain["value"] = "AAA"print(keychain["value"])// -> AAA
keychain = Keychain(server: "BBB", protocolType: .https)keychain["value"] = "BBB"print(keychain["value"])// -> BBB
keychain = Keychain(server: "AAA", protocolType: .https)print(keychain["value"])// -> BBB二つのインスタンスは異なるものなのでAAAの値は保存されそうなのだが、実は上書きされてしまう。
というのも、このserverの値には URL に変換可能な文字列を代入する必要があるからだ。KeychainAccessライブラリの内部でStringからURLに変換される際に、変換不可能な倍にはserverには空文字が割り当てられている。
AAAもBBBも URL に変換不可能なのでどちらもserver=""が割り当てられているのと同じ状態になり、そのため二つは同一のインスタンスになってしまっている。
そのため、データが上書きされてしまっているのだ。
var keychain: Keychainkeychain = Keychain(server: URL(string: "https://tkgstrator.work")!, protocolType: .https)keychain["value"] = "AAA"print(keychain["value"])// -> AAA
keychain = Keychain(server: URL(string: "https://tkgstrator.works")!, protocolType: .https)keychain["value"] = "BBB"print(keychain["value"])// -> BBB
keychain = Keychain(server: URL(string: "https://tkgstrator.work")!, protocolType: .https)print(keychain["value"])// -> AAAこのように URL に変換可能な文字列または直接 URL を指定した場合には正しくデータが保存される。
Service
基本的には Server と同じなのですが、任意の文字列が利用できるという点が異なります。それ以外は全て同じです。
Keychain へのデータ保存
ここを勘違いしてしまっていたのですが、Keychain へのデータの保存は辞書型ではないようです。
KeychainAccess のインスタンスの中身は以下のようになっており、これらが配列として保存されています。
| Server | Service | |
|---|---|---|
| authenticationType | Enum | Enum |
| synchronizable | Bool | Bool |
| accessGroup | BundleID | BundleID |
| class | Enum | Enum |
| key | String | String |
| value | Any | Any |
| accessibility | Enum | Enum |
| protocol | Enum | - |
| server | URL | - |
| service | - | String |
つまり、例えばkeychain["price"] = 100のようなコードを書いたとしてもどこにもkeychain["price"]のデータはないということです。
じゃあどうやって保存されているのかというと、以下のようにkeyがpriceでvalueが100のデータ(正確には上のようにもっといろんなデータが入っているが)が配列に追加されているだけだということです。
[ [ "key": "price", "value": "100", "server": "tkgstrator.work" ], [ "key": "name", "value": "apple" "server": "tkgstrator.work" ],]これがどう困るかというと、サブアカウントのようなものを利用するときに困ります。
何故なら、このままだとどのアカウントのデータかを区別することができないからです。
値をユニークに保つために、Keychain では同一のkeyをもつことは許されていません。そうすれば単にデータが上書きされてしまうだけです。
[ [ "key": "userId", "value": "XXXXXXXX", "server": "tkgstrator.work" ], [ "key": "userId", "value": "YYYYYYYY", "server": "tkgstrator.work" ]]単なる配列ならこのようなデータも保存できますが、これは同一のキーなのでどちらか一方しか保存できません。最初に書き込んだ方のデータは失われます。
これの対策として考えられるのがServerないしはServiceの値を変更することです。こうすれば別のデータとして扱えます。
var keychain: Keychainkeychain = Keychain(server: URL(string: "https://tkgstrator.work/account01")!, protocolType: .https)keychain["userId"] = "XXXXXXXX"print(keychain["value"])
keychain = Keychain(server: URL(string: "https://tkgstrator.work/account02")!, protocolType: .https)keychain["userId"] = "YYYYYYYY"print(keychain["value"])つまり、このようにしてしまえば良いわけです。
ただし、この方法は次の観点から実装を見送りました。
- Keychain を切り替えるのがめんどくさい
- 切り替えるのは良いとして、そのための
Serverなどのリストはどうやって保存するのか - それも Keychain に入れれば仕様がややこしくなってしまう
- 切り替えるのは良いとして、そのための
- アカウント数が増えたときに切り替えるのがめんどくさい - アカウント数の分だけ Keychain のインスタンスを用意するのはめんどくさい
構造体を Keychain に保存する
そこで考えたのが、userId = XXXXXXXXのようなデータを保存するのではなく、キーとしてユーザ固有の値を与え、データにユーザ情報を全部入れてしまえばよいのではないかという方法でした。
// Beforekeychain["userId"] = "XXXXXXXX"keychain["password"] = "YYYYYYYY"keychain["balancee"] = "ZZZZZZZZ"
// Afterkeychain["XXXXXXXX"] = User(password: "YYYYYYYY", balance: "ZZZZZZZZ")しかし、これはこのままではビルドが通りません。Keychain に保存できるのは Data 型が String 型だと決まっているからです。
Codable を利用する
ですが、Swift には構造体を Data 型に変換するためのプロトコルがあります。
それが当ブログでも何度か取り上げたCodbaleというプロトコルで、これを使えば構造体を JSONEncoder で Data 型に変換できます。
いちいちデータをエンコードしたりデコードしたりはめんどくさいので、Extension を使ってそれらの部分をうまく処理してやりましょう。
// Keychianに保存する構造体をCodable準拠にするstruct Account: Codable { var userId: String = "" var password: String = "" var membership: Bool = false
init() {}}構造体を Codable にするには単に適合させるだけで良いです。何か特別なことをする必要はありません。
// Extensionextension Keychain { func setValue(forKey: String, account: Account) throws -> () { let encoder: JSONEncoder = { let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase return encoder }()
let data = try encoder.encode(account) try set(data, key: forKey) }
func getValue(forKey: String) throws -> Account { let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return decoder }() guard let data = try getData(forKey) { return try decoder.decode(Account.self, from: data) } throw fatalError() }}これはエラーを認めてそれを返すようなメソッドですが、ネットワークからレスポンスを受け取っているわけではないので実際にエラーが発生することは(おそらく殆どない)と思われます。
// エラーを握りつぶす場合extension Keychain { func setValue(forKey: String, account: Account) -> () { let encoder: JSONEncoder = { let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase return encoder }()
guard let data = try? encoder.encode(account) else { return } try? set(data, key: forKey) }
func getValue(forKey: String) -> Account? { let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return decoder }()
guard let data = try? getData(forKey) else { return nil } return try? decoder.decode(Account.self, from: data) }}エラーが発生しないようなケースであればtry?でエラーを握りつぶしてしまうのもアリです。
自分の場合はエラーが発生しないケースでしたので、後者を選択しました。
extension Keychain { func removeValue(account: Account) { try? remove(account.userId) }}最後に、データを削除できるようにしておいても良いかもしれません。
使い方
let keychain = Keychain(service: "work.tkgstrator")
// データ読み込み(ない場合はnilが返ってくる)guard let account = keychain.getValue(userId: "tkgling") else { return }
// データ書き込みlet account: Account = Account()keychain.setValue(account: account)構造体が Nil を許容する場合
構造体にオプショナル型のプロパティをつけても正しく動作しました。
struct Account: Codable { var userId: String? var password: String? var membership: Bool
init() {}}ただし、キーだけはオプショナルではダメなので、今回のようにuserIdをキーにする場合は書き込む前にuserIdがnilでないかだけはチェックする必要があります。
let keychain = Keychain(service: "work.tkgstrator")
var account: Account = Account()account.userId = "tkgstrator"keychain.setValue(account: account)
guard let account = keychain.getValue(userId: "tkgstrator") else { return }print(account)// Account(userId: Optional("tkgstrator"), password: nil, membership: nil)こうすれば直感的にデータを取得できるので便利でした。