Firebase Authentication + SPA(React/TypeScript) でサンプル作り直してみた

　以前、Firebase Authentication によるユーザ認証周りについて理解しておく目的で、
[Firebase Authentication + SPA(React/TypeScript) でユーザ登録／ログイン／ログアウト](https://qiita.com/katsu-o/items/df1684b98cccd002e041)として作ってみましたが、納得いかんので作り直して見ました[^1]。

※[ソースコード一式](https://github.com/katsu-o/firebase-auth-spa) は GitHub に置いています[^2]。

# 起動方法
※ 以下、[GitHub: README.md](https://github.com/katsu-o/firebase-auth-spa/blob/master/README.md) に書いている内容です。
###### 前提条件
- Firebase プロジェクトを 1 つ用意する。
    - そのプロジェクトの Authentication で **メール / パスワード** と **Google** を有効にしておくこと。また、**1 つのメールアドレスにつき 1 つのアカウント** (デフォルト)としておくこと。
    - Facebook, Twitter, GitHub への連携も可能(ただしその場合は各プロバイダ側での設定も必要)[^3]。

###### 必要な環境
(バージョンは動作確認済バージョン)

- Node >= 10.12.0
- npm >= 6.4.1
- yarn >= 1.10.1 (npm を使う場合は適宜読み替えて下さい)
- firebase-tools >= 5.1.1

###### インストールから起動まで
```sh
# 1. GitHub からクローン
$ git clone git@github.com:katsu-o/firebase-auth-spa.git
$ cd firebase-auth-spa

# 2. 依存パッケージのインストール
$ yarn install

# 3. .env ファイルを作成
$ mv .env.sample .env

# 4. .env ファイルの中身を埋める
# Firebase コンソールの Authentication にある「ウェブ設定」をクリックすると表示される
# config を当該箇所にセットして下さい。
# .env ファイルへの設定文字列にクォートは不要です。=の後に余計な空白もつけないでください。

# 5. Firebase へログイン
$ firebase login

# 6. Firebase プロジェクトの選択
$ firebase use --add

# 7. サンプル起動
$ yarn start

以上です。
Hosting URL へアクセスして下さい。
```

# 使い方

### Sign Up
　ユーザーアカウントの作成(Sign Up)を行います。
<img width="364" alt="signup.png" src="https://qiita-image-store.s3.amazonaws.com/0/209550/571f8bb8-6bbb-8f63-8c41-8f681075a977.png">

- Google 認証と Email & Password 認証の 2 種類のみ。
- ただし Email & Password 認証での Sign Up は @gmail.com 以外に限定。

### Sign In
　既存ユーザーアカウントでの Sign In を行います。
<img width="364" alt="signin.gif" src="https://qiita-image-store.s3.amazonaws.com/0/209550/fe89b2ea-c481-67c7-961b-fb29cb35b552.gif">

- 各種プロバイダ認証と Email & Password 認証により、アプリケーションに Sign In。
- あくまで Sign In であり、この画面からの操作によりユーザーアカウントの新規作成はさせない。
- パスワードリセットメール送信画面へのリンクを設置。

### Settings(Sign In 後の画面)
　ユーザーアカウントに関する各種情報の設定を行います。
<img width="364" alt="settings1.png" src="https://qiita-image-store.s3.amazonaws.com/0/209550/64451e2e-68e6-aa0b-ae42-ad4790720ded.png">
<img width="364" alt="settings2.png" src="https://qiita-image-store.s3.amazonaws.com/0/209550/120831b0-d1b9-5022-1771-ec2723139d16.png">
<img width="364" alt="settings3.png" src="https://qiita-image-store.s3.amazonaws.com/0/209550/1dc02e35-a46f-8e38-57b9-b251d81da2cf.png">

- アカウント Email アドレスの変更
- Profile(displayName, PhotoURL)の変更
- 認証プロバイダの追加／削除
- パスワード変更
- 退会(アカウントの削除)

### Reset Password
　パスワードリセット用リンクをメール送信します。
　(※画面省略)

- そのままです。ただし画面文言にも書いてますが、パスワードリセットするとそのユーザーアカウントの Email & Password 以外のプロバイダ認証が全て解除されます。

### 補足
- EmailVerification を必須に設定([後述](#ソースコード上の要所))すると、Email & Password で Sign Up した場合に送られる Veification Email のリンクをクリックしてアカウントの状態を emailVerified にしないとサインインできなくすることもできます。

# Firebase Authentication を使う上でのポイント
　実際作ってみて分かったことがいくつかありました。Firebase Authentication を使う上で知っておいた方が良いと思ったことを列挙します(あくまで Web 版 SDK を今回のような前提条件で作った場合の話ではありますが)。
※下記は、あくまで 2018/10/29 時点で確認した内容です(今後 Firebase 側が仕様を変更する可能性は充分にあり得ます)

### 1. 認証状態は ```onAuthStateChanged()``` で監視していればよい。

　認証状態が変更された場合、```onAuthStateChanged()``` オブザーバーがトリガーされるので、そこで認証状態を監視します。認証処理の then ブロックで待ち受けてという方法もありますが、公式にも以下のように書かれています。
> 現在ログインしているユーザーを取得するには、Auth オブジェクトでオブザーバーを設定することをおすすめします。


### 2. redirect 後は、```getRedirectResult()``` -> ```onAuthStateChnaged()``` の順で処理が進む。

　redirect 系の処理(```signInWIthRedirect()``` や ```linkWithRedirect()```)の後、再び SPA に戻って来た時、まず ```getRedirectResult()``` の処理が行われた後で ```onAuthStateChnaged()``` オブザーバーがトリガーされることになります。
　つまり redirect 系処理の後で認証前になんらかの処理が必要な場合は、```getRedirectResult()``` で処理すればよいです。
　ちなみに redirect 系処理の後、```getRedirectResult()``` を実行しなくても、しばらくすると ```onAuthStateChnaged()``` オブザーバーがトリガーされます。


### 3. プロバイダ認証を使って Sign In する場合、新規にユーザーアカウントが作成される場合がある。
　あくまで Sign In のつもりなのに、ユーザーアカウントが作成されることがあるということです。
　プロバイダ認証を使って Sign In する場合、```signInWithRedirect(provider)``` 等のメソッドを使用しますが、当該プロバイダによる認証が成功した場合、そのプロバイダで認証された認証情報がどのユーザーアカウントにも紐付けられておらず、且つ、そのプロバイダによって返される Email アドレスがユーザーアカウントとして存在しない場合には、その Email アドレスをユーザーアカウントとする当該プロバイダ認証と紐づけられたユーザーアカウントが新規作成されます。

　具体的には GitHub に登録されている Email アドレスが xxx@gmail.com であった場合、ユーザーアカウントに xxx@gmail.com が存在しなければ xxx@gmail.com というユーザーアカウントが新規作成されるケースがあるということです。

### 4. Google 認証で Sign In した場合に既存アカウントを上書きする場合がある。
　xxx@gmail.com が Google 認証以外でユーザーアカウントとして登録されている状態で、xxx@gmail.com を Google 認証で ```signInWithRedirect(provider_google)``` 等としてサインインした場合、xxx@gmail.com は Google 認証のユーザーアカウントとして上書きされてしまいます(この時、それまで登録されていた Google 認証以外の認証方法はクリアされてしまいます)。

　つまり、Google 認証で Sign In した場合、意図せずユーザーアカウントが上書きされるケースがあるということです。
　なお、例外ハンドリング等でこの上書きを阻止することはできないようです(最終的に上書きされた後の結果が返されるだけ)。
[Firebase Overwrites Signin with Google Account](https://code.i-harness.com/en/q/26e0b68)
> Since google is the trusted provider for @gmail.com addresses it gets higher priority than other accounts using a gmail as their email. This is why if you sign in with Facebook then Gmail an error isn't thrown, but if you try going Gmail to Facebook then it does throw one.


### 5. ユーザーアカウントに複数の認証プロバイダをリンクする際、追加したい認証情報が既にユーザーリスト内に存在する場合、その認証情報はリンクできない。
　以下いずれかに該当するとその認証情報はリンクできません。1. については当然かなと思うのですが、2. については私はそんなに当然とは感じませんでした。

1. 「追加したい認証情報が別のユーザーにリンクされている」 
2. 「追加したい認証情報が持つ Email アドレスが既にユーザーアカウントとして存在する」

### 6. ```onAuthStateChanged()``` オブザーバーのトリガーについて。

　```onAuthStateChanged()``` で認証状態を監視するのですが、認証関連情報の更新を行った場合にトリガーされると思ってたけどされなくてちょっとハマりました。各メソッドによるトリガーの有無を表にしました(redirect 系処理は一旦画面遷移してしまうので書いてません)。
　基本的にはカレントユーザーへの更新処理時は ```firebase.auth().currentUser.delete()``` 時以外発火されないようです。

|メソッド|内容|トリガー|
|---|---|:-:|
|firebase.auth().createUserWithEmailAndPassword()|ユーザーアカウント作成|○|
|firebase.auth().signInWithEmailAndPassword() |Sign In(Email & Password)|○|
|firebase.auth().signOut()|Sign Out|○|
|firebase.auth().currentUser.updateProfile()|ユーザー Profile 更新|×|
|firebase.auth().currentUser.linkWithPopup()|プロバイダ認証追加(Poupup)|×|
|firebase.auth().currentUser.linkAndRetrieveDataWithCredential()|パスワード認証追加|×|
|firebase.auth().currentUser.unlink()|プロバイダ認証解除|×|
|firebase.auth().currentUser.updateEmail()|アカウント Email アドレス変更|×|
|firebase.auth().currentUser.updatePassword()|パスワード変更|×|
|firebase.auth().currentUser.delete()|ユーザーアカウント削除|○|


### 7. カレントユーザーに対する更新処理の内容が実際に反映されるまで多少時間がかかる。
　上記のカレントユーザーに対する更新処理(例えば ```firebase.auth().currentUser.updateProfile()```)を実行した後、実際に ```firebase.auth().currentUser``` に更新内容が反映されるまでに多少時間がかかります。
　なので、更新後にデータを再取得してどこかに保持しておくような場合には、多少インターバルを置いて取得する必要があります。

### 8. パスワード認証が無いユーザーアカウントに対しても ```updatePassword()``` は実行可能。この場合、パスワード認証が追加される。
　知らないと思いがけずに Email & Password 認証を追加してしまうことになります。パスワード変更機能を実装する際にはこの点の注意が必要です。

### 9. パスワードリセットを実行すると、そのアカウントに紐づけられていたパスワード認証以外のプロバイダ認証は全て解除される。
　パスワードリセットメールのリンク先からパスワード変更を実施すると、そのアカウントに紐づけられていたパスワード認証以外のプロバイダ認証は全て解除されます。複数プロバイダによる認証機能を実装する際にはこの点の注意が必要です。

### 10. ```updateEmail()``` により、変更元にメールが送信される。Email の Verification が必要な場合は、```sendEmailVerification()``` で変更先にメールを送信する。

　```updateEmail()``` による変更元へのメール送信は自動で行われますが、変更先への Email Verification メールは ```sendEmailVerification()``` を使って自分で送信する必要があります。
　ちなみに、
  (A): ```updateEmail()``` によって**変更元**に送信されるメール
  (B): ```sendEmailVerification()``` によって**変更先**に送信されるメール
とした場合、(A) のメールのリンクを、 (B) のメールのリンクの前にクリックすれば、Email 変更自体がキャンセルされます。

# ソースコード上の要所
　次の 3 つです。ここらへん見れば大体分かると思います。

- src/sagas/auth.ts
  - 認証系処理の実行はほぼここに書いてあります。

- src/containers/ApplicationManager.tsx
  - 認証状態の監視に関する処理はほぼここに書いてあります。
  - ```mountFirebaseObservers()``` 内に ```onAuthStateChanged()``` と ```getRedirectResult()``` があります。

- src/constants/constants.ts
  - アプリケーションの実行に関する定数です。
  - EmailVerification を必須にする場合は、```AUTH_EMAIL_VERIFICATION_REQUIRED``` を ```true``` にして実行して下さい。

```ts:src/constants/constants.ts
...
// 追加利用する認証プロバイダ
// Email & Password および Google は組み込み
// Facebook, Twitter, GitHub の追加選択設定(使わない場合は空配列)
export const AUTH_AVAILABLE_PROVIDERS = ['Facebook', 'Twitter', 'GitHub'];

// Firebase Authentication 更新系処理後に行う認証情報再取得待ち時間(ms)
export const AUTH_RELOAD_TIMEOUT = 2000;

// Email Verify の是非
export const AUTH_EMAIL_VERIFICATION_REQUIRED = false;

// @gmail.com への アカウント Email 変更時 Email Verify メール送信の是非
export const AUTH_SEND_EMAIL_VERIFICATION_AT_EMAIL_UPDATED = false;

// パスワードリセットメール送信時の user not found メッセージ表示の是非
export const AUTH_SHOW_USER_NOT_FOUND_AT_SEND_PASSWORD_RESET_EMAIL = true;
```

# あとがき

Firebase Authentication とは直接関係ありませんが、今回やってみたこと、感じたことをちょっと。

- これまで [redux-form](https://redux-form.com/) 使ってたけど [Formik](https://jaredpalmer.com/formik) に乗り換えることにした
  - [redux](https://redux.js.org/) を使わないケースが増えそう([GraphQL](https://graphql.org/) や [React Context](https://reactjs.org/docs/context.html) とか考えると)。
  - これまで概ね redux 前提で作ってきたのでさほど気にしていなかったのですが、form 使うためだけに redux 導入というのは厳しいなとは思ってました。そもそも依存しなくていいならそれに越したことは無いですし。
  - Formik をここまで使ってきた感じ、特に問題感じません。というかむしろ redux-form より使いやすい気もします(redux-form 使ったことある方であればすぐ使えると思います)。

- [TypeScript](https://www.typescriptlang.org/) で作っておくと refactor 時の心理的ハードルが低い
  - 当たり前のことかも知れませんが、結構大事なことだと思います。そしてそう思えることが大きなメリットだと思います(結果的に好循環が生まれる)。
  - 数年前まで javascript と jQuery で Web アプリをいろいろ作ってましたが、ある程度の規模の Web アプリケーションを動的型付け言語上で自前で DOM 操作しながらつつがなく動かすというのはなかなか至難の業であり、バグフィックスはもちろん機能追加やら仕様変更で修正が必要となると、結構気が重かったことを思い出します(こう思ってる時点ですでに品質低下は始まってるわけで、、、)[^4]。
  - [React](https://reactjs.org/) / [Angular](https://angular.io/) / [Vue](https://vuejs.org/) 等の Component 指向の Framework の導入で DOM 操作の辛さからはかなり解放されましたが、TypeScript の導入で静的型付け言語の持つ**堅さ**を持たせることで、より持続可能な開発に繋がるなと、月並みですが改めて思った次第です。


[^1]: 動作サンプルを公開しようかと思ったのですが、「Sign Up すると私に Email アドレスがばれる」「操作しながら Firebase の Authentication の状態をみないとあまり意味がない」という理由からやめときます。
[^2]: 今回作り直す前のサンプルは、v1.0.0 としてタグを切ってます。
[^3]: いろいろ他で詳しく説明されてるので詳細はそちらを参考にして下さい。ちなみに Twitter の開発者アカウントの取得には承認までちょっと時間がかかります(私は 10 日ほどかかりました) ↩
[^4]: 決して javascript と jQuery で作ることを否定するわけではありません。静的ページにちょっと機能を付けるような場合は今でも jQuery を使います。目的によりけりです。