その他

Node.jsで掲示板アプリのAPIを作成してみよう(ログイン関連API編)

その他
この記事は約19分で読めます。

implの岡本です。
掲示板アプリAPI作成の第三弾です。

今回はユーザー登録、サインイン、サインアウトのAPIを作成していきたいと思います。

サインインの処理の流れについて

サインイン処理の流れは以下の通りで進めていきます。
①サインインAPIでメールアドレスとパスワードを送信する
②認証が成功するとトークンを発行し、クライアントにCookieで送信する

このトークンはリクエストを送信する際に、含めてAPIに送信されます。
トークンを受け取ったAPIはトークンを検証とデコードを行うとメールアドレスが含まれたデータになる
ので、そのメールアドレスがデータベースに存在していれば、登録しているユーザーから送られてきた
データという判断ができます。

ルーティング定義の修正

src/app.tsファイルの内容を以下に書き換えます

// src/app.ts
import express, { Application, Request, Response } from "express";
export const app: Application = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// URLが「http:IPアドレス:ポート番号/api」から始まるならrouter/indexに処理を回す
app.use("/api", require("./router/index"));

// URLが「http:IPアドレス:ポート番号/」ならHello Worldというメッセージのレスポンスを返す
app.get("/", (_req: Request, res: Response) => {
  return res.status(200).send({
    message: "Hello World!",
  });
});

srcディレクトリの直下にserver.tsファイルを作成します

// src/server.ts
const { app } = require("./app");
const PORT = 3000;

try {
  app.listen(PORT, () => {
    console.log(`board_api server running at: http://localhost:${PORT}/`);
  });
} catch (e) {
  if (e instanceof Error) {
    console.error(e.message);
  }
}

ログイン関連のルーティング作成

画像と同じ階層になるように下記を作成します
・routerディレクトリ
・routesディレクトリ
・index.tsファイル
・auth.tsファイル

index.tsファイルとauth.tsファイルの内容は以下の通りです

// src/router/index.ts
import express from "express";
const router = express.Router();

router.use("", require("./routes/auth"));

module.exports = router;
// src/router/routes/auth.ts
import express from "express";
const router = express.Router();
const { AuthController } = require("../../api/controller/AuthController");
const { validateError } = require("../../api/handler/rules/validateError");
const {
  authRegisterRule,
  authLoginRule,
} = require("../../api/handler/rules/auth");

const authContext = new AuthController();

// リクエストのURLと一致すれば、左から順番に処理を渡していきます
// バリテーションエラーがなければ、AuthControllerのregisterメソッドに処理が渡されるなどです
router.post("/signup", authRegisterRule, validateError, authContext.register);
router.post("/signin", authLoginRule, validateError, authContext.login);
router.post("/signout", authContext.logout);

module.exports = router;

authRegisterRuleとauthLoginRuleはリクエストで送信された内容が条件をクリアしているかを見ています
例えばパスワードは5文字以上かつ50文字以内などですね

validateErrorはauthRegisterRuleとauthLoginRuleで条件をクリアできない場合に、そのエラーを返す処理を行います

この処理はコントローラーとモデルを作成した後に作成します

AuthControllerの作成

次はAuthコントローラーを作成していきます
画像と同じ階層になるように下記を作成していきます
apiディレクトリ
controllerディレクトリ
AuthController.tsファイル

// src/api/controller/AuthController.ts
import { Request, Response } from "express";
const { fetchUserPassword, registerUser } = require("../model/Auth");
const { hashingPassword, jwtSign, compareCheck } = require("../service/auth");

export class AuthController {
  //ユーザーの新規登録
  async register(
    req: Request,
    res: Response,
  ): Promise<void> {
    const { name, email, password } = req.body;
    try {
      const hashedPassword = await hashingPassword(password);

      if (!hashedPassword) throw new Error("failed to hash the password");

      const user = await registerUser(name, email, hashedPassword);

      if (!user) throw new Error("this register does not success");

      res.status(201).json({
        message: "this register user is success",
        user,
      });
    } catch (error: any) {
      res.json({
        message: error.message,
      });
    }
  }

  //ログイン処理
  async login(req: Request, res: Response): Promise<void> {
    const { email, password } = req.body;
    try {
      const existedUserPassword = await fetchUserPassword(email);

      if (existedUserPassword === null) throw new Error("this password does not exist");

      const isMatchUser = await compareCheck(password, existedUserPassword);

      if (isMatchUser === false) throw new Error("failed to compare with password");

      const token = await jwtSign(email);

      if (!token) throw new Error("failed to issue token");

      res.cookie("jwtToken", token, { httpOnly: true });
      res.status(201).json({
        message: "login success",
        token,
      });
    } catch (error: any) {
      res.json({
        message: error.message,
      });
    }
  }

  //ログアウト処理
  async logout(req: Request, res: Response): Promise<void> {
    try {
      res.clearCookie("jwtToken", {
        httpOnly: true
      });

      res.status(200).json({ message: "Logout success" });
    } catch (error) {
      throw new Error("Logout failed");
    }
  }
}

パッケージのインストール

bcryptとjsonwebtokenのパッケージをインストールします
docker compose exec node shでDockerコンテナに入った後で下記コマンドを実行してください

yarn add bcrypt
yarn add jsonwebtoken
yarn add bcryptjs
yarn add @types/bcrypt
yarn add @types/jsonwebtoken

コントローラーで使用しているメソッドの作成

src/api/serviceディレクトリを作成し、その中にauth.tsファイルを作成します

// src/api/service/auth.ts
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import dotenv from "dotenv";
dotenv.config();

const jwtSecret = process.env.JWT_SECRET_KEY || "";

// パスワードのハッシュ化
export const hashingPassword = async (password: string): Promise => {
  const hashed = await bcrypt.hash(password, 10);
  return hashed;
};

// リクエストのパスワードが登録されているものか検証
export const compareCheck = async (
  password: string,
  existedUserPassword: string
): Promise => {
  const result = await bcrypt.compare(password, existedUserPassword);
  return result;
};

// ログイン成功時にJWTトークンの発行
export const jwtSign = async (email: string): Promise => {
  const payload = { email: email };
  const options = {
    expiresIn: "24h",
  };
  const token = await jwt.sign(payload, jwtSecret, options);

  return token;
};

JWT_SECRET_KEYの作成

先ほど作成したファイルで使用しているJWT_SECRET_KEYという環境変数を作成します
環境変数に設定する値を以下のコマンドを実行し作成します

openssl rand -hex 32

.envにJWT_SECRET_KEYの環境変数を追記します
先ほどのコマンドで表示されたものをJWT_SECRET_KEYに格納してください

JWT_SECRET_KEY="コマンドで出力された値"

Prismaコンテキストの作成

Prismaを使用してデータベースとの接続を確立するためのコンテキストを作成します
これは次で作成するモデルで使用します

// src/lib/prismaContext.ts
import { PrismaClient } from "@prisma/client";

export const prismaContext = new PrismaClient();

Auth Modelの作成

Authモデルを作成します
src/apiディレクトリの中にmodelディレクトリを作成し、その中にAuth.tsファイルを作成します

// src/api/model/Auth.ts
import { User } from "@prisma/client";
import { prismaContext } from "../../lib/prismaContext";

// ユーザーの新規登録
export const registerUser = async (
  name: string,
  email: string,
  hashedPassword: string
): Promise => {
  const password = hashedPassword;
  const user = await prismaContext.user
    .create({
      data: {
        name,
        email,
        password,
      }
    })
    .catch(() => {
      return null;
    });

  return user;
};

// ユーザーのパスワードを取得する処理
export const fetchUserPassword = async (
  email: string
): Promise => {
  const resultUser = await prismaContext.user
    .findFirstOrThrow({
      where: {
        email: email,
      },
      select: {
        password: true,
      },
    })
    .catch(() => {
      return null;
    });

  const existedUserPassword = resultUser?.password;

  return existedUserPassword;
};

バリテーションの作成とエラーのレスポンスを返す処理の作成

ルーティングの定義で使用していたバリテーションとそのエラーを返す処理を作成していきます
バリテーションのファイルで使用するパッケージをインストールします
node_containerに入り、下記のコマンドを実行してインストールします

yarn add express-validator

画像と同じ階層になるように以下を作成します
・handlerディレクトリ
・rulesディレクトリ
・auth.tsファイル
・validateError.tsファイル

// src/api/handler/rules/auth.ts
import { check, body } from "express-validator";
import { prismaContext } from "../../../lib/prismaContext";

// ユーザー新規登録時のバリテーション作成
export const authRegisterRule = [
  check("name")
    .not()
    .isEmpty()
    .withMessage("name is required")
    .isLength({ min: 3 })
    .withMessage("name mast be at least 3 characters")
    .isLength({ max: 255 })
    .withMessage("name mast be at largest 3 characters"),
  check("email")
    .not()
    .isEmpty()
    .withMessage("email is required")
    .isEmail()
    .withMessage("Invalid email")
    .custom(async (value) => {
      const existedUser = await prismaContext.user.findUnique({
        where: {
          email: value,
        },
      });
      if (existedUser) {
        throw new Error("email already exists");
      }
    }),
  body("password")
    .notEmpty()
    .withMessage("password is required")
    .isLength({ min: 5 })
    .withMessage("password mast be at least 5 characters")
    .isLength({ max: 50 })
    .withMessage("name mast be at largest 50 characters")
];

// ログインのバリテーション作成
export const authLoginRule = [
  body("email")
    .notEmpty()
    .withMessage("email is required")
    .isEmail()
    .withMessage("Invalid email"),
  body("password")
    .not()
    .notEmpty()
    .withMessage("password is required")
    .isLength({ min: 5 })
    .withMessage("password mast be at least 5 characters")
    .isLength({ max: 50 })
    .withMessage("name mast be at largest 50 characters"),
];
// src/api/handler/rules/validateError.ts
import { Request, Response, NextFunction } from "express";
import { validationResult } from "express-validator";

export const validateError = (
  req: Request,
  res: Response,
  next: NextFunction
): any => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(442).json({ errors: errors.array() });
  }
  next();
};

動作確認

まずはサーバーを立ち上げます

ターミナルでアプリのディレクトリで以下のコマンドを実行します
そうするとDockerで作成したnode_containerコンテナに入れます

docker compose exec node sh

下記のコマンドでサーバーを立ち上げます

/home/node/board_api/src # yarn nodemon server.ts
yarn run v1.22.19
$ /home/node/board_api/src/node_modules/.bin/nodemon server.ts
[nodemon] 3.0.1
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: ts,json
[nodemon] starting `ts-node server.ts`
board_api server running at: http://localhost:3000/

Postmanでリクエストを送信してレスポンスの返り方を検証します

ユーザー登録の検証方法

URL: http://localhost:3000/api/signup
HTTPメソッド: POST

Body > JSONの内容

{
    "name": "user",
    "email": "test@gmail.com",
    "password": "test1234"
}

上記の内容を設定した後Sendをクリックすると下のBodyにレスポンスの内容が表示されます

実務ではハッシュ化されているとはいえpasswordをレスポンスに含めるのは
あまりよろしくないかとは思いますが、今回は練習なので温かい目でみてください

サインインの検証

URL: http://localhost:3000/api/signin
HTTPメソッド: POST

Body > JSONの内容

{
    "email": "test@gmail.com",
    "password": "test1234"
}

上記の内容を設定した後Sendをクリックすると下のBodyにレスポンスの内容が表示されます

サインアウトの検証

URL: http://localhost:3000/api/signout
HTTPメソッド: POST
bodyの内容は不要



次回は掲示板関係のAPIを作成していきます