いまどきのiOSアプリのログイン画面の実装について考える - 酢ろぐ!

酢ろぐ!

カレーが嫌いなスマートフォンアプリプログラマのブログ。

いまどきのiOSアプリのログイン画面の実装について考える

いまどきのiOSアプリでのログイン画面ってどんなかんじなんだろう?と考えてみた。本記事ではデザイン的な話ではなくて、内部の実装的な部分について言及しています。

結論としては、あまり小難しいことをしなくても、

  • ユーザーIDを入力するUITextField の textContentType をusernameにする
  • パスワードを入力するUITextField の textContentType をpasswordにする
  • 上記のふたつのUITextField を同一画面上に配置する
  • Associated Domainsを設定する (これが一番手間がかかる)

で、よいのではないかと思います。

本記事では、OAuth(SFSafariViewControlelr)を使ってのログインについては取り上げません。Sign In with Appleについても言及しません。

iOS 13以降がスタンダードになった世界ではSign In with Appleに丸投げできるんでしょうか(2019年9月現在、iOS 10のユーザーは随分少なくなってきましたが、iOS 10→iOS 11への移行している感じです。

よくあるログイン画面とその実装

よくあるログイン画面はこんな感じですね。ログイン画面に関しては、開発者・ユーザーともにこのような(普遍的な)イメージがあるのではないかと思います。「ログインする」ボタンのほかに「パスワードを忘れた時はこちら」ボタンがあったりなかったりくらいかなぁと。

f:id:ch3cooh393:20190914105958p:plain

余計な要素を取り除けば、ログイン処理はだいたいこんな感じ?

@IBAction func buttonAction(_ sender: Any) {
    guard
        let username = usernameTextField.text,
        let password = passwordTextField.text,
        !username.isEmpty,
        !password.isEmpty
        else {
            // ログイン、ダメです
            return
    }
    
    // サーバーにユーザーIDとパスワードを送って認証
    DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
        let hasError = false //response.get()
        if hasError {
            // ログイン、ダメです
        } else {
            // 成功したし、ホーム画面へ遷移する!
        }
    }
}

DispatchQueue.main.asyncAfterの部分は、WebAPIを呼び出しているイメージです。

いまどきのログイン画面とは

僕が理想とするログイン画面は、PCブラウザのログイン画面のような感じです。

ChromeやSafariで対応しているように、サービスに初回ログインしたときにユーザーIDとパスワードを記録しておいてもらいたい。二度目からログインする際には、OS標準のパスワードマネージャーから、任意のログイン情報を選択できるようにしたい。クソ長いパスワードを何回も入力したくないのです……

入力したログイン情報を保存する

Managing Shared Credentials を使えば、パスワードを記憶することができます。

たとえば前述のログイン処理に追加するのであれば、下記のようにSecAddSharedWebCredentialを使ってログイン情報を保存します。

// サーバーにユーザーIDとパスワードを送って認証
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
    let hasError = false //response.get()
    if hasError {
        // ログイン、ダメです
    } else {
        // 成功したし、ホーム画面へ遷移する!
        
        // ログインできたからパスワードを記録する
        SecAddSharedWebCredential("loginsample.ch3cooh.jp" as CFString, username as CFString, password as CFString, { (_) in                    
        })
    }
}

ログイン情報の読み出しに関しては SecRequestSharedWebCredentialを使いますが、岸川さんが2015年にManaging Shared Credentialsについて書いた記事があります。

iOSが標準でパスワード管理できるようになった

iOS 11からはパスワードマネージャーの利用できるようになりました。しかし、ネイティブアプリに関してはログイン情報の read のみで、 create/update はサポートされていませんでした。

iOS 12になってからは、パスワードマネージャーの利用シーンが広がりました。

まとめ

iOS 12でのパスワードマネージャー機能の強化を受けて、設定アプリの「パスワードとアカウント」の「パスワードを自動入力」をONにしているユーザーは恩恵を受けられるようになりました。

f:id:ch3cooh393:20190914210828p:plain

ログイン画面での実装

ログイン画面では、同一画面内に以下のようにtextContentTypeを指定しているUITextFieldがあれば、ソフトウェアキーボードの上にパスワードマネージャーを呼び出すショートカットが表示されます。

usernameTextField.textContentType = .username
passwordTextField.textContentType = .password

実装的にはとても簡単ですね。.username.password が使えるのは iOS 11以降ですのでご注意ください。

アカウント新規登録画面での実装

アカウントを作成するときには .newPassword を指定しておく(iOS 12以上の場合)。

usernameTextField.textContentType = .username
passwordTextField.textContentType = .newPassword

ただし、これだけでは不十分でAssociated Domainsの設定が必要です。

Associated Domainsの設定のハマりどころ

Associated Domainsの設定が、ちゃんとできているかどうかわからず困った。Apple Developerのポータルページで、アプリが使っているTeamIDを調べてようやく設定ができた。注意する点としては、

  • ファイル名は apple-app-site-association であること
  • apple-app-site-association はJSONファイルで、アプリのTeam IDとBundle IDを指定する
  • ファイルをアップロードするディレクトリ名は .well-known であること
  • たとえば https://blog.ch3cooh.jp/login_sample/.well-known/apple-app-site-association にアップロードした場合、Xcode上で指定する指定は webcredentials:blog.ch3cooh.jp/login_sample となる

トラブルシューティング

実際にアプリに実装してみて、かなりハマったので知見を残しておきます。

新規会員登録またはパスワードリセット画面のケースで、iCloudキーチェーンへのパスワードの追加/更新は下記の条件で実行されます。

  • ユーザーIDを入力するUITextField の textContentType をusernameになっている
  • パスワードを入力するUITextField の textContentType をnewPasswordになっている
  • 上記のふたつのUITextField を同一画面上に配置されている
  • Associated Domainsを設定されている
  • 画面が閉じられたとき、勝手にiCloudキーチェーンが更新される ←←←ここ注目!!!

UIの変更など、実装自体はスムーズに進みましたが、「パスワードが保存されない」「キャンセル(閉じる)ボタンを押したら、違うパスワードが勝手に保存された」で大いにハマってしまいました。

「強力なパスワードを使用する」が出るのにパスワードが保存されない

iCloudキーチェーンに保存されるときには「画面を閉じる」というアクションが必要です。現在開発中のアプリでは、起動時にログイン状態によって表示する画面を切り分けています。

  • 未ログイン時に AppDelegateのwindowに InitialViewController
  • ログイン済み時に AppDelegateのwindowに MainViewController

を設定しています。

ログイン情報を送って認証に成功したあと self.window = MainViewController() を実行していましたが、これでは画面を閉じたことにならずログイン情報が保存されませんでした。

if let delegate = UIApplication.shared.delegate as? AppDelegate {
    delegate.moveHomeScreen() //self.window = MainViewController()を実行している
}

下記のように、画面を一旦閉じてiCloudキーチェーンへパスワードの保存をうながすようにすることで、パスワードを保存した上でMainViewControllerへ遷移させるようにしました。

self?.dismiss(animated: false, completion: {
    if let delegate = UIApplication.shared.delegate as? AppDelegate {
        delegate.moveHomeScreen() //self.window = MainViewController()を実行している
    }
})

iCloudキーチェーンへのパスワード保存は viewWillDisappear 以降におこなわれるということを理解しておく必要があります。

キャンセル(閉じる)ボタンを押したら、違うパスワードが勝手に保存された

新規登録画面/パスワードリセット画面で「強力なパスワードを使用する」を出したあとに、キャンセルボタンを押して画面を閉じると、間違ったパスワードが勝手に保存されてしまいます。

画面を閉じるとパスワードが保存される ので、画面を閉じる前にUITextFieldのtextプロパティにnilを設定します。

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        if Service.mainAccessToken == nil {
            textFields?[0].text = nil
            textFields?[1].text = nil
        }
    }

newPasswordを設定しているUITextFiledをタップしても「強力なパスワードを使用する」が表示されない

Associated Domainsの設定に失敗しています。

もしくは、アプリ側で正しくapple-app-site-association が読み込めていません。アプリを一旦削除して、再度インストールしてください。