【React×Express】フルスタックでSPAの認証機能を実装してみた。
はじめに
JSON Web Token(JWT)を使用したSPAでの認証機能を実装してみたので、学びがあったので記事にしました。
バックエンドとフロントエンドを包括的にまとめた記事は少なかったので、この記事をみればSPAで認証できるような記事にしたいと思います。
実装で意識した点は以下の3点です。
・JWTをlocalStorageに保存すると、XSSが起きる可能性があるということでJWTはcookieに保存
・ページ移動・ロードごとにサーバー側で認証チェックするためのフック(useAuth)を作成
・ログイン状態の有無によって、ルーティングを制御(ログイン状態していないときに、認証後のページアクセスした場合は認証前にページに移動する)
対象となる読者
この記事は下記ような人を対象として書いています。
- プログラミング初心者
- 駆け出しエンジニア
- SPAで認証機能を実装する方法を知りたい人
開発環境
-
OS
- macOS Monterey v12.3.1
-
フロントエンド
- 言語: TypeScript
- ライブラリー: React+React・Redux-toolkit
-
バックエンド
- 言語: Node.js+TypeScript
- フレームワーク: Express
- DB:PostgereSQL
- ORM: TypeORM
バックエンドの実装
環境構築
Node.jsで環境構築できる前提で進めますので、まだインストールしていない方はこちらを参考にしてください。
プロジェクトの構築
まず、プロジェクトを作成して、必要なパッケージをインストールします。
その後に、CLIを使用して、TypeORMに必要なフォルダ・ファイルを生成します。
$ mkdir auth-app
$ cd auth-app
npmの初期化処理をする。
package.jsonファイルが作成される(インストールするべきパッケージのバージョンの範囲が記載される)
$ npm init
必要なパッケージのインスール
$ npm install express typeorm pg concurrently bcrypt dotenv jsonwebtoken ms cookie-parser cors
開発環境に必要なパッケージをインストール
$ npm install --save-dev typescript ts-node-dev @types/express @types/cors
CLIでDBをpostgreSQLに指定して、TypeORMのプロジェクトを構築
npx typeorm init --database postgres
PostgreSQLの設定
次に、PostgreSQLの設定をします。
PostgeSQLの起動
$ brew services start postgresql;
PostgeSQLへの接続
$ psql
ロールの作成
$ CREATE USER my_dev
データベースの作成
$ CREATE DATABASE auth_app_development OWNER my_dev;
接続停止
$ \q
エンティティの定義
エンティティとは、データベースの箱というイメージで理解しています。
エンティティの定義で、プライマリキーと外部キーの指定・カラム名の設定・カラムの型定義、リレーションなどをします。
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
//Userクラスをエンティティと指定する
@Entity()
export class User {
//プライマリキーが自動的に生成される
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
mail: string;
@Column()
password: string;
//レコードの作成日が自動で挿入されるデコレーター
@CreateDateColumn()
createdAt: Date;
//レコードの更新日が自動で挿入されるデコレーター
@UpdateDateColumn()
updatedAt: Date;
}
data-source.tsファイルの設定
srcフォルダ下ではdata-source.tsファイルを読み込むことができなかったので、まずdata-source.tsファイルをauth-appフォルダ下に移動させます。
DataSourceを設定することでデータベースとの接続が可能になります。
DataSourceオプションはデフォルトで設定されていますが、「username」「password」「entities」「migrations」など一部修正します。
import "reflect-metadata";
import { DataSource } from "typeorm";
//DataSourceをインスタンス化
//DataSourceのオプションを設定
export const AppDataSource = new DataSource({
type: "postgres",
host: "localhost",
port: 5432,
username: "my_dev",
password: undefined,
database: "auth_app_development",
synchronize: true,
logging: false,
entities: ["../src/entity/*.ts"],
migrations: ["../src/migration/*.ts"],
subscribers: [],
});
マイグレーション
ここでのマイグレーションとは、エンティティの定義をもとにDBでテーブル作成をすることです。
npmスクリプトにマイグレーションの実行に必要なコードをは、ドキュメント通りに書くとエラーが出たので下記のissueを参考にして書きました。
{
"name": "auth-app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "ts-node src/index.ts",
+ "typeorm": "ts-node-dev ./node_modules/typeorm/cli.js -d data-source.ts",
+ "migration:generate": "npm run typeorm migration:generate",
+ "migration:show": "npm run typeorm migration:show",
+ "migration:run": "npm run typeorm migration:run",
+ "migration:revert": "npm run typeorm migration:revert",
+ "migration:create": "typeorm-ts-node-commonjs migration:create"
},
//以下略
マイグレーションファイルを作成します。
$ npm run migration:generate src/migration/init
ひとまず、環境構築・TypeORMの設定はこれで終了です。
サーバー起動
次に、Expressでデータベースに接続して、サーバー起動をします。
さらに、今回のプロジェクトで必要なミドルウェアも設定します。
ここで、CORS(Cross-origin resource sharing)について少し解説します。CORSのO、つまりoriginとは、プロトコル・ホスト・ポートの3つの部分で定義されます。通常、同一のorigin同士でしかリソース共有(情報通信)はできませんが、CORSは異なるオリジン間でリソース共有できることをいいます。今回、フロントエンドとバックエンドでそれぞれ別のポート番号でサーバーを立ち上げるのでCORSを設定する必要があります。
CORSについてはこちらの記事が参考になります。
import { AppDataSource } from "../data-source";
import * as cors from "cors";
import * as dotenv from "dotenv";
import * as cookieParser from "cookie-parser";
import * as express from "express";
dotenv.config();
const app = express();
const corsOptions: cors.CorsOptions = {
//フロントエンド側のポート番号を設定する
origin: "http://localhost:3000",
//認証情報の通信をするためにtrueにする
credentials: false,
};
//3000番ポートのリクエストを許可
app.use(cors(corsOptions));
//URLのなかでエンコードされた文字を読み取れるようにする
app.use(express.urlencoded({ extended: true }));
//リクエストされたJSONオブジェクトを読み取れるようにする
app.use(express.json());
//リクエストされたcookieを読み取れるようにする
app.use(cookieParser());
AppDataSource.initialize()
.then(() => {
console.log("Data Source has been initialized!");
})
.catch((err) => {
console.error("Error during Data Source initialization:", err);
});
app.listen(8000);
JWTの設定
MVCのフレームワークではセッションベースの認証方式が一般的ですが、SPA(Single Page Application)では一般的にトークンを用いた認証方式が用いられます。セッションはサーバーサイドのDBに保存して管理していますが、トークンはクライアントサイドに保存されます。そして、このトークンとしてよく使われるのがJWTです。ここでは詳しい説明は省略しますが、JWTとはヘッダー(header)、ペイロード(payload)、署名(signature)の3つの部分で構成されいて、JSONデータ構造で表現したトークンです。認証機能を実装する上で、JWTの生成と検証が必要なので、jsonwebtokenというライブラリを使用して実装します。
今回は、jwtHelperクラスを作成して、ルーティングの部分でクラスを使用するようにします。
import * as jwt from "jsonwebtoken";
export class jwtHelper {
//秘密鍵
static jweSecret = "secret123";
static createToken() {
const token = jwt.sign({ foo: "bar" }, this.jweSecret, {
expiresIn: "30d",
});
return token;
}
static verifyToken(token: string) {
try {
const decoded = jwt.verify(token, this.jweSecret);
return decoded;
} catch (err) {
console.log(err);
}
}
}
ルーティング
ルーティングをするために、srcフォルダ下にrouterフォルダを作成して、そのフォルダ下に必要なフォルダ・ファイルを作成します。
今回はrouterフォルダ下でコントローラーも入れんこんだ形にします。
src
├ router
│ ├ home
│ │ └index.ts
│ ├ login
│ │ └index.ts
│ ├ logout
│ │ └index.ts
│ ├ signUp
│ │ └index.ts
│ ├ index.ts
//以下略
ルーティングをします。
import * as express from "express";
import login from "./login";
import signUp from "./signUp";
import logout from "./logout";
import home from "./home";
const router = express.Router();
//認証前ページからのデータ処理のルーティング
router.use("/sign-up", signUp);
router.use("/login", login);
router.use("/logout", logout);
//jwtトークンの検証
router.get("/tokenVerification", (req, res, next) => {
let token = "";
if (req.cookies.jwtToken) {
token = req.cookies.jwtToken;
} else {
//cookieにjwtトークンがない場合は、認証不可
return res.status(200).json({ isAuthenticated: false });
}
// リクエストされたjwtトークンを検証
const decode = jwtHelper.verifyToken(token);
if (decode) {
//検証がOKであれば、jwtトークンを再作成
const token = jwtHelper.createToken();
res.cookie("jwtToken", token, {
httpOnly: true,
expires: new Date(Date.now() + ms("2d")),
});
res.status(200).json({ isAuthenticated: true });
}
});
//認証後ページからのデータ処理のルーティング
router.use("/home", home);
export default router;
モジュールの定義
ルーティングで読み込んでいる各モジュールを定義します。
サインアップ
import * as express from "express";
import { User } from "../../entity/User";
import { AppDataSource } from "../../../data-source";
import * as bcrypt from "bcrypt";
import ms = require("ms");
import { jwtHelper } from "../../helper/jwtHelper";
const router = express.Router();
const userRepository = AppDataSource.getRepository(User);
router.post("/", async (req, res, next) => {
try {
const user = await userRepository.findOne({
where: { mail: req.body.email },
});
if (user) {
throw new Error("USERS_ALREADY_EXISTS_USER");
}
//パスワードのハッシュ化
const hashPassword = await bcrypt.hash(req.body.password, 10);
if (!hashPassword) {
throw new Error("SERVER_ERROR");
}
//DBに保存
await userRepository.insert({
firstName: req.body.firstName,
lastName: req.body.lastName,
mail: req.body.email,
password: hashPassword,
});
//jwtトークンを生成
const jwtToken = jwtHelper.createToken();
return res.status(200).cookie("jwtToken", jwtToken, {
httpOnly: true,
//トークンの期限を設定
expires: new Date(Date.now() + ms("2d")),
});
} catch (error) {
console.log(error);
}
});
export default router;
ログイン
import * as express from "express";
import { AppDataSource } from "../../../data-source";
import { User } from "../../entity/User";
import * as bcrypt from "bcrypt";
import { jwtHelper } from "../../helper/jwtHelper";
import ms = require("ms");
const router = express.Router();
//レポジトリを取得(データの格納先のエンティティを指定する)
const userRepository = AppDataSource.getRepository(User);
router.post("/", async (req, res, next) => {
try {
const user = req.body;
if (!user.email || !user.password) {
throw new Error("USERS_INVALID_VALUE");
}
//DBからユーザー情報を取得
const result = await userRepository.findOne({
where: { mail: user.email },
});
//既に登録済みのアドレスかチェック
if (!result) {
throw new Error("USERS_NOT_EXISTS_USER");
}
//リクエストされたパスワードとDBのパスワード(暗号化されたパスワード)を比較
const match = await bcrypt.compare(user.password, result.password);
if (match) {
//パスワードが同じの場合、jsonWebTokenを作成
const jwtToken = jwtHelper.createToken();
res
.cookie("jwtToken", jwtToken, {
//webサーバーのみがアクセス可能
httpOnly: true,
//cookieの有効期限は2日間に設定
expires: new Date(Date.now() + ms("2d")),
})
.json({
user: {
id: result.id,
},
});
} else {
throw new Error("SERVER_ERROR");
}
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
}
});
export default router;
ログアウト
import * as express from "express";
import ms = require("ms");
const router = express.Router();
router.post("/", (req, res, next) => {
try {
return res.status(200).cookie("jwtToken", "", {
httpOnly: true,
expires: new Date(Date.now() + ms("1d")),
});
} catch (error) {
console.log(error);
}
});
export default router;
ログイン後
import * as express from "express";
const router = express.Router();
router.get("/", (req, res, next) => {
res.send("SUCCESS");
});
export default router;
バックエンドの実装はこれで完了です。
次にフロントエンドの実装をしていきます。
フロントエンドの実装
環境構築
「Create-React-App」を使用して、フロントエンドのプロジェクトを作成し、必要なパッケージもインストールします。
$ npx create-react-app@5.0.1 frontend --template typescript
$ npm i react-router-dom axios
バックエンドとフロントエンドの両方のサーバーを同時に立ち上げるために、concurrentlyというパッケージを使用して、バックエンド側のpackage.jsonを修正します。
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "ts-node src/index.ts",
+ "serve": "ts-node-dev --respawn src/index.ts --ignore-watch ./frontend",
+ "dev": "concurrently \"npm run serve\" \"cd frontend && npm start\"",
"typeorm": "ts-node-dev ./node_modules/typeorm/cli.js -d data-source.ts",
"migration:generate": "npm run typeorm migration:generate",
"migration:show": "npm run typeorm migration:show",
"migration:run": "npm run typeorm migration:run",
"migration:revert": "npm run typeorm migration:revert",
"migration:create": "typeorm-ts-node-commonjs migration:create"
},
コンソールに「npm run dev」を走らせると、3000番ポートと8000番ポートが同時に立ち上がります。
ディレクトリの整理
不要なファイルを削除したりデフォルトのディレクトリを整理していき、src下を下記のディレクトリ構造にします。
frontend
└── src
├─api
├──components
│ ├──ErrorBoundary
│ └──pages
│ ├──HomePage
│ ├──LoginPage
│ ├──SignUpPage
│ └──TopPage
├──hooks
├── App.tsx
└──index.tsx
必要なライブラリのインストール
フロントエンドで使用するライブラリーをインストールします。
$ npm install react-hook-form react-router-dom axios
react-hook-form・・・フォームを送信するときに使用するライブラリ。
react-router-dom・・・ページ間の移動に使用するライブラリ。ルーティングを行う。
axios・・・HTTP通信(データの更新・取得)を簡単に行うことができるライブラリ。
APIの作成
バックエンドのポート(8000番ポート)とデータのやり取りをするために、axiosを使用して必要なAPIを作成します。
import axios from "axios";
axios.defaults.withCredentials = true;
export type User = {
id?: number;
firstName?: string;
lastName?: string;
mail: string;
password?: string;
};
export const signUp = async (data: User) => {
await axios.post("http://localhost:8000/sign-up", data);
return;
};
export const login = async (data: User) => {
await axios.post("http://localhost:8000/login", data);
return;
};
export const logout = async () => {
await axios.post("http://localhost:8000/logout");
return;
};
//jwtの検証
export const checkJwt = async () => {
const response = axios.get("http://localhost:8000/tokenVerification");
return response;
};
コンポーネントの作成
次に描画するページを作成していきます。(スタイリングは省略させていただきますので、画面は多少みにくいかもしれません。)
トップページ
react-router-domのLinkコンポーネントで、クリックしたときの移動先のURLを指定します。
ルーティングは後ほど実装します。
import { Link } from "react-router-dom";
export const TopPage = () => {
return (
<div>
<Link to={"/sign-up"}>登録</Link>
<Link to={"/login"}>ログイン</Link>
</div>
);
};
表示画面
サインアップページ
ユーザー登録情報を送信するときに、react-hook-formを使用します。
詳しい実装方法はこちらをご覧ください。
import { useForm, SubmitHandler } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { signUp, User } from "../../../api/index";
export const SignUpPage = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<User>();
const navigate = useNavigate();
const onSubmit: SubmitHandler<User> = async (data) => {
await signUp(data);
//認証後のページにリダイレクト
navigate("/home");
};
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="firstName">
性
<input
id="firstName"
type="text"
{...register("firstName", { required: true })}
/>
</label>
<p> {errors.firstName && "文字が入力されていません"}</p>
<label htmlFor="lastName">名</label>
<input
id="lastName"
type="text"
{...register("lastName", { required: true })}
/>
<p> {errors.lastName && "文字が入力されていません"}</p>
<label htmlFor="email_register">Eメール</label>
<input
id="email_register"
type="email"
{...register("mail", { required: true })}
/>
<p> {errors.mail && "文字が入力されていません"}</p>
<label htmlFor="password_register">パスワード</label>
<input
id="password_register"
type="password"
{...register("password", { required: true })}
/>
<p> {errors.password && "文字が入力されていません"}</p>
<button type="submit">新規登録</button>
</form>
</div>
);
};
表示画面
ログインページ
import React from "react";
import { useForm, SubmitHandler } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { User, login } from "../../../api/index";
export const LoginPage = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<User>();
const navigate = useNavigate();
const onSubmit: SubmitHandler<User> = async (data) => {
await login(data);
//認証後のページにリダイレクト
navigate("/home");
};
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="email_register">Eメール</label>
<input
id="email_register"
type="email"
{...register("mail", { required: true })}
/>
<p> {errors.mail && "文字が入力されていません"}</p>
<label htmlFor="password_register">パスワード</label>
<input
id="password_register"
type="password"
{...register("password", { required: true })}
/>
<p> {errors.password && "文字が入力されていません"}</p>
<button type="submit">ログイン</button>
</form>
</div>
);
};
表示画面
認証後のページ
import React from "react";
import { useNavigate } from "react-router-dom";
import { logout } from "../../../api";
export const HomePage = () => {
const navigate = useNavigate();
const handleLogout = async () => {
console.log("logout");
await logout();
navigate("/");
};
return (
<div>
<p>ログイン中</p>
<button onClick={() => handleLogout()}>ログアウト</button>
</div>
);
};
表示画面
コンポーネントの描画
ブラウザにコンポーネントの内容を描画するようにします。
各コンポーネントの説明
・ErrorBoundaryコンポーネント
予期せぬエラーが発生したときに描画されます。ここではコードは省略しますが、ページ最下部にgit hubを添付しておりますのでそちらよりご確認ください。
・React.StrictModeコンポーネント
公式によると、StrictModeは下記の機能があるようです。
安全でないライフサイクルの特定
レガシーな文字列 ref API の使用に対する警告
非推奨な findDOMNode の使用に対する警告
意図しない副作用の検出
レガシーなコンテクスト API の検出
state の再利用性を保証する
・BrowserRouterコンポーネント
<BrowserRouter/>は、HTML5のHisory API(pushstate、replacestate、popstateイベント)を使用して、UIをURLと同期させます。<App/>でreact-dom-routerの<Routes/>や<Route/>を使用してルーティング設定をしますが、<BrowserRouter/>がそれらのコンポーネントをラップする必要があります。
・Appコンポーネント
ルーティング設定をするコンポーネントです。後ほど説明します。
import React from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
const container = document.getElementById("root")!;
const root = createRoot(container);
root.render(
<ErrorBoundary>
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
</ErrorBoundary>
);
認証のためのフックの実装
ここが今回の肝かと思います。
ページ移動・ローディングしたときに、認証するロジックを組み込むためのフックを実装します。
import { useState, useEffect } from "react";
import { checkJwt } from "../api";
export const useAuth = () => {
//認証を許可するかどうかを状態管理
const [check, setCheck] = useState<{
checked: boolean;
isAuthenticated: boolean;
}>({ checked: false, isAuthenticated: false });
//レンダリング後に実行
useEffect(() => {
const handleCheckJwt = async () => {
try {
//バックエンドでJWTの検証および再作成
const response = await checkJwt();
setCheck({
checked: true,
isAuthenticated: response.data.isAuthenticated,
});
} catch (error) {
setCheck({ checked: true, isAuthenticated: false });
}
};
handleCheckJwt();
}, []);
return check;
};
PrivateRouteとGuestRouteの作成
認証が必要なコンポーネントには<PrivateRoute/>でラップして、認証が不要なコンポーネントには<GuestRoute/>をラップします。
<GuestRoute/>はログイン状態で認証前のページにアクセスしたときにログイン後のページにリダイレクトするようにします。
<PrivateRoute/>のロジック
認証が許可→子コンポーネントがレンダリング
認証が不許可→"/"にリダイレクト
<GuestRoute/>のロジック
認証が許可→"/home"にリダイレクト
認証が不許可→子コンポーネントがレンダリング
import { Navigate } from "react-router-dom";
import { useAuth } from "./hooks/useAuth";
type Props = {
children: React.ReactNode;
};
export const PrivateRoute = ({ children }: Props) => {
const check = useAuth();
if (!check.checked) {
return <div>Loading...</div>;
}
if (check.isAuthenticated) {
return <>{children}</>;
}
return <Navigate to="/" />;
};
export const GuestRoute = (props: Props) => {
const { children } = props;
const check = useAuth();
console.log(check);
if (!check.checked) {
return <div>Loading...</div>;
}
if (check.isAuthenticated) {
return <Navigate to="/home" />;
}
return <>{children}</>;
};
ルーティング
最後に、ルーティングをして実装完了です。
まず、<Routes/>で複数の<Route/>をラップします。
<Route/>のpathに一致したときに描画するコンポーネントを指定します。
import { Routes, Route } from "react-router-dom";
import { TopPage } from "./components/pages/TopPage";
import { HomePage } from "./components/pages/HomePage";
import { SignUpPage } from "./components/pages/SignUpPage";
import { LoginPage } from "./components/pages/LoginPage";
import { NotFoundPage } from "./components/pages/NotFoundPage";
import { GuestRoute, PrivateRoute } from "./AuthRouter";
export const App = () => {
return (
<>
<Routes>
<Route
path="/"
//<GuestRoute/>でログイン状態をチェックして、ログイン状態でなければ子コンポーネント(<TopPage/>)が描画される
element={<GuestRoute children={<TopPage />}></GuestRoute>}
/>
<Route
path="/sign-up"
element={<GuestRoute children={<SignUpPage />}></GuestRoute>}
/>
<Route
path="/login"
element={<GuestRoute children={<LoginPage />}></GuestRoute>}
/>
<Route
path="/home"
element={<PrivateRoute children={<HomePage />}></PrivateRoute>}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</>
);
};
最後にトップページをロードして、checkオブジェクトの状態をコンソールに出力してみます。
checkオブジェクトが2回出力されていますが、これは副作用関数によりレンダリングの前後で2回useAuthが実行されているためです。
1回目では認証のチェック前なので「loading...」が表示されて、2回目では認証が完了している(認証が不許可)のでトップページが表示されます。
最後に
フルスタックによるSPAの認証機能の実装は以上なります。
もし誤りがあったり、よりよい実装方法がありましたらコメントいただけると幸いです。
参考記事
Discussion