- 投稿日:2020-11-27T20:53:29+09:00
Firebase Authenticationを使ってiOSアプリのログイン機能を実装する
最近、iOSアプリにFirebase Authentication を使ってSMS認証を実装する機会がありました。
実際に利用してみた感想としては、ログイン機能
や新規会員登録
などの複雑を要する機能を比較的簡単に品質の高く実装できるのでおすすめ出来ると感じました。前提
- Firebase/Auth をプロジェクトに追加し、
pod install
の実行までが完了している
- まだの方は、Firebase を iOS プロジェクトに追加する を参照しFirebaseSDKの導入と、Firebase/Authのinstallをお願いします。
- 各イベントのバインディング処理についてはRxSwift、RxCocoaを利用する
- RxSwiftに関する理解はこちらの記事がおすすめです: RxSwiftについてようやく理解できてきたのでまとめることにした(1)
参考となる資料
Firebaseはドキュメントが充実しており、環境構築に関しては公式のドキュメントの手続きに沿えば基本的に問題ないと感じました。
GoogleによるFirebase公式ドキュメント
その他の参考とした資料
Firebaseへ電話番号の送信を行う
- ユーザーのiPhoneに認証コードをSMSで送信するようFirebaseにリクエストする処理は以下です
- 詳細については公式ドキュメントを参照ください → ユーザーの電話に確認コードを送信する
[注意点] 日本の電話番号コード
+81
を電話番号の先頭に付与する必要があるので、注意してください。func sendPhoneNumber(phoneNumber: String) { let num = "+81" + phoneNumber // Firebaseへ電話番号を送信 PhoneAuthProvider.provider().verifyPhoneNumber(num, uiDelegate: nil) { [weak self] verificationID, error in if let error = error { // Errorの場合はこちらへ } if let id = id { // 取得したauthVerificationIDをUserDefaultsへ保存 UserDefaults.standard.set(verificationID, forKey: "authVerificationID") // SMS画面へ遷移させる } } }
verificationID
を永続領域に保存するメリットについては、Firebaseの公式ドキュメントに記載がある通りです。確認 ID を保存し、アプリの読み込み時に復元します。これにより、ユーザーがログインフローを完了する前にアプリが終了した場合でも(たとえば SMS アプリへの切り替え時など)、有効な確認 ID を残すことができます。
確認コードを使ってユーザーをログインさせる
- 詳細については公式ドキュメントを参照ください → 確認コードを使ってユーザーをログインさせる
以下の関数を使ってユーザの クレデンシャル を発行します。
クレデンシャル とは、ユーザのIDやPWなどのユーザ情報におけるパラメータの総称です。
クレデンシャルを生成するための関数の第一引数には電話番号を送信する際に永続領域に保存したverificationID
を渡し、第二引数にはユーザが実際に入力した確認コードを渡します。コールバックでログイン結果が返されるので、返ってきた結果によってエラー文言を出し分けたり、インジケータを表示してユーザに通信状態を伝えるとより良いと思いました。
// ユーザから入力された確認コードで func sendSMSAuthCode(code: String) { // クレデンシャルの発行 let id = userDefault.object(forKey: "authVerificationID") as? String ?? "" let credential = PhoneAuthProvider.provider().credential(withVerificationID: id, verificationCode: code) Auth.auth().signIn(with: credential) { [weak self] authResult, error in if let error = error { guard let errorCode = AuthErrorCode(rawValue: error._code) else { return } switch errorCode { case .invalidVerificationCode: print("認証コードが正しくありません") default: print("エラーが起きました。しばらくしてから再度お試しください。") } } if let _ = authResult { let currentUser = Auth.auth().currentUser currentUser?.getIDTokenForcingRefresh(true) { idToken, _ in guard let _ = idToken else { print("トークンの取得に失敗しました") return } // LOGIN SUCCESS!!!! } } } }以上で、ログイン機能は完了で??
アプリ初回起動時にログイン状態にあるかどうかを取得したい
ログイン機能は実装できましたが、アプリ起動時にユーザにログインフローを毎回踏んで貰うのは現実的ではありません。
Firebaseは特にユーザに操作をさせず、ログイン済みかどうかのステータスを取得する事ができます。したがって、AppDelegateやユーザ情報を取得する処理の中で以下の処理を実行するとログイン済みの場合はtokenが取得出来ます。
私はとりあえず、tokenの取得成功
サインインが必要
エラー
のステータスを用意してみました。enum FirebaseTokenCallbackResult { case success(token: String), needSignIn, error(error: Error?) } func fetchFirebaseToken() -> Observable<FirebaseTokenCallbackResult> { return Observable.create { observer in guard let currentUser = Auth.auth().currentUser else { // 自分自身のアカウントが取得できなかった場合は、新規会員登録のフローを踏む observer.onNext(.needSignIn) return Disposables.create() } currentUser.getIDToken(completion: { [weak self] idToken, error in if let token = idToken { self?.firebaseToken = token return observer.onNext(.success(token: token)) } return observer.onNext(.error(error: error)) }) return Disposables.create() } }注意点
AppDelegateで以下の処理を呼び出して初期化すると思いますが、以下の処理が実行されるよりも先にAuthなどの処理を呼び出してしまうとクラッシュする可能性があります。そのため、以下の処理が実行されるよりも先に
currentUser
などを取得しないよう注意が必要です。FirebaseApp.configure()以上です。
- 投稿日:2020-11-27T14:03:49+09:00
サーバーサイドエンジニアが知っておくべきiOSアプリのデプロイとかテスト周りの知識をメモる
- 投稿日:2020-11-27T13:49:23+09:00
モーダル遷移
- 投稿日:2020-11-27T13:05:48+09:00
iOS 上で巨大なファイルを復号する方法
エキサイトで刺身にたんぽぽを添える仕事をしているエンジニアの坂田です。
cocorus と言うアプリの iOS版 を開発したりしてます。突然ですが 1GB 超の巨大なファイルの暗号化/復号を iOS (iPhone 実機)上で行おうとして OS 側からアプリを落とされる経験をされたことはありますか?
自分はあります。
当記事では巨大なファイルの復号を iOS 上で行うための方法を紹介します。想定
- マシン
iPhone (iOS 14等)- 言語
Swift 5- 想定暗号
aes-128-cbc一般的な復号コード
おそらくセオリー通り実装すると下記のようなコードになるかと思います。
import CommonCrypto class CommonCryptoUtility { /// 中略 /// 復号処理 /// /// - Parameters: /// - encryptedData: 暗号化されたData型リソース /// - keyData: 暗号キーバイナリ /// - initializationVectorData: 初期化ベクターバイナリ /// - isPadding: PKCS#7 パディングの有無 /// - Returns: 復号されたData型リソース /// - Throws: CommonCryptoUtilityError のいずれか func decrypt( encryptedData: Data, keyData: Data, initializationVectorData: Data, isPadding: Bool = true ) throws -> Data { // 復号化後のメモリーサイズ let decryptBufferLength: Int = encryptedData.count // 復号化後のメモリを確保 var decryptBufferData = Data(count: decryptBufferLength) // デコード処理バイト数 var executeBytesCount: size_t = 0 // デコード let options: UInt32 = isPadding ? CCOptions(kCCOptionPKCS7Padding) : 0 do { try keyData.withUnsafeBytes { (keyBytes: UnsafeRawBufferPointer) in try initializationVectorData.withUnsafeBytes { (ivBytes: UnsafeRawBufferPointer) in try encryptedData.withUnsafeBytes { (encryptedDataPointer: UnsafeRawBufferPointer) in try decryptBufferData.withUnsafeMutableBytes { decryptBufferDataPointer in guard let keyBytesBaseAddress = keyBytes.baseAddress, let ivBytesBaseAddress = ivBytes.baseAddress, let encryptedDataBytesBaseAddress = encryptedDataPointer.baseAddress, let decryptBufferDataBytesBaseAddress = decryptBufferDataPointer.baseAddress else { throw CommonCryptoUtilityError.decryptFailedWhenConvertBaseAddress } let cryptStatus = CCCrypt( CCOperation(kCCDecrypt), CCAlgorithm(kCCAlgorithmAES), options, keyBytesBaseAddress, keyData.count, ivBytesBaseAddress, encryptedDataBytesBaseAddress, encryptedData.count, decryptBufferDataBytesBaseAddress, decryptBufferLength, &executeBytesCount ) guard cryptStatus == kCCSuccess else { throw CommonCryptoUtilityError.decryptFailed } } } } } } catch { throw CommonCryptoUtilityError.decryptFailedWhenConvertUnsafePointer } return decryptBufferData } // 中略 }ただ、上記のような実装だと暗号化データをすべて渡すので、このままだとメモリを食いつぶして iOS にアプリを落とされてしまいます。
なので、この関数をラップして分割して復号したくなるのが心情です。
幸いなことに、AES 128 CBC
と言う暗号モードは16バイト単位での暗号化を行っており、暗号ブロックの一つ前のブロックがそのまま今のブロックの初期化ベクターになるとのことです。
つまり、前の16バイトさえわかればどのブロックからでも複合できるということになります。指定ブロックを取り出すための復号処理のラッパー
上記を踏まえてブロック毎に復号するように上記メソッドを呼び出すラッパーを実装すると以下のようになります。
import CommonCrypto class CommonCryptoUtility { // 中略 let blockSizeAES128: UInt64 = UInt64(kCCBlockSizeAES128) /// 指定された範囲を復号する /// - 2 GiB 以内のファイルのみ扱うものとする(32Bit) /// - 8 EiB 以内のファイルのみ扱うものとする(64Bit) /// - Parameters: /// - sourceURL: ファイルURL /// - keyData: 暗号キーデータ /// - firstInitVectorData: 初期化ベクターデータ /// - requiredSliceOffset: 切り出すオフセット /// - sliceLength: 切り出すバイト数 /// - Returns: 複合したバイナリデータ func decrypt( readFrom sourceURL: URL, keyData: Data, firstInitVectorData: Data, offset requiredSliceOffset: UInt64, sliceLength: Int ) throws -> Data { let fileHandler = try FileHandle(forReadingFrom: sourceURL) defer { fileHandler.closeFile() } guard let sourceFileSize = MediaManager.getFileSize(from: sourceURL) else { throw CommonCryptoUtilityError.destinationStreamOpenFailed } guard requiredSliceOffset + UInt64(sliceLength) <= sourceFileSize else { throw CommonCryptoUtilityError.requestSizeGreaterThanSource } guard 0 < sliceLength else { throw CommonCryptoUtilityError.lengthLessThanOne } let alignedOffset4decrypt: UInt64 = requiredSliceOffset / blockSizeAES128 * blockSizeAES128 let heading: Int = Int(requiredSliceOffset - alignedOffset4decrypt) let totalDecryptBlockAmount: Int = (sliceLength + heading) / kCCBlockSizeAES128 + 1 let totalDecryptBlockLength: Int = totalDecryptBlockAmount * kCCBlockSizeAES128 let dropLength: Int = totalDecryptBlockLength - (sliceLength + heading) // iv設定 let initVectorData: Data if alignedOffset4decrypt == 0 { initVectorData = firstInitVectorData } else { fileHandler.seek(toFileOffset: alignedOffset4decrypt - blockSizeAES128) initVectorData = fileHandler.readData(ofLength: kCCBlockSizeAES128) } fileHandler.seek(toFileOffset: UInt64(alignedOffset4decrypt)) // 復号 let encryptedChunkData = fileHandler.readData(ofLength: Int(totalDecryptBlockLength)) let decryptedChunk: Data = try decrypt( encryptedData: encryptedChunkData, keyData: keyData, initializationVectorData: initVectorData, isPadding: alignedOffset4decrypt + UInt64(totalDecryptBlockLength) == sourceFileSize ) // 要求されたバイト数を切り出し let extractData = decryptedChunk.subdata(in: heading..<(totalDecryptBlockLength - dropLength)) return extractData } // 中略 }これでどのオフセットからでもデータを切り出すことが出来るようになりました。
分割して復号しつつ保存する
最終目的である、暗号化されたファイルを読んで復号し、ファイルへ書き出すように上記メソッドを更にラップすると以下のようになります。
import CommonCrypto class CommonCryptoUtility { // 中略 // 最大処理チャンクサイズ(100MiB) let cryptChunkSize: Int = 104857600 /// ファイルへ復号 /// /// - 暗号化ファイルを読み出し、CommonCryptoUtility.chunkSize毎に復号処理をしてテンポラリファイルへ書き出す。 /// - Parameters: /// - sourceURL: 暗号化されたファイルのURL /// - destinationURL: 保存先のURL /// - keyData: 暗号キーバイナリ /// - firstInitVectorData: 初期化ベクターバイナリ /// - Throws: CommonCryptoUtilityError のいずれか func decrypt( readFrom sourceURL: URL, storeTo destinationURL: URL, keyData: Data, firstInitVectorData: Data ) throws { do { // ファイルストリームを用意 guard let destinationData = OutputStream(url: destinationURL, append: false), let _sourceFileSize = MediaManager.getFileSize(from: sourceURL) else { throw CommonCryptoUtilityError.destinationStreamOpenFailed } destinationData.open() defer { // ファイルストリームを閉じる destinationData.close() } let sourceFileSize = Int(_sourceFileSize) var totalSliceSize = 0 while totalSliceSize < sourceFileSize { let currentChunkSize = sourceFileSize - totalSliceSize > cryptChunkSize ? cryptChunkSize : sourceFileSize - totalSliceSize let decryptedChunk: Data do { // チャンク毎に復号 decryptedChunk = try decrypt( readFrom: sourceURL, keyData: keyData, firstInitVectorData: firstInitVectorData, offset: UInt64(totalSliceSize), sliceLength: currentChunkSize ) } catch { throw error } var decryptedBytes = [UInt8](decryptedChunk) // 保存先に追記 let writeResult = destinationData.write(&decryptedBytes, maxLength: decryptedBytes.count) guard writeResult >= 0 else { throw CommonCryptoUtilityError.failedToWriteToDestination } totalSliceSize += currentChunkSize } } } // 中略 }これで実用的なメモリ使用量でファイルの復号が出来るようになりました。
しかしながら、複数の巨大な暗号化ファイルを連続して復号すると、mach_task_basic_info()
などで正しくメモリを開放しているように見えても、Instruments 上ではメモリ使用量が増え続けました。
どうやら iOS 上(.app コンテナを実行する vm 上?)ではまだ開放されていないようで、それらが開放されるまで若干の時差があるようです。
なので、連続して複合処理をする場合は数秒のディレイを入れながら復号すると成功するかと思います。参考
https://github.com/backslash-f/aescryptable/blob/master/Sources/AESCryptable/AESCryptable.swift
最後に
良いのか悪いのか cocorus iOS版 の実コードから引っ張ってきています。
ま、アプリはコンテンツが主体なのでこの程度は問題ないでしょう。
それに、これを実装するため参考にしたコードを公開している方々へ、せめてものお返しとして辛かった部分をあえて公開することにしました。ちなみに切り出しているので上記に書かれていない enum などあるので実際使う場合は適宜置き換えるなり定義するなりしないと動きません。
コードも回りっくどいのは試行錯誤の跡だと思って大目に見てください。
(改善案はお待ちしてます!)あ、そうそう。
エキサイト株式会社ではエンジニアを随時募集しているようです。
気軽な気持ちで応募してみても良いのではないでしょうか?
https://www.wantedly.com/companies/excite