Firebaseをバックエンドにした認証まわりのテンプレートを作ってみました。


# 追記 2020.3.19
リンク先のリポジトリのコードをドメイン駆動設計を意識して作り直したので、この記事の解説とは齟齬ができおります。

- Vuexがfirebaseに依存するのをやめた。
- VuexをUseCaseレイヤーとみなしてRepositoryレイヤーのみを操作するようにした。
- Repositoryレイヤーにfirebaseの処理を押し込んだ。
- 依存線逆転はあえてしていない。
　（この規模だと定義ジャンプで実装にたどり着けないほうがの方が自分にとってデメリットが大きいから。）

あたりをやってます。
ストックしていただた方もいるため、一応この記事も残しておきます。

# この記事の概要


- Firebaseをバックエンドにした認証周りをテンプレート化してみました。
- 便利なFirebaseですが、実践的に使おうと思うといろいろ考えることが多かった。
- 実装方法の１方法として、考えたこととかをまとめておきます。



# 環境

- Firebase
- Typescript
- Vue
- Vuex    ( vuex-module-decoratorsでクラス化しています。)




# ここにあげてあります

よければみてやってください。

[GitHub](https://github.com/mutsuyuki/vue-firebase-auth-template)

[Demo](https://mutsuyuki.github.io/vue-firebase-auth-template/)



#### 画面イメージ
ワイヤーフレームぽいシンプルな見た目にしています。
![introduction.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139559/808dfb4c-ffd6-5163-325d-d4ab61147308.png)


機能は

- 新規登録
- サインイン
- サインアウト
- ユーザー名変更
- パスワード変更
- 退会

です。

プロトタイピングの土台として使うことを想定しています。
今後SNS認証も入れて汎用性を高めたいです。


# 前提

firebaseの解説は良記事がたくさんあるので、ここではfirebase自体の解説はしません。
実践的にfirebase認証使う際に気が付いたことなどをピックアップして書いてみます。

Firebase自体を含めて網羅的に解説してある記事だと、
[Vue.js + Firebase を使って爆速でユーザ認証を実装する](https://qiita.com/sin_tanaka/items/ea149a33bd9e4b388241)
が素晴らしいです。



# Firebase認証を使うにあたり、考える必要があったこと。
やってみて、次のようなことを考慮する必要がありそうだなと思いました。

- Firebaseと心中するのは怖い。別の認証を導入することになった際のインパクトを抑えたい。
- 認証情報（ユーザー情報）はVuexで管理したい。しかし、firebaseのUserをstateととして持つのはのは厳しい。依存の面もそうだし、そもそもfirebaseのUser情報は状態が勝手に変わりうるので、Mutation外で更新したってVuexがめっちゃ怒ってくる。
- エラー系はかなりfirebaseが細かく返してくれるんだけど、それをそのまま使いたくはない。どうにかして自前のエラー体系とマージしたい。（今回は雑、というかほぼやってませんが。。）



具体的には、認証に必要なinterfaceの定義と、それを使用するStore(Vuex）を１つ作りました。
その中にfirebaseを全部押し込みます。

###### インターフェース定義

```ts:UserStoreTypes.ts
export interface User {
    id: string;
    email: string;
    name: string;
    photoURL?: string;
    providers:string[];
    emailVerified:boolean;
}

export interface SignUpParam {
    name: string;
    email: string;
    password: string,
}

export interface SignInParam {
    email: string;
    password: string
}

export interface EditUserParam {
    name: string;
    email: string;
    photoURL?: string;
}


export interface SendPasswordResetParam {
    email: string;
}

export interface ActionResult {
    isError: boolean;
    errorCode: string;
    errorMessage: string;
}



```

##### ユーザー情報を管理するStore

```ts:UserStore.ts

class UserStore extends VuexModule {

    // states
    private _user: User = UserStore.makeEmptyUser();

    // getters
    get isSignedIn() {
        // 省略
    }

    get user(): User {
       // 省略
    }

    @Mutation
    private setUser(user: User) {
       // 省略
    }

    @Action
    public async init() {
        // 省略
    }

    @Action
    public async signUp(param: SignUpParam) {
        // 省略
    }

    @Action
    public signIn(param: SignInParam) {
        // 省略
    }

    @Action
    public editUser(param: EditUserParam) {
        // 省略
    }

    @Action
    public signOut() {
        // 省略
    }

    @Action
    public sendPasswordReset(param: SendPasswordResetParam) {
        // 省略
    }

    @Action
    public deleteUser() {
        // 省略
    }

    // ---- ヘルパーメソッド群 ------------------
    private static makeEmptyUser(): User {
        return {
            id: "",
            name: "",
            email: "",
            providers: [],
            emailVerified: false
        }
    }

    private static makeUserByFirebaseUser(firebaseUser: firebase.User | null): User {
        if (firebaseUser) {
            return {
                id: firebaseUser.uid,
                email: firebaseUser.email || "",
                name: firebaseUser.displayName || "",
                photoURL: firebaseUser.photoURL || "",
                providers: firebaseUser.providerData.map(v => v ? v.providerId : ""),
                emailVerified: firebaseUser.emailVerified,
            };
        } else {
            return UserStore.makeEmptyUser();
        }
    }


    private static makeSuccessResult(): ActionResult {
        return {
            isError: false,
            errorCode: "",
            errorMessage: ""
        }
    }

    private static makeFailedResult(error: { code: string, message: string }): ActionResult {
        return {
            isError: true,
            errorCode: error.code,
            errorMessage: error.message
        }
    }

    private static makeFailedResultByCode(errorCode: string): ActionResult {
    }

    private static makeFailedResultByFirebaseError(firebaseError: firebase.FirebaseError): ActionResult {
    }

}
```
ポイントとしては、

- 自前User型を作ったこと。こちらをstateに持つ（firebaseのUser型を変換して使うようにする）
- 各Actionの内部ではfirebaseのAPI実行をしますが、一般的な認証メソッド風にして内部を隠蔽する。
- 各Actionの入力値はそれぞれインターフェース定義する。
- 各Actionの返却値は同じインターフェースにする。（好みですが、呼ぶ側の処理が同じ流れになって好き）
- firebaseの情報を自前型に変換するためのヘルパーメソッドもstatic関数で書いちゃう。（でかくなったら分ける）

これくらいやると、このアプリとしてどんな認証が必要なのか、を定義することができ、内部ではfirebaseなどを使って頑張る、という風に分離できるかなと思います。



# 機能別の実装ピックアップ

## ユーザー新規登録

上段が自分で実装する部分、下段がfirebaseがやってくれる部分です。

![signup.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139559/24ccbb0f-fffb-02af-ae66-3faa84cd4caa.png)



### Vuex側の実装

```ts:UserStore.ts(抜粋)
    @Action
    public async signUp(param: SignUpParam) {
        return new Promise<ActionResult>(async resolve => {
            // ユーザー作成
            const resultCreateUser = await firebase
                .auth()
                .createUserWithEmailAndPassword(param.email, param.password)
                .then(value => {
                    if (!value.user) {
                        return UserStore.makeFailedResultByCode("999");  // ありえないはず
                    }

                    const actionCodeSetting: firebase.auth.ActionCodeSettings = {
                        url: window.location.origin + window.location.pathname
                    };
                    value.user.sendEmailVerification(actionCodeSetting);

                    return UserStore.makeSuccessResult();
                })
                .catch(error => {
                    return UserStore.makeFailedResultByFirebaseError(error);
                });

            // ユーザー作成エラーのチェック
            if (resultCreateUser.isError) {
                resolve(resultCreateUser);
                return;
            }

            // ユーザー取得
            const firebaseUser = firebase.auth().currentUser;
            if (!firebaseUser) {
                return UserStore.makeFailedResultByCode("999");  // ありえないはず
            }

            // ユーザー名の登録
            const resultEditUser = await this.editUser({
                email: firebaseUser.email || "",
                name: param.name,
                photoURL: ""
            });

            // ユーザー名登録のエラーチェック
            if (resultEditUser.isError) {
                resolve(resultEditUser);
                return;
            }

            // ログアウトしておく（初回は手動ログインがいいな）
            const resultSignOut = await this.signOut();

            // ログアウトのエラーチェック
            if (resultSignOut.isError) {
                resolve(resultSignOut);
                return;
            }

            resolve(UserStore.makeSuccessResult());
        });
    }
```
これが一番大変です。

ポイントは
- ユーザー登録時にユーザー名も登録させたい。
- メールの認証リンクからウェブアプリ側に戻ってきたときに、手動ログインさせたい（個人の好み）

firebaseでユーザーを作る際、メールアドレスとパスワードだけで登録するなら簡単なのですが、自分のアプリに合わせて挙動を細かく制御するとたくさんAPI呼ばなくちゃいけなくなります。
awaitが使えるとこうゆうときはAPI順番に読んでいくのがきれいにかけて気持ちがいいですね。


### テンプレート側（呼ぶ側）の実装

```ts:SignUp.vue(抜粋)
// ・・・（略）

        <div v-if="isSubmitted" class="confirm-message">
            確認メールを送信しました。<br/>
            ご確認ください。
        </div>

// ・・・（略）

        const result = await UserStore.signUp({
            name: this.userName,
            email: this.email,
            password: this.password
        });
        if (result.isError) {
            this.errorMessage = result.errorMessage;
            return;
        }
            
        this.isSubmitted = true;

// ・・・（略）
```
呼ぶほうは簡単です。


# サインイン

実にログインですね。
![signin.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139559/94930899-9cdb-eba7-843b-e9d37639f397.png)

### Vuex側の実装

```ts:UserStore.ts(抜粋)
    @Action
    public signIn(param: SignInParam) {
        return new Promise<ActionResult>(resolve => {
            firebase
                .auth()
                .signInWithEmailAndPassword(param.email, param.password)
                .then(value => {
                    if (!value.user) {
                        resolve(UserStore.makeFailedResultByCode("999"));  // ありえないはず
                        return;
                    }

                    if (!value.user.emailVerified) {
                        resolve(UserStore.makeFailedResultByCode("001"));
                        return;
                    }

                    this.setUser(UserStore.makeUserByFirebaseUser(value.user));
                    resolve(UserStore.makeSuccessResult());
                })
                .catch(error => {
                    resolve(UserStore.makeFailedResultByFirebaseError(error));
                });
        });
    }

```

１点だけ注意です。
メールでの認証が済んでなくても、firebase上ログインできてしまうので、これは別途判定して独自エラーを返すようにした方がよさそうです。


### テンプレート側(呼ぶ側)の実装
```ts:Front.vue(抜粋)
            const result = await UserStore.signIn({
                email: this.email,
                password: this.password
            });

            if (result.isError) {
                this.errorMessage = result.errorMessage;
                return;
            }

            this.$router.push({name: "home"});
```
成功したらホームページに飛ぶだけです。
新規登録とほぼ同じ流れ。


# ユーザー情報の変更
変更可能はユーザー名だけです。

![modify_user.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139559/9c4fb2cf-f1e9-4e8e-4030-c97c2e38135c.png)


```ts:UserStore.ts(抜粋)
    @Action
    public editUser(param: EditUserParam) {
        return new Promise<ActionResult>(resolve => {
            const firebaseUser = firebase.auth().currentUser;
            if (!firebaseUser) {
                resolve(UserStore.makeFailedResultByCode("002"));
                return;
            }

            firebaseUser
                .updateProfile({displayName: param.name})
                .then(value => {
                    const editedUser = UserStore.makeUserByFirebaseUser(firebaseUser);
                    this.setUser(editedUser);
                    resolve(UserStore.makeSuccessResult());
                })
                .catch(error => {
                    resolve(UserStore.makeFailedResultByFirebaseError(error));
                });
        });
    }
```

最初にログインチェックを一応しています。
あとは、firebaseのAPIでユーザー情報を変更したあと、ヘルパー関数(makeUserByFirebaseUser)でfirebaseのUser情報を自前User情報に変換して、stateを更新しています。


### テンプレート側(呼ぶ側)の実装
```ts:EditUser.vue(抜粋)
            const result = await UserStore.editUser(editedUser);

            if (result.isError) {
                this.errorMessage = result.errorMessage;
                return;
            }

            alert("更新しました")
```

特にありません（呼ぶ側はほとんど全部同じですね）


# サインアウト

実にログアウトです。特に注意点もありません・
![signout.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139559/774ca893-37f7-9ad9-9076-7baaeedc3e6c.png)


### Vuex側の実装

```ts:UserStore.ts(抜粋)
    @Action
    public signOut() {
        return new Promise<ActionResult>(resolve => {
            firebase
                .auth()
                .signOut()
                .then(_ => {
                    this.setUser(UserStore.makeEmptyUser());
                    resolve(UserStore.makeSuccessResult());
                })
                .catch(error => {
                    resolve(UserStore.makeFailedResultByFirebaseError(error));
                })
        });
    }
```
ほぼfirebaseまんまです。
成功したら、空のユーザー情報をstateに突っ込んでおきます。

### テンプレート側(呼ぶ側)の実装
```ts:SignOut.vue(抜粋)
            const result = await UserStore.signOut();

            if (result.isError) {
                this.errorMessage = result.errorMessage;
                setTimeout(() => this.$router.replace({name: "home"}), 3000);
            } else {
                this.$router.replace({name: "front"});
            }

```

サインアウトにエラーがあるのかわからないのですが、一応考慮しています。
失敗したときはエラーを一定時間表示してからユーザーホームのページ（サインイン直後のページ）に戻ります。
こうゆうケースに遭遇したことがないので、正しい挙動が何なのかわかりません。詳しい人知見をください。




# 退会とパスワードリセット

### 退会
![quit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139559/85a12e80-cb03-fe22-1dd2-7bb090bd8a9e.png)

処理的にはユーザー変更とほぼ同じだったりしますので省略。


### パスワードリセット
![reset_password.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139559/43b91b3c-456e-22f9-8ce7-4a9fcc89ee04.png)

複雑そうに見えますが、下の段はfirebaseがやってくれるので、ほぼfirebaseのAPI叩いてるだけなので省略です。






#終わりに

長くなりすぎました。。


今回の内容は実装方法の１案ではありますが、こうゆう風にまとめておくとさっと使いまわせそうでよいかなと思いました。
認証周りの実装が得意というわけでもないので、忌憚のないツッコミとかいただけたらとてもうれしいです。