【Next.js】MongoDBを使ってユーザー登録とログインをやってみる。
概要
Next.jsとMongoDBを利用したいわゆるMEAN STACKというアプリケーションがある。
これをいきなり、投稿・編集・削除などやるとこんがらがってしまうので、とりあえず新規ユーザーの登録とログインをやってみようと思う。
仕様について
仕様については、以下のようにしてみた。
- 新規登録ができる
- ログインができる
- トークンを発行して期限を2分とする
- ログインしているユーザーしか見れないページがあり、ログインしていない場合はログインページで移動する
これを実現するために以下のようなライブラリやサービスを利用。
- Next.js:フレームワーク。api側の処理を担当。
- mongoDB:ユーザーを登録する為のデータベース。
- jsonwebtoken:データを暗号化したトークンを発行するためのライブラリ。
- mongoose: MongoDBを操作するためのnpmモジュール。
実装
まずはログインから実装する前にMongoDBの接続関連のコード。
その後、ユーザーの新規登録、ログイン画面の実装。最後は、ログインしているかどうかの処理とういう流れで。
今回、NEXT.jsのプロジェクト作成の流れは省くの下記のサイトを参考に。
接続に必要なKEY情報をenvファイルを作る
まずはルート階層に.env
ファイルを作成して必要なKEY情報を環境変数として保存する。
今回必要な情報はjsonwebtokenとmongoDBに接続するためのKEYです。
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の公式サイトでデータベースの作成などは省きます。他のサイトを参考にしてください。
公式サイト
こちらのサイトが参考になります。
上記の「connect your application」をクリック。
上記の画像の赤い枠に囲まれたコードが接続に必要なコードです。
実際のコードなのでぼかしているが、<password>という部分はデータベース作成の際に設定するパスワードに変更する。
MongoDB関連
今回はsrcフォルダ配下のpagesフォルダと同階層にutilsフォルダを作成してそこにファイルを作っていく。
-src
--pages
--utils
データベースに接続する
まずはMongoDBで作成したデータベースと接続するためのコードを書く。
他でも使うのでファイル名はconnectDB.js
というファイルを作成しておく。
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
とします。
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を使う事ができる。
スキーマについてはこちらのサイト
ユーザー登録
まずはユーザー登録の画面。
フロントエンド側
フロントエンド側のコードを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のuseRouter
のpush()
でログインページへリダイレクトさせます。
そのためのインスタンスを作成しています。
フォームデータを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.created
とdata.message
の値はapiのバックエンド側でそれぞれプロパティを設定しています。
バックエンド側
今度はバックエンド側の処理。apiフォルダ配下にregister.js
を作成。
api配下に作成されるファイルの引数はリクエスト(req
)とレスポンス(res
)となります。
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.js
とshemaModel.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フォルダ配下に作成。
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()
で処理します。
また非同期処理なので、async
とawait
を忘れずに。
api側のレスポンスを受け取る
後々バックエンド側の処理は記載するが、データを送ってバックエンド側からのレスポンスを受け取ります。
res.json()
とjsonファイルとして受け取ります。もし成功していたら、トークンが発行されてるので、data.token
でトークがあるかチェック。
もしトークンのデータがあれば成功したと判断できるので、**トークンの値を他のページでも利用する為、localStorageでtokenという値で保存しておきます。
**なお、localStorageに値を保存するのは賛否両論がある。Cookieのライブラリでreact-cookiesなどあるのでそちらを利用するのもありかも?
保存後はrouter.push()
でトップページに移動しています。
もしトークンデータが無ければ失敗していると判断してdata.message
のメッセージをuseState
の値に設定して、このメッセージ内容をフォームボタンの上に表示しています。
なお、data.token
のtoken
とdata.message
のmessage
のプロパティは任意です。
この変数名は、apiのバックエンド側でフロントエンド側に返す際に設定します。
バックエンド側
今度は、apiのバックエンド側の処理です。フロントエンド側の処理でfetch()
の第1引数に指定した、api/login
というURLに送りたいのでapiフォルダの中にlogin.js
というファイルを作成します。
バックエンド側のファイルは引数がリクエスト(req
)とレスポンス(res)になってる。
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
をインポートします。このUserModel
にfindOne
というメソッドがあるのでメールアドレスを引数に指定して同じメールアドレスが登録されているかチェックできる。
戻り値として受けった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)
は成功した時の数値です。
これでログイン側の実装は終わりです。
以下公式サイト
実際にトークンにちゃんとデータが含まれてるか確認したい場合は公式サイトでデバッグで確認できる。
ログインしているかどうかを検証する
最後はログインしているユーザーにしか表示させないようにする為の処理。
他のページでも利用することを想定して、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
の変数であるloginUser
をreturn
で返す。
これはフロントエンド側のページでこのloginUser
に値が入ってるかどうかでログイン中か条件分岐して、表示の制限に利用する為である。
ページの表示の制限をする
最後に、ログインしているかどうかを検証する為のauthCheck.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
これは下記のサイトが参考になりました。
まとめ
以上で、MongoDBを利用したユーザー登録・ログイン・表示の制限を試してみた。
最近は、supabaseやfirebaseなど、認証やデータべースといったバックエンドに必要な機能を提供している、いわゆるBaaSもあったりバックエンドはどのサービスを選んでいいか迷ったりしますが、MongoDBは割と使われてるサービスなので、今度はユーザーごとに投稿など作ってみたい。
下記のサイトを参考にさせていただきました。
Discussion