【初心者向け】【認証(Authentication)編】Vuetify3(Vue.js3)とFirebaseを使って掲示板アプリをつくってみました。

※ 当サイトではアフィリエイト広告を利用しています。リンクは広告リンクも含みます。

この記事は約36分で読めます。
広告

前回の続きで認証機能を追加します。firebaseの認証機能は、アプリにログイン機能を簡単に追加できるサービスです。メールアドレスとパスワードや、GoogleやFacebookなどのSNSアカウントを使ってユーザーを認証できます。1番単純なメールアドレスとパスワードでのログインを実装します。
認証を通ったユーザーはデータベースにアクセスできます。また、そのユーザーが現在ログインしているか判断できる関数が用意されているので、それを使って表示を切り替えることができます。
メールアドレスは実在しなくても大丈夫なので、まずはこれで色々実験してから本格的な認証を使っていけばいいと思います。ここで紹介する方法はセキュリティ対策は考慮していません。firebaseの認証機能の理解が目的なので別途セキュリティ対策は調べてください。公式のFirebase の API キーの使用と管理について学ぶも参考になります。


Firestore+Vuetifyで作った掲示板(認証機能付き)
メールアドレス、パスワードを登録した人だけが、読み書きできる掲示板です。人が書いたものも削除できます。メールアドレスはabc@def.jpなどメールアドレスの形をしていれば、実際に存在してなくても登録できます。適当なメールアドレスで登録して読み書き削除してログアウト。登録したメールアドレス、パスワードで再ログインして動作確認してみてください。絶対に本物のメールアドレスは使用しないでください。(メールアドレスが漏洩しても保証できません。)

広告

Vuetify3のコンポーネント追加

メールアドレス、パスワードを入力するところはVuetify3公式のpassword inputを改造しました。
また注意事項をダイアログで表示するようにDialogsを使いました。こちらはボタンを押すとダイアログが表示されますが、表示/非表示は以下の”dialog”がtrueで表示、falseで非表示なのでボタン削除して直接この”dialog”に値を入れてコントロールするようにしました。

<v-dialog
v-model=”dialog”

そのほか前回のはVuetify2で作ったものだったのでワーニングが出てました。そこを修正しました。

認証機能追加

認証機能関連コード

前回のアプリがローカルで動く状態(Hostingはやらなくてもいい)まで作っておきます。
ウェブサイトで Firebase Authentication を使ってみるを見ながらやっていきます。
以下赤い部分を追加します。authDomain:はプロジェクトの概要→プロジェクトの設定で下のほうにあります。ほかに3つ存在するのですが、storageBucket,messagingSenderId,appIdこれはなくても動きました。また、ここに書いた値はユーザーから検証ツールで見えてしまうので、あまり余分なことは書かないようにしました。authDomeinは、Hostingしたらドメインの一部になってます。この辺は自己責任でお願いいたします。

import { getAuth } from "firebase/auth";
略
      const firebaseConfig = {
          apiKey: "***",
          projectId: "***",
          authDomain: "***",
      };
略
// Initialize Firebase Authentication and get a reference to the service
const auth = getAuth(app);

firebaseに用意されている以下の4つの関数(スタートガイドに出ている関数)を以下の場面で使用します。

登録ボタンを押したら呼ぶ → createUserWithEmailAndPassword(新しいユーザーを登録する)
ログインボタンを押したら呼ぶ → signInWithEmailAndPassword(既存のユーザーをログインさせる)
最初に呼び出しログイン状態を判定する→onAuthStateChanged(認証状態オブザーバーを設定してユーザーデータを取得する)

Firebase でユーザーを管理するは今回、使用しませんでした。
JavaScript でパスワード ベースのアカウントを使用して Firebase 認証を行うにあるsignOutを使います。

ログアウトを押したら呼ぶ→signOut(ユーザーのログアウトを行う)
完成したコードは下に貼っておきます。

firebasebase設定

左のAuthentication→始める→メール / パスワード→sign-in-methodsタブ→メール / パスワードを有効にして保存
またCloudFirestoreのルールを以下のようにログインしたユーザーにしかデータベースが読み書きできないように変更します。データをセキュリティで保護するを参照してください。

allow read, write: if request.auth != null;

下の画面は誰も読み書きできない設定です。

これで動かすとユーザー認証付きの掲示板が動作します。
登録したユーザーは、この画面で確認できますし削除もできます。

プログラムを実行
yarn dev

コード

デフォルトでできるHelloWorld.vueコンポーネントを置き換えるだけにしてます。

<template>
  <v-app>
    <v-main>
      <!-- <HelloWorld /> -->
      <FrontEnd/>
    </v-main>
  </v-app>
</template>

<script setup>
  // import HelloWorld from '@/components/HelloWorld.vue'
  import FrontEnd from '@/components/FrontEnd.vue'
</script>

apiKey:projectId: authDomain:は”それぞれ人によって違う“,ので変更してください。

<template>
    <v-app id="inspire"> 
      <v-app-bar
        class="mt-4"
        max-height="50"
        color="deep-purple"
        dark
        absolute
      >
        <v-app-bar-nav-icon @click="drawer = true"></v-app-bar-nav-icon>
          <v-toolbar-title>Firestore+Vuetifyで作った掲示板(認証機能付き)</v-toolbar-title>
      </v-app-bar>
  
      <v-navigation-drawer
        v-model="drawer"
        absolute
        temporary
      >       
        <v-list >
          <v-list-item
            v-for="(item, i) in items"
            :key="i"
            :value="item"
            color="primary"
          >
            <template v-slot:prepend>
              <v-icon :icon="item.icon"></v-icon>
            </template>
            <v-list-item-title><a :href="item.url" target="_blank">{{item.text}}</a></v-list-item-title>
          </v-list-item>
        </v-list>
      </v-navigation-drawer>

      <v-main class="grey lighten-2">
        <v-container>
          <div v-if="logined" style="text-align:center">
            <h3 >ログインUserのemail:{{this.loginUser}}</h3>
          </div>
          <v-row>
            <v-col class="mt-2" cols="12">
                <v-sheet v-if="!logined" height="180" color="blue-grey-lighten-4" class="mt-2 mx-auto" max-width="400">
                    <v-text-field v-if="!logined"
                        v-model="email"
                        persistent-hint                        
                        :type="show2 ? 'text' : 'email例abc@def.jp'"
                        name="input-10-2"
                        label="E-mail"
                        hint="E-mailアドレスを入力してください。abc@def.jpとpasswordで誰でもログインできます。"
                        class="input-group--focused"
                        @click:append="show2 = !show2"
                    ></v-text-field>
                    <v-text-field v-if="!logined"
                        v-model="password"
                        persistent-hint                        
                        :append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'"
                        :rules="[rules.required, rules.min]"
                        :type="show1 ? 'text' : 'password'"
                        name="input-10-1"
                        label="password"
                        hint="8文字以上入力してください。abc@def.jpとpasswordで誰でもログインできます。"
                        counter
                        @click:append="show1 = !show1"
                    ></v-text-field>
                </v-sheet>
                <div class="text-center">
                    <v-btn v-if="!logined" color="success" dark v-on:click="createAuth" class="ma-5">登録</v-btn>
                    <v-btn v-if="!logined" color="success" dark v-on:click="signinAuth">ログイン</v-btn>
                    <v-btn v-if="logined" color="success" dark v-on:click="signoutAuth" class="ma-5">ログアウト</v-btn>
                </div>
            </v-col>
            <v-col class="mt-2" cols="12">
                <div v-if="logined">
                    <v-sheet height="230" color="orange lighten-2" class="mt-2 mx-auto" max-width="400">
                        <v-text-field
                            v-model="new_article_heading"
                            label="名前を入力してください"
                            filled>
                        </v-text-field>
                        <v-text-field
                            v-model="new_article_body"
                            label="メッセージを入力してください"
                            filled>
                        </v-text-field>
                        <v-btn color="success" dark v-on:click="createNewUser" class="ma-5">投稿</v-btn>
                        
                    </v-sheet>
                </div>
            </v-col>
  
            <template v-if="logined" v-for="(article, index) in articles" :key="index">
              <v-col
                class="mt-2"
                cols="12"
              >
                <v-card
                    class="mt-2 mx-auto"
                    max-width="400"
                    color="lime lighten-5"
                >
                    <v-card-text class="text--primary">
                      <div><br>ID: {{ article.article_id}}<br>投稿日時: {{ article.pub_date}}<br>名前: {{ article.article_heading}}<br>{{ article.article_body}}</div>
                    </v-card-text>
                    <v-card-actions>
                        <v-btn color="success" dark v-on:click="DeletedArc(article.article_id)">削除</v-btn>
                    </v-card-actions>
                </v-card>
              </v-col>
            </template>
          </v-row>
        </v-container>
      </v-main>
      <!-- 初期メッセージダイアログ -->
      <v-container>
        <v-dialog v-model="dialog" width="auto">
            <v-card>
              <v-card-text>
                必ず架空のメールアドレスとパスワードを使ってください。例、email:abc@def.jp
                password:password 実際のメールアドレスを入力した場合、メールアドレス漏洩の保証はしません!
              </v-card-text>
              <v-card-actions>
                <v-btn color="primary" block @click="dialog = false"
                  >閉じる</v-btn
                >
              </v-card-actions>
            </v-card>
          </v-dialog>
      </v-container>
      <!-- フッター -->
      <v-footer padless>
          <v-col
            class="text-center"
            cols="12"
          >
            <p>Copyright (c) 2023 イチゲブログ</p><v-btn href="https://opensource.org/licenses/mit-license.php">Released under the MIT license</v-btn>
          </v-col>
      </v-footer>
      <!-- フッターend -->
    </v-app>
  </template>
    
  <script>
  // 「Cloud Firestore を初期化する」コピーしたものbegin
    import { initializeApp } from "firebase/app";
    import { getFirestore } from "firebase/firestore";
    import { getAuth } from "firebase/auth";
      const firebaseConfig = {
          apiKey: "それぞれ人によって違う",
          projectId: "それぞれ人によって違う",
          authDomain: "それぞれ人によって違う",
      };
      // Initialize Firebase
      const app = initializeApp(firebaseConfig);
      // Initialize Cloud Firestore and get a reference to the service
      const db = getFirestore(app);
      // Initialize Firebase Authentication and get a reference to the service
      const auth = getAuth(app);
  //「 Cloud Firestore を初期化する」をコピーしたものend
  // 使用する関数(firebaseが用意している)をここに追加する
    import { collection, getDocs,addDoc,doc, deleteDoc} from "firebase/firestore"
    import { createUserWithEmailAndPassword ,signInWithEmailAndPassword ,signOut,onAuthStateChanged} from "firebase/auth";

    export default {
        data() { 
            return{
                loginUser:'',
                dialog: false,
                drawer: null,
                articles: [],
                new_article_heading:'',
                new_article_body:'',
                del_article_id:'',
                logined:false,
                show1: false,
                show2: true,
                password: 'password',
                email:'abc@def.jp',
                rules: {
                required: value => !!value || 'パスワードを入力してください。',
                min: v => v.length >= 8 || '8文字以上入力してください',
                emailMatch: () => (`メールアドレスとpasswordご確認ください。`),
                },
                items: [
                  { url:'https://kikuichige.com',text: 'イチゲブログ', icon: 'mdi-home' },
                  { url:'https://vuetifyjs.com/ja/',text: 'Vuetify', icon: 'mdi-flag' },
                ],
            };
        },
        async created(){
            await onAuthStateChanged(auth, (user) => {
            if (user) {
                // User is signed in, see docs for a list of available properties
                const uid = user.uid;
                console.log('ユーザーは',uid);
                this.loginUser=user.email;
                console.log("メールアドレスは",this.loginUser);
                this.logined=true;
                this.dialog=false;
                this.getArticles();
            } else {
                // User is signed out
                this.dialog=true;
            }
            });
        },
        methods: {
          // firestoreからデータ取得
            async getArticles() {
              this.articles=[];
              const querySnapshot = await getDocs(collection(db, "users"));
              querySnapshot.forEach((doc) => {
                let article_buf=doc.data();
                article_buf["article_id"]=doc.id
                console.log("article_buf => ", article_buf);
                this.articles.push(article_buf)
              });
            // 日付順にソート
              this.articles.sort((a, b) => new Date(a.pub_date) - new Date(b.pub_date)).reverse();
            },
            // firestoreにデータ書き込み
            async createNewUser(){
              var date = new Date();
              try {
                const docRef = await addDoc(collection(db, "users"), {
                  "article_heading": this.new_article_heading,
                  "article_body": this.new_article_body,
                  "pub_date":date.toLocaleString(),
                });
                console.log("Document written with ID: ", docRef.id);
                this.new_article_heading='';
                this.new_article_body='';
              } catch (e) {
                console.error("Error adding document: ", e);
              }
              this.getArticles();
            },
            // firestoreのデータ1件削除
            async DeletedArc(delId){
              console.log("delid=>",delId);
              await deleteDoc(doc(db, "users", delId));
              this.getArticles();
            },
            async createAuth(){
                console.log('password=',this.password)
                console.log('email=',this.email)
                try {
                    await createUserWithEmailAndPassword(auth, this.email, this.password)
                    .then((userCredential) => {
                        // Signed in
                        const user = userCredential.user;
                        console.log('userは',user);
                    })
                    .catch((error) => {
                        const errorCode = error.code;
                        const errorMessage = error.message;
                        console.log('errorは',error);
                        alert('メールアドレスとパスワードをご確認ください。');
                    });
                }catch (error) {
                    alert('メールアドレスとパスワードをご確認ください。');
                    console.log('errorは',error);
                }
                this.password='';
                this.email='';
            },
            async signinAuth(){
                try {
                    await signInWithEmailAndPassword(auth, this.email, this.password)
                    .then((userCredential) => {
                        // Signed in
                        const user = userCredential.user;
                        console.log('Signed in');
                        this.logined=true;
                        this.dialog=false;
                    })
                    .catch((error) => {
                        const errorCode = error.code;
                        const errorMessage = error.message;
                        alert('メールアドレスとパスワードをご確認ください。');
                    });
                }catch (error) {
                        alert('メールアドレスとパスワードをご確認ください。');
                        console.log('errorは',error);
                }
                this.password='';
                this.email='';
            },
            async signoutAuth(){
                await signOut(auth).then(() => {
                    console.log('Sign-out successful.');
                    }).catch((error) => {
                    // An error happened.
                });
                this.logined=false;
                this.dialog=true;
                this.loginUser='';
            }
      }
    }
    </script>

メールリンク認証

メール送信に関する制限事項にあるメールリンク ログインメール 5 件/日(Sparkプラン)なので公開はしませんがローカルで動かせたので紹介します。
メールアドレス/パスワードでログインしたユーザーでメールリンクでログインすると、この方法でしかログインできなくなりました。
作ったものはメールアドレス/パスワードで登録後、ログアウトして「メールでログインボタン」をクリックするとメールが送られます。メールにあるリンクをクリックすると新しいタブで開きます。「メールログイン完了」ボタンを押すとログイン完了します。
JavaScript でメールリンクを使用して Firebase 認証を行うを参考に作りました。
Firebase コンソールで [Authentication] セクションを開く→[Sign-in method] タブで [メールリンク(パスワードなしでログイン)] を有効→[保存]

ActionCodeSettingsの開設
url:は届いたメール内のリンクをユーザーがクリックしたあとに表示するurlになります。今回は以下のようにurlパラメータ?sendmail=1'を追加してメールから来た事を判別して処理で利用しました。
url: 'http://localhost:3000/?sendmail=1',
このurlパラメータは以下のようにすると読み込めます。
this.params = new URLSearchParams(location.search);
this.sendmail = this.params.get('sendmail');
また、sendmailを読み込んだらURLを以下の処理でページ遷移せずに消す処理も入れました。
これをやらないといつもsendmail=1を読み込んでしまう。
const url = new URL(window.location.href);
url.search = '';
window.history.replaceState(null, null, url);

handleCodeInApp: true に設定(意味は分かりません)
iOS:、android:、dynamicLinkDomain:こちらも意味が分からないので使いませんでした。
sendSignInLinkToEmailでメールが送信できます。
isSignInWithEmailLink, signInWithEmailLinkでログインを完了させます。

修正部だけ載せます。初期メッセージダイアログは今回表示しないようにしました。(複雑になるため)

略
          <v-col class="mt-2" cols="12">
            <div v-if="this.sendok">
              <h2 class="text-center">メールを送信しまいた。メールを確認してください。</h2>
            </div>
            <div v-else>
              <v-sheet v-if="!logined && !(this.sendmail === '1')" height="180" color="blue-grey-lighten-4" class="mt-2 mx-auto" max-width="400">
略
              </v-sheet>
            </div>
              <div class="text-center">
                <div v-if="this.sendmail === '1'">
                  <v-btn color="success" dark v-on:click="confirmMail" class="ma-5">メールログイン完了</v-btn>
                </div>
                <div v-else>
                  <v-btn v-if="!logined" color="success" dark v-on:click="createAuth" class="ma-5">登録</v-btn>
                  <v-btn v-if="!logined" color="success" dark v-on:click="signinAuth" class="ma-5">ログイン</v-btn>
                  <v-btn v-if="!logined" color="success" dark v-on:click="mailAuth" class="ma-5">メールでログイン</v-btn>
                  <v-btn v-if="logined" color="success" dark v-on:click="signoutAuth" class="ma-5">ログアウト</v-btn>
                </div> 
              </div>
          </v-col>
略
<!-- 初期メッセージダイアログ -->は削除
略
  import { createUserWithEmailAndPassword ,signInWithEmailAndPassword ,signOut,onAuthStateChanged} from "firebase/auth";
  import { sendSignInLinkToEmail,isSignInWithEmailLink, signInWithEmailLink } from "firebase/auth";
  export default {
      data() { 
          return{
            sendok:false,
            params:'',
            sendmail:'',
略
          });
          try{
            this.params = new URLSearchParams(location.search);
            this.sendmail = this.params.get('sendmail');
            console.log('paramsは',this.params); 
            console.log('sendmailは',this.sendmail);
          } catch (e) {
              console.error("Error adding document: ", e);
            };
      },
      methods: {
略
          },
          async mailAuth(){
              try {
                  const actionCodeSettings = {
                        url: 'http://localhost:3000/?sendmail=1',
                        // This must be true.
                        handleCodeInApp: true,
                        // iOS: {
                        //   bundleId: 'com.example.ios'
                        // },
                        // android: {
                        //   packageName: 'com.example.android',
                        //   installApp: true,
                        //   minimumVersion: '12'
                        // },
                        // dynamicLinkDomain: 'example.page.link'
                      };
                      await sendSignInLinkToEmail(auth, this.email, actionCodeSettings)
                        .then(() => {
                          window.localStorage.setItem('emailForSignIn', this.email);
                          console.log('送信成功');
                          this.sendok=true;
                        })
                        .catch((error) => {
                          console.log('send時のerrorは',error);
                          const errorCode = error.code;
                          const errorMessage = error.message;
                        });
              }catch (error) {
                  alert('メールアドレスとパスワードをご確認ください。2');
                  console.log('errorは',error);
              }
              this.password='';
              this.email='';
              this.sendmail='';
          },
          async signinAuth(){
略
              this.loginUser='';
          },
          async confirmMail(){
            if (await isSignInWithEmailLink(auth, window.location.href)) {
  let email = window.localStorage.getItem('emailForSignIn');
  if (!email) {
    email = window.prompt('Please provide your email for confirmation');
  }
  await signInWithEmailLink(auth, email, window.location.href)
    .then((result) => {
      window.localStorage.removeItem('emailForSignIn');
      console.log('メール確認のresult',result);
    })
    .catch((error) => {
      console.log('メール確認のerror',error);
    });
    this.sendmail='';
    this.sendok=false;
    const url = new URL(window.location.href);
    console.log('urlは',url);
    url.search = '';
    window.history.replaceState(null, null, url);
}
          }
    }
  }
  </script>

あとがき

認証機能を簡単に実装できるがセキュリティ対策は、これで安心せず、よく検討したほうがいいと思います。
メールリンクについて実験していたらメール送信に関する制限事項にあるメールリンク ログインメール 5 件/日(Sparkプラン)に引っかかってなかなか実験できない。でも勝手にプランを上げられていないことは確認できた。

イチゲをOFUSEで応援する(御質問でもOKです)Vプリカでのお支払いがおすすめです。
MENTAやってます(ichige)

コメント

タイトルとURLをコピーしました