👻

【Next.js】MongoDBを使ってユーザー登録とログインをやってみる。

2023/04/10に公開

概要

Next.jsとMongoDBを利用したいわゆるMEAN STACKというアプリケーションがある。
これをいきなり、投稿・編集・削除などやるとこんがらがってしまうので、とりあえず新規ユーザーの登録とログインをやってみようと思う。

仕様について

仕様については、以下のようにしてみた。

  • 新規登録ができる
  • ログインができる
  • トークンを発行して期限を2分とする
  • ログインしているユーザーしか見れないページがあり、ログインしていない場合はログインページで移動する

これを実現するために以下のようなライブラリやサービスを利用。

  • Next.js:フレームワーク。api側の処理を担当。
  • mongoDB:ユーザーを登録する為のデータベース。
  • jsonwebtoken:データを暗号化したトークンを発行するためのライブラリ。
  • mongoose: MongoDBを操作するためのnpmモジュール。

実装

まずはログインから実装する前にMongoDBの接続関連のコード。
その後、ユーザーの新規登録、ログイン画面の実装。最後は、ログインしているかどうかの処理とういう流れで。

今回、NEXT.jsのプロジェクト作成の流れは省くの下記のサイトを参考に。

https://nextjs-ja-translation-docs.vercel.app/docs/getting-started

接続に必要なKEY情報をenvファイルを作る

まずはルート階層に.envファイルを作成して必要なKEY情報を環境変数として保存する。
今回必要な情報はjsonwebtokenとmongoDBに接続するためのKEYです。

env
NEXT_PUBLIC_SECRET_KEY="authtest"
DB_API_KEY="mongodb+srv://<ユーザー名>:<パスワード>@auther.51yamfu.mongodb.net/?retryWrites=true&w=majority"

注意点として、NEXT_PUBLIC_SECRET_KEYはフロントエンド側でも使うので、環境変数名にNEXT_PUBLIC_をつける必要がある
これは環境変数をクライアントサイド(ブラウザ)で使用する場合、変数名にNEXT_PUBLIC_ とつけるないと読み込まれない。

今回はjsonwebtokenでトークンを発行する際にシークレットキーが必要になってくる。
jsonwebtokenを使う場面は「ログインした時にトークンを発行」するのはNext.jsのapiのバックエンド側だが、「ログインしているかどうかトークンをチェック」するときは特定のページを読み込んだ時に、、つまりフロントエンド側の処理の際にトークンを解析する時にシークレットキーが必要になるのでNEXT_PUBLIC_ をつけている。

DB_API_KEYは接続に必要な情報です。
MongoDBの管理画面から取ってくる。MongoDBの公式サイトでデータベースの作成などは省きます。他のサイトを参考にしてください。

公式サイト
https://www.mongodb.com/ja-jp

こちらのサイトが参考になります。
https://reffect.co.jp/node-js/mongodb-cloud


上記の「connect your application」をクリック。


上記の画像の赤い枠に囲まれたコードが接続に必要なコードです。
実際のコードなのでぼかしているが、<password>という部分はデータベース作成の際に設定するパスワードに変更する。

MongoDB関連

今回はsrcフォルダ配下のpagesフォルダと同階層にutilsフォルダを作成してそこにファイルを作っていく。

-src
--pages
--utils

データベースに接続する

まずはMongoDBで作成したデータベースと接続するためのコードを書く。
他でも使うのでファイル名はconnectDB.jsというファイルを作成しておく。

utils/connectDB
import mongoose from "mongoose";

const connectDB = async () => {
    try {
        await mongoose.connect(process.env.DB_API_KEY)
        console.log("succecc mongoDB")
    } catch (err) {
        console.log("Failure:Unconnected to MongoDB")
        throw new Error()
    }
}

export default connectDB

mongooseのライブラリを使うのでimportする。
connect()で引数に環境変数で設定したMongoDBに接続するためのKEYを設定する。

MongoDBに登録するデータの構造を作成する

MongoDBに登録するデータの構造をスキーマを作成する。
スキーマとはデータの構造のこと。
こちらもmongooseを利用していきます。ファイル名はshemaModels.jsとします。

utils/shemaModels.js
import mongoose from "mongoose";

const Schema = mongoose.Schema;
const UserSchema = new Schema({
    username: {
        type: String,
        required: true,
    },
    email: {
        type: String,
        required: true,
        unique: true,
    },
    password: {
        type: String,
        required: true,
    }
})

export const UserModel = mongoose.models.User || mongoose.model("User", UserSchema)

mongoose.Schemaを利用してデータ構造を作ってきます。
今回は、ユーザー名・メールアドレス・パスワードを保存したいのでぞれぞれのtypeで文字列の設定や、requiredで必須項目かどうか、uniqueで重複させないような設定をします。

その後、mongoose.model()の中に名前と作成したユーザーのスキーマであるUserSchemaを指定する。こちらは、エラーがでるかもしれないのでmongoose.models.Userとしている。

このファイルも後々利用するのでUserModelとしてexportしています。
このUserModelは利用して、データの検索・削除・登録・更新などのメソッドである find,deleteOne,updateOneを使う事ができる。

スキーマについてはこちらのサイト
https://weseek.co.jp/tech/913/

ユーザー登録

まずはユーザー登録の画面。

フロントエンド側

フロントエンド側のコードをpagesフォルダ配下にregister.jsとして作成。

pages/register.js
import React, { useState } from "react";
import { useRouter } from "next/router";

const Registar = () => {
 const [username, setUsername] = useState("");
 const [email, setEmail] = useState("");
 const [password, setPassword] = useState("");
 const [error, setError] = useState("");
 
 //登録後にログイン画面に移動
 const router = useRouter();
 
 //フォームデータをapi側にリクエストを送る
 const submitHandler = async (e) => {
    e.preventDefault();

    const res = await fetch("http://localhost:3000/api/register", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        username,
        email,
        password,
      })
    });
	
    //api側のレスポンスを受け取る
    const data = await res.json();
    if (data.created) {
      router.push("/login");
    } else {
      setError(data.message);
    }
  };
  
  const changeHandler = (e) => {
    const { name, value } = e.target;

    switch (name) {
      case "username":
        setUsername(value);
        break;
      case "email":
        setEmail(value);
        break;
      case "password":
        setPassword(value);
        break;
    }
  }

 return (
    <>
     <form onSubmit={submitHandler} >
          <label htmlFor="username">ユーザー名</label>
          <input onChange={changeHandler} value={username} type="text" name="username" id="username" />
          <label htmlFor="email">メールアドレス</label>
          <input onChange={changeHandler} value={email} type="email" name="email" id="email" />
          <label htmlFor="password">パスワード</label>
          <input onChange={changeHandler} value={password} type="password" name="password" id="password" />
          {error && <div>{error}</div>}
          <button css={SubmitBtn} type="submit">
            ログイン
          </button>
        </form>
    </>
  )
}

export default Registar

ざっくり説明すると、フォームの入力した値をchangeHandlerで受け取って、switch()でユーザー名・メールアドレス・パスワードとチェックしてuseStateで値を保存しています。
でクリックしたらその値をapi側に送信している。

登録後にログイン画面に移動

ユーザー登録が成功したらログインしてもらいたいので、Next.jsのuseRouterpush()でログインページへリダイレクトさせます。
そのためのインスタンスを作成しています。

フォームデータをapi側にリクエストを送る

フォームで受け取った値をfetchメソッドで送信します。
apiのバックエンド側の処理は後程、記載するがとりあえず/api/registerというURLを指定。
第2に引数はそれぞれデータをPOSTで送信、ヘッダーにはメディア種別としてapplication/jsonとjsonを指定し、実際に送るデータはbodyに指定する。

その際、jsonファイルとして送信したいのでJSON.stringify()を使ってjsonファイルにする。

api側のレスポンスを受け取る

api側のバックエンドで処理してその結果をフロントエンド側でjsonファイルとして受け取る。
受けったデータにdata.createdという値が存在していたらユーザー登録は成功となるので、
router.push()でログインページへ移動させています。

失敗したら、setErrorでバックエンド側から受けとったメッセージ(data.message)を保存して、フロントエンド側のフォームのボタンの上あたりにメッセージを表示しています。

なお、data.createddata.messageの値はapiのバックエンド側でそれぞれプロパティを設定しています。

バックエンド側

今度はバックエンド側の処理。apiフォルダ配下にregister.jsを作成。
api配下に作成されるファイルの引数はリクエスト(req)とレスポンス(res)となります。

api/register.js
import React from 'react'
import connectDB from '@/utils/connectDB'
import { UserModel } from '@/utils/shemaModels'

const Register = async (req, res) => {

    try {
	//MongoDBに接続してユーザーが存在してるかチェックする
        await connectDB()
        const checkUser = await UserModel.findOne({ email: req.body.email })
	
        if (checkUser) {
            return res.status(400).json({ message: "既に登録されているユーザーです。"             })
        } else {
	  //ユーザーを作成する
            await UserModel.create(req.body)
            return res.status(200).json({ message: "ユーザー登録成功", created: true             })
        }
    } catch (error) {
        return res.status(400).json({ message: "ユーザー登録失敗" })
    }
}

export default Register

MongoDBに接続してユーザーが存在してるかチェックする

まずは、すでにユーザーが登録されていないかチェックする必要がある。
メールアドレスは重複しないユニークな値としてスキーマに設定しているので、受け取ったメールアドレスからMongoDBに問い合わせして同じメールアドレスが存在するかチェックする。

ちなみにフロントエンド側から入力された値はreq.bodyに入っているので、その中にメールアドレスの値もある。

MongoDBに問い合わせする為にconnectDB.jsshemaModel.jsをインポートする。connectDB()を実行してアクセスして、UserModelにあるfindOne()を利用すれば、存在するかチェックできるので、メールアドレスを指定する。

結果をcheckUser変数で受けとって条件分岐で調べる。もしデータが存在するならすでに、MongoDBにはそのメールアドレスが存在してることになるので、フロントエンド側にjsonファイルとしてmessageというプロパティにメッセージをレスポンス(res)する。

ユーザーを作成する

UserModelにあるfindOne()の結果でcheckUser変数に何もなかったら、ユーザーが登録されていないことになるので、UserModelにあるcreate()メソッドで登録することができる。

登録するデータは、req.bodyに入ってるのでそのまま引数に指定する。
フロントエンド側で下記のようにオブジェクトして送信しているので、そのままで大丈夫。

{
  username,
  email,
  password,
}

あとはユーザー登録が成功したことをフロントエンド側に送りたいので、以下のようにレスポンス(req)で返す。
またフロントエンド側で、登録が成功したかどうか条件分岐としてcreatedというプロパティを使ってるのでこちらも設定しておく。

return res.status(200).json({ message: "ユーザー登録成功", created: true })

以上でユーザー登録の実装は終わり。

ログインの実装

MongoDBの接続関連の次はログインの実装。

フロントエンド側

まずはフロントエンド側のコードをpagesフォルダ配下に作成。

pages/login.js
import React, { useState } from "react";
import { useRouter } from "next/router";

const Login = () => {
    const [email, setEmail] = useState("");
    const [password, setPassword] = useState("");
    const [error, setError] = useState("");
    
    //ログイン後にトップページへ移動させる
    const router = useRouter();
	
  //フォームデータをapi側にリクエストを送る
    const submitHandler = async (e) => {
        e.preventDefault();
	
        const res = await fetch("http://localhost:3000/api/login", {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                email,
                password,
            })
        });
	
	//api側のレスポンスを受け取る
        const data = await res.json();
        if(data.token) {
            alert("ログイン成功")
            localStorage.setItem("token", data.token);
            router.push("/");
        }else{
            setError(data.message);
        }
    };

    const changeHandler = (e) => {
        const { name, value } = e.target;
 
        switch (name) {          
            case "email":
                setEmail(value);
                break;
            case "password":
                setPassword(value);
                break;
        }
    }
    return (
        <>
          <form onSubmit={submitHandler}>
                    <label htmlFor="email">メールアドレス</label>
                    <input onChange={changeHandler} value={email} type="email" name="email" id="email" />
                    <label htmlFor="password">パスワード</label>
                    <input onChange={changeHandler} value={password} type="password" name="password" id="password" />
                    {error && <div>{error}</div>}
                    <button css={SubmitBtn} type="submit">
                        ログイン
                    </button>
                </form>
        </>
    );
};

export default Login;

ざっくり説明すると、フォームの入力した値をuseStateで保存して、クリックしたらapi側にデータを送ります。

ログイン後にトップページへ移動させる

フォームデータの結果を受け取ってログインが成功したらトップページに移動させたいので、Next.jsにあるuseRouterのルーティング機能を使うのでインスタンスを作る。

フォームデータをapi側にリクエストを送る

fetch()で第1引数にapi側に送るURL,第2引数に送るデータを設定します。
今回はフォームの値をPOSTとしてheadersにはJSONファイルを送りたいので、application/jsonとしています。

肝心のデータはbodyにメールアドレスとパスワードを送ります。JSONファイルに変換したいので、JSON.stringify()で処理します。

また非同期処理なので、asyncawaitを忘れずに。

api側のレスポンスを受け取る

後々バックエンド側の処理は記載するが、データを送ってバックエンド側からのレスポンスを受け取ります。

res.json()とjsonファイルとして受け取ります。もし成功していたら、トークンが発行されてるので、data.tokenでトークがあるかチェック。
もしトークンのデータがあれば成功したと判断できるので、**トークンの値を他のページでも利用する為、localStorageでtokenという値で保存しておきます。

**なお、localStorageに値を保存するのは賛否両論がある。Cookieのライブラリでreact-cookiesなどあるのでそちらを利用するのもありかも?

保存後はrouter.push()でトップページに移動しています。

もしトークンデータが無ければ失敗していると判断してdata.messageのメッセージをuseStateの値に設定して、このメッセージ内容をフォームボタンの上に表示しています。
なお、data.tokentokendata.messagemessageのプロパティは任意です。
この変数名は、apiのバックエンド側でフロントエンド側に返す際に設定します。

バックエンド側

今度は、apiのバックエンド側の処理です。フロントエンド側の処理でfetch()の第1引数に指定した、api/loginというURLに送りたいのでapiフォルダの中にlogin.jsというファイルを作成します。

バックエンド側のファイルは引数がリクエスト(req)とレスポンス(res)になってる。

api/login.js
import React from 'react'
import jwt from "jsonwebtoken"
import connectDB from '@/utils/connectDB'
import { UserModel } from '@/utils/shemaModels'

const Login = async (req, res) => {

    try {
	//フロントエンド側からのデータを受け取る
        const { email, password } = req.body
	
	//MongoDBに接続してユーザーを探す
        await connectDB()
        const saveUser = await UserModel.findOne({ email: email })
        
        if (saveUser) {
            if (password === saveUser.password) {
	       //jsonwebtokenでトークンを発行する
                const token = jwt.sign({
                    username: saveUser.username,
                    email: email,
                },process.env.NEXT_PUBLIC_SECRET_KEY , { expiresIn: "2m" })
             
                return res.status(200).json({ message: "ログイン成功", token: token })
            } else {
                return res.status(400).json({ message: "パスワードが間違っています" })
            }
        } else {
            return res.status(400).json({ message: "ユーザーが存在しない。登録してください" })
        }

    } catch (error) {
        return res.status(400).json({ message: "ログイン失敗" })
    }
}

export default Login	

フロントエンド側からデータを受け取る

フロントエンド側のデータを取りさすにはreq.bodyかた取り出せるので、メールアドレスとパスワードを取り出してます。

MongoDBに接続してユーザーを探す

取り出したデータのメールアドレスを利用してMongoDBにユーザーが登録されているかチェックします。
shemaModels.jsで作成したUserModelをインポートします。このUserModelfindOneというメソッドがあるのでメールアドレスを引数に指定して同じメールアドレスが登録されているかチェックできる。
戻り値として受けったsaveUserという変数をチェックしてデータが無ければユーザー登録されていないので、その内容をフロントエンド側にjsonファイルとしてmessageプロパティとしてレスポンス(res)で返す
データ存在していた場合は、フロントエンド側受け取ったパスワードと、MongoDBから受け取ったパスワードが合っているかチェックする。
合っていなければ同じく、フロントエンド側にjsonファイルとしてmessageとして「パスワードが間違っています」とレスポンス(res)で返す。

jsonwebtokenでトークンを発行する

まずは一部ソースコード。

 const token = jwt.sign(
	{
         username: saveUser.username,
         email: email,
                },
	process.env.NEXT_PUBLIC_SECRET_KEY,
	{ expiresIn: "2m" }
	)
return res.status(200).json({ message: "ログイン成功", token: token })

jwt.sign()でトークンを発行できるのが、引数は順番にペイロード、シークレットキー、有効期限となっている 。またペイロードには任意の値を値を含めることができるので、今回はメールアドレスとMongoDBに登録されてるユーザー名を含めてみます。

成功すると戻り値としてトークンが返ってくるので,これをフロントエンド側にjsonとしてトークンも含めたいので下記のようにしています。
status(200)は成功した時の数値です。

これでログイン側の実装は終わりです。

以下公式サイト
実際にトークンにちゃんとデータが含まれてるか確認したい場合は公式サイトでデバッグで確認できる。
https://jwt.io/introduction

ログインしているかどうかを検証する

最後はログインしているユーザーにしか表示させないようにする為の処理。
他のページでも利用することを想定して、utilsフォルダにauthCheck.jsで作成。

utils/authCheck.js
import { useRouter } from 'next/router'
import React, { useEffect, useState } from 'react'
import jwt from 'jsonwebtoken'

const authCheck = () => {
    const [loginUser, setLoginUser] = useState({ username: '', email: '' })
    const router = useRouter()
  //トークン情報を取り出して検証する
    useEffect(() => {
        const token = localStorage.getItem('token')
        if (!token) {
            router.push('/login')
        }

       try {
        //トークンを検証する
        const decoded = jwt.verify(token, process.env.NEXT_PUBLIC_SECRET_KEY)

        setLoginUser(decoded)

       } catch (error) {
        router.push('/login')        
       }

    }, [router])

    return loginUser
}

export default authCheck

トークン情報を取り出して検証する

useEffectを利用して、トークンをチェックします。
まずはログイン時に保存したトークンをlocalStorage.getItemで取り出す。

トークンの情報が入っていなければログインしていないので、useRouterを利用してログイン画面にリダイレクトさせます。
またトークンの有効期限が切れている場合、jwt.verify関数はエラーをスローします。そのため、ログインへリダイレクトがかかります。

トークンを検証する

トークン情報が入っていたら、jwt.verifyを利用して検証と読み取りをする。
成功すれば、ログイン時にjwt.signで渡したユーザー名・メールアドレスのデータも入ってるので、setLoginUserで保存する。

最後にsetLoginUserの変数であるloginUserreturnで返す。
これはフロントエンド側のページでこのloginUserに値が入ってるかどうかでログイン中か条件分岐して、表示の制限に利用する為である。

ページの表示の制限をする

最後に、ログインしているかどうかを検証する為のauthCheck.jsのコードを対象ページでインポートして利用する。今回はpagesフォルダ配下にabout.jsというファイルを作成してみた。

ログイン状態であれば、ユーザー名とメールアドレスを表示させてみる。

pages/about.js
import authCheck from '@/utils/authCheck'
import React from 'react'
const About = () => {
    const authCheck = authCheck();

    return (
        <>
            <h1>Aboutページ</h1>
                <p>このページはログインしているユーザー専用</p>
                <hr />
                {user && (
                    <>
                    <p>{user.username}</p>
                    <p>{user.email}</p>
                    </>
                )}
        </>
    )
}

export default About

上記を見ればわかるが、インポートしたauthCheckの関数を実行して戻り値を変数authCheckで受け取ってる。
この変数に値が入っていたら、ユーザー名とメールアドレスが表示されるようにしている。

jsonwebtokenを使う上での注意点

今回、jsonwebtokenのライブラリを使ったがフロントエンド側で以下のエラーが出てjwt.verifyで検証と読み取りができなかった。

jwt.verify()でRight-hand side of ‘instanceof’ is not an object

色々ネットを調べていたら、どうやらjsonwebtokenのバージン9の場合、バックエンド側(Node.js側)では問題ないがフロントエンド側(ブラウザ側)で使うとエラーが出る模様。
対処法としてバージョンを8に下げる事らしい。
なので、vsCodeのターミナルを開いてバージョン9を削除して、バージョン8をインストールすると解決した。

npm remove jsonwebtoken
npm install jsonwebtoken@8.5.1

これは下記のサイトが参考になりました。
https://ageo-soft.info/programming_languages/javascript/nodejs/8115/

まとめ

以上で、MongoDBを利用したユーザー登録・ログイン・表示の制限を試してみた。
最近は、supabaseやfirebaseなど、認証やデータべースといったバックエンドに必要な機能を提供している、いわゆるBaaSもあったりバックエンドはどのサービスを選んでいいか迷ったりしますが、MongoDBは割と使われてるサービスなので、今度はユーザーごとに投稿など作ってみたい。

下記のサイトを参考にさせていただきました。
https://weseek.co.jp/tech/913/
https://ageo-soft.info/programming_languages/javascript/nodejs/8115/
https://zenn.dev/bluepost/articles/74ac51dfa484d1
https://zenn.dev/mikakane/articles/tutorial_for_jwt
https://qreat.tech/3412/
https://reffect.co.jp/node-js/mongodb-cloud

Discussion