この記事について
NuxtとFirebaseを使って、これまでいくつかサービス開発をしていますが、認証/認可の実装はどのサービスでも毎回同じようなコードを書いている気がします。
サービスとしてのコア部分ではないですが、センシティブな部分なのでしっかりと調べながら実装すると結構大変ですよね(毎回時間がかかってしまいます)。
ここ最近のサービスはNuxt +Firebaseで開発することが多く、認証 / 認可のコードベースのTipsが貯まってきたので公開したら需要あったりするのかな?
— フジワラユウタ | SlideLive▶️ (@Fujiyama_Yuta) June 7, 2020
サンプルになりそうなプロジェクト見当たらないし、コアな部分ではないのであまり楽しくないし...。
雛形のプロジェクトとして需要あれば公開します👍
自分だけではなく、いろんな人が同じような課題感を感じているのではないかと思い、これから何かサービスを開発する人のためのNuxt+Firebaseのスターター用のプロジェクトを作成し、テンプレート化して使いまわせるようにリポジトリを公開しました。今回はそのリポジトリの概要を解説します。
※ 自分の考えるベストプラクティスなので、もっとこうした方がいいよね、これヤバそう...などのご意見やマサカリ、プルリク、フィードバック歓迎しております🙏
対象
- NuxtとFirebaseで何か開発しようと考えている人
- 認証認可のあるサービスをゼロから作ろうとしている人
-
create-nuxt-app
ではない、サンプルのプロジェクトを動かしてみたい人
※ Firebaseプロジェクトをゼロから作成する部分から解説するので、必要ない人は飛ばしてください🙏
Githubのリポジトリ
下記のリポジトリをクローンしてください。
nuxt-firebase-project
https://github.com/FujiyamaYuta/nuxt-firebase-project.git
技術
- Nuxt.js
- Buefy + Bulma
- Firebase🔥
- Hosting
- Cloud Firestore
- Cloud Storage
- Authentication
環境
% firebase --version
8.4.0
% npm -v
6.14.4
Firebaseの設定
今回はFirebaseの以下のサービスを使います(ある一定の転送量までは全て無料で使うことができます🙏)。それぞれがどのようなサービスかは、別で調べてみてください。
- Hosting
- Cloud Firestore
- Cloud Storage
- Authentication
はじめにFirebaseの設定をします。以下のリンクからプロジェクトを作成してください。
Firebase - プロジェクトの追加
https://console.firebase.google.com/u/0/?hl=ja
① プロジェクトを作る
② プロジェクト名を決める
③ プロジェクトIDとウェブAPIキーを確認
プロジェクト作成後の 「プロジェクトの管理」 の右の設定アイコンを押すとSettingsのページに遷移するのでそこから確認することができます。プロジェクトIDとウェブAPIキーはNuxt側に設定する必要があるのでメモしておいてください。
④ AuthticationのGoogleの認証を許可する
Authticationのバーをクリックして Sign-in method のタブをクリックして、Authticationで認証を許可するサービスを選択します。今回はGoogleの認証を使うので、以下の手順で許可してください。
Authticationでは、認証できるホストを管理することが可能です。カスタムドメインで認証を許可したい場合などは「ドメインを追加」から、追加することができます。
GithubとTwitterで同じメールアドレスを使用しているユーザーの認証を許可したい場合、「1つのメールアドレスにつき複数のアカウント」に変更することで、同じメールアドレスでも複数のプロバイダからログインすることが可能になります。デフォルトは「1つのメールアドレスにつき1つのアカウント」なので、同じメールアドレスで認証は失敗します。
⑤ Cloud Firestoreをプロビジョニング
Databaseのバーをクリックして、Firestoreを方を選択してください。(RealTimeDatabaseもあるので注意)
リージョンを選択できるので asia-northeast1(東京) を選択することをお勧めします。海外のリージョンを選択すると、物理的な距離が遠いのでレイテンシが発生するため。
⑥ Cloud Storageをプロビジョニング
こちらもリージョンを選択できるので asia-northeast1(東京) を選択することをお勧めします。
Firebase側の設定は以上になります。
Nuxtの設定
Githubのリポジトリからクローンした src/plugins/firebase.js
のファイルにFirebaseプロジェクトで設定されたプロジェクトIDとウェブAPIキーを追加します。
import firebase from 'firebase';
// ** Firebaseプロジェクトの設定を記す
if (!firebase.apps.length) {
firebase.initializeApp({
apiKey: '{ウェブAPIキー}',
authDomain: '{プロジェクトID}.firebaseapp.com',
databaseURL: 'https://{プロジェクトID}.firebaseio.com',
projectId: '{プロジェクトID}',
storageBucket: '{プロジェクトID}.appspot.com',
messagingSenderId: '1234567890' // ** cloudmessagingを使う場合は設定
})
}
export const firestore = firebase.firestore();
export const storage = firebase.storage();
Nuxt側の設定は以上になります。
ローカルホストで動作確認
// モジュールをインストール&ビルド
% npm install
% npm run build
% npm run dev
localhost:3000
でブラウザからアプリケーションが立ち上がるのを確認します。
とりあえず、ローカルでは動いています🙌
Cloud functionsにもモジュールをインストールしておきます。
% cd functions
% npm install
デプロイ
ビルドしたモジュールをデプロイします。
※firebase CLIでログインしていない方は firebase login
を実行してください。
// 先ほど作ったFirebaseプロジェクトが存在するか確認
% firebase projects:list
ちゃんとありますね👍
先ほど作ったFirebaseプロジェクトにデプロイします。
% firebase use {プロジェクトID}
Now using project {プロジェクトID}
% firebase deploy
※ 下記のエラーは発生した場合は functions
のディレクトリで npm install
のコマンドを実行してください。
Error: Error parsing triggers: Cannot find module 'firebase-functions'
Require stack:
これで、URLからアクセスができたはずです🙌 おつかれさまでした!Google認証をすると、認証結果の情報がFirestoreに登録されています。
※ Github、Twitter、Facebookについても、Firebaseと連携すれば認証が使えるようになります。
動くものがデプロイでき、動作確認ができたのでNuxtの説明をします。
プロジェクトの紹介
クローンしたプロジェクトは以下のようになっています。 .eslintrc.js
や nuxt.config.js
は自分標準のファイルを入れいているので、個人のお好みでカスタマイズしてください。
src
配下に開発者用のソースが格納されています。
.
├── README.md
├── database.rules.json
├── firebase.json
├── firestore.indexes.json
├── firestore.rules
├── functions
├── jsconfig.json
├── node_modules
├── nuxt.config.js
├── package-lock.json
├── package.json
├── public
├── src
│ ├── assets
│ ├── components
│ ├── layouts
│ ├── middleware
│ ├── pages
│ ├── plugins
│ ├── static
│ └── store
└── storage.rules
Authentication
① 認証状態の永続性をセット
Firebaseでは認証状態をどの程度維持するかを指定することが可能です。
https://firebase.google.com/docs/auth/web/auth-state-persistence?hl=ja
列挙型 | 値 | 説明 |
---|---|---|
firebase.auth.Auth.Persistence.LOCAL | 'local' | ブラウザ ウィンドウを閉じたり React Native でアクティビティが破棄されたりした場合でも、状態が維持されることを示します。この状態をクリアするには、明示的なログアウトが必要です。Firebase Auth のウェブ セッションは単一のホストを生成元とするため、単一のドメインでのみ永続化されることに注意してください。 |
firebase.auth.Auth.Persistence.SESSION | 'session' | 現在のセッションまたはタブでのみ状態が維持され、ユーザーが認証を受けたタブやウィンドウを閉じるとクリアされることを示します。ウェブ アプリケーションのみに適用されます。 |
firebase.auth.Auth.Persistence.NONE | 'none' | 状態はメモリにのみ保存され、ウィンドウまたはアクティビティが更新されるとクリアされることを示します。 |
イメージとしては永続性をローカルストレージ/ セッションストレージ / メモリのどこに保持するか?という認識でよいを思います(Webの場合はデフォルトlocal
)。GoogleAuthProvider()
の関数を呼び出す前に、永続性の明示的に呼び出します。
firebase.auth().setPersistence(firebase.auth.Auth.Persistence.SESSION)
.then(function() {
return firebase.auth.GithubAuthProvider()
})
.catch(function(error) {
// Handle Errors here.
let errorCode = error.code;
let errorMessage = error.message;
});
Firebaseの関数はほとんどか非同期処理になっているので、①をした後に②をして③をして④をする...のような同期処理にしたい場合はPromiseやasync/awaitを使う必要がありそうですね。コールバックの後に続けて書いてもいいですが、ネストが深くなりコードが読みにくくなるので、あまりオススメではありません。
いい感じに関数化して、順番に呼び出すような書き方をしてみました。
<template>
<b-modal :active.sync="isLoginModalActive" :width="420" scroll="keep">
<button @click="google">
<span class="icon">
<i class="fab fa-google"></i>
<span>Google</span>
</span>
</button>
</b-modal>
</template>
<script>
import "bulma/css/bulma.min.css";
import "@fortawesome/fontawesome-free/css/all.min.css";
import "bulma-social/bin/bulma-social.min.css";
import firebase from "firebase";
import { firestore, storage } from "~/plugins/firebase.js";
export default {
data() {
return {
isLoginModalActive: true
};
},
methods: {
// ** Google認証を行うときに呼び出される関数
google() {
// ** ② Google認証
const auth = () => {
return new Promise((resolve, reject) => {
const authUI = new firebase.auth.GoogleAuthProvider();
console.log("auth");
// This gives you a the Google OAuth 1.0 Access Token and Secret.
firebase
.auth()
.signInWithPopup(authUI)
.then(result => {
resolve(result);
})
.catch(error => {
// Handle Errors here.
const errorCode = error.code;
const errorMessage = error.message;
const email = error.email;
const credential = error.credential;
reject(error);
});
});
};
// ** ③ 認証後のユーザー情報を取得してオブジェクト化
const getAccountData = result => {
return new Promise((resolve, reject) => {
let userObject = {};
let user = result.user;
userObject.token = result.credential.accessToken;
userObject.refreshToken = user.refreshToken;
userObject.uid = user.uid;
userObject.displayName = user.displayName;
userObject.photoURL = user.photoURL;
userObject.uid = user.uid;
userObject.email = user.email;
userObject.isNewUser = result.additionalUserInfo.isNewUser;
userObject.providerId = result.additionalUserInfo.providerId;
resolve(userObject);
});
};
// ** 同期的に順番に処理を実行する
Promise.resolve()
.then(this.setPersistence)
.then(auth)
.then(getAccountData)
.then(userObject => this.createPhotoURL(userObject))
.then(userObject => this.setPublicUserData(userObject))
.then(userObject => this.setPrivateUserData(userObject))
.then(userObject => this.setLocalUserData(userObject))
.catch(error => this.onRejectted(error));
},
// ** ① 認証状態を明示的にセットする
setPersistence() {
return new Promise((resolve, reject) => {
firebase
.auth()
.setPersistence(firebase.auth.Auth.Persistence.LOCAL)
.then(result => {
resolve();
});
});
},
// ** ④ Googleから取得したアイコンのURLをFirestorageに登録して、そのURLをFirestoreに登録する準備
createPhotoURL(userObject) {
return new Promise((resolve, reject) => {
// ** TODO - 初めてじゃない場合は処理しない対応が必要
let url = userObject.photoURL;
let xhr = new XMLHttpRequest();
xhr.responseType = "blob";
xhr.onload = function(event) {
let blob = xhr.response;
let storageRef = storage.ref();
let mountainsRef = storageRef.child(
`user/${userObject.uid}/image.jpg`
);
let uploadTask = mountainsRef.put(blob);
uploadTask.then(snapshot => {
uploadTask.snapshot.ref.getDownloadURL().then(downloadURL => {
console.log(downloadURL);
// ** firestorageに登録したURLを登録するオブジェクトに代入
userObject.photoURL = downloadURL;
resolve(userObject);
});
});
};
xhr.open("GET", url);
xhr.onerror = function(e) {
// クロスドメインでひっかかる場合はstorageに登録しない
console.log("ooooooops!!cros!!");
resolve(userObject);
};
xhr.send();
});
},
// ** ⑤ 公開可能なユーザー情報をFirestoreに登録
setPublicUserData(userObject) {
return new Promise((resolve, reject) => {
let publicUser = firestore.collection("users").doc(userObject.uid);
publicUser
.set(this.createPublicObj(userObject), { merge: true })
.then(result => {
resolve(userObject);
});
});
},
createPublicObj(obj) {
let publicObj = {};
publicObj.uid = obj.uid;
publicObj.providerId = obj.providerId;
publicObj.isNewUser = obj.isNewUser;
if (obj.isNewUser) {
publicObj.photoURL = obj.photoURL;
publicObj.displayName = obj.displayName;
}
if (
(obj.providerId.indexOf("twitter") != -1 ||
obj.providerId.indexOf("github") != -1) &&
obj.isNewUser
) {
// ** プロフィールが存在して、isNewUserがtrueじゃないときにオブジェクトに代入する
publicObj.profile = obj.profile;
publicObj.screenName = obj.screenName;
}
return publicObj;
},
// ** ⑥ 非公開のユーザー情報をFirestoreに登録
setPrivateUserData(userObject) {
return new Promise((resolve, reject) => {
let privateUsers = firestore
.collection("privateUsers")
.doc(userObject.uid);
privateUsers
.set(this.createPrivateObj(userObject), { merge: true })
.then(result => {
resolve(userObject);
});
});
},
createPrivateObj(obj) {
let privateObj = {};
privateObj.uid = obj.uid;
privateObj.providerId = obj.providerId;
privateObj.isNewUser = obj.isNewUser;
privateObj.email = obj.email;
privateObj.token = obj.token;
privateObj.refreshToken = obj.refreshToken;
return privateObj;
},
// ** ⑦ ローカルストレージに保持するユーザー情報を設定
setLocalUserData(userObject) {
return new Promise((resolve, reject) => {
let user = firestore.collection("users").doc(userObject.uid);
user
.get()
.then(doc => {
if (doc.exists) {
localStorage.setItem("photoURL", doc.data().photoURL);
localStorage.setItem("uid", userObject.uid);
localStorage.setItem("token", userObject.token);
localStorage.setItem("displayName", doc.data().displayName);
this.$buefy.toast.open({
duration: 5000,
message: `ログインに成功しました`,
position: "is-bottom",
type: "is-success"
});
this.isLoginModalActive = false;
location.reload();
resolve(userObject);
}
})
.catch(error => {
console.log("Error getting document:", error);
});
});
},
// ** エラー処理
onRejectted(error) {
this.$buefy.toast.open({
duration: 5000,
message: `ログインに失敗しました。`,
position: "is-bottom",
type: "is-danger"
});
this.isLoginModalActive = false;
console.log("onRejectted", error);
}
}
};
</script>
上の処理を実行することで、FirestoreとFirestorageにデータが登録されます🙌 コードは一部になりますが、LoginModal.vue
というファイルをチェックしてみてください。
認証済みユーザーかどうかの確認
submitしたユーザーが本当に認証したユーザーかどうかを、データ登録前に確認したいことがあると思います。そのような場合には、 firebase.auth().onAuthStateChanged()
の関数を呼び出すことで、確認することができます。コールバックに認証済みのユーザー情報が付加されています。
isCommonLoginUser() {
return new Promise((resolve, reject) => {
firebase.auth().onAuthStateChanged((user) => {
if (user) {
// ** ログイン済のユーザー
console.log('ok!!Login User!!')
var name, email, photoUrl, uid, emailVerified
name = user.displayName
email = user.email
photoUrl = user.photoURL
emailVerified = user.emailVerified
uid = user.uid
resolve(user)
} else {
// ** ログインしていないユーザーもしくは認証が切れている
resolve(false)
}
})
})
},
async submit() {
try {
let result = await this.isCommonLoginUser()
// ** ↓↓ resultはuser or false ↓↓
} catch (error) {
console.log(error)
}
},
セキュリティルールでも制御はしていますが、念のためチェックするとより安全かもしれません。
セキュリティルール
Firebaseの特徴はクライアントサイドから、データを読み書きするため、悪意あるユーザーが不正をしてくる恐れがあるためセキュリティルールはしっかりと書きましょう。
今回はルートにあるfirestore.rules
とstorage.rules
というファイルにあらかじめセキュリティルールを追加しているため firebase deploy
のコマンドを実行すると、自動的にセキュリティルールが反映されます。
【Cloud Firestore セキュリティ ルールの条件の記述】
https://firebase.google.com/docs/firestore/security/rules-conditions?hl=ja
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{uid}/{allPaths=**} {
allow read;
allow create: if request.auth != null;
allow update,delete: if request.auth.uid == uid;
}
match /privateUsers/{uid}/{allPaths=**} {
allow create: if request.auth != null;
allow read,update,delete: if request.auth.uid == uid;
}
}
}
service firebase.storage {
match /b/{bucket}/o {
match /user/{uid}/{allPaths=**} {
allow read;
allow create: if request.auth != null;
allow delete,update: if request.auth.uid == uid;
}
}
}
Firestoreにデータが登録されているか確認
最後に本当にユーザー情報以外のデータがFirestoreに登録されるかどうか確認してみましょう。
users/{uid}/rooms/{roomId}/**
にデータが登録されているはずです🙌
終わりに
Nuxt+Firebaseでスターター用のプロジェクトを作ってみました。何かこれからサービスを作ろうと考えるときに、参考にするリポジトリのひとつになればと思います🙏
もっとこうした方がいいよね、これヤバそう...などのご意見やマサカリ、プルリク、フィードバックがありましたら、ぜひお願いいたします。
nuxt-firebase-project
https://github.com/FujiyamaYuta/nuxt-firebase-project.git