15
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Node.js / Express】シンプルなログインAPIを実装

Last updated at Posted at 2023-11-04

5794fd54cdd1c630a06c25c76826d0cc.gif

目標

  • メールアドレス・パスワードを用いて、ユーザー認証ができる API(/login)の作成
  • ログイン成功時に認証トークンを生成し、Cookie に保存。保存したトークンを用いてログイン状態を保持
  • Cookieに保存されたトークンの整合性をチェックするミドルウェアの作成
  • 認証チェックのミドルウェアを通過後は、Express のリクエストオブジェクトを介してログインしているユーザーの ID を参照できる

前提

  • パスワードのハッシュ化・照合に使用するライブラリ「bcrypt」、トークンの生成・照合に使用するライブラリ「jsonwebtoken」がインストールされていること
  • usersテーブルが作成されていること
  • usersテーブルにはemailカラム、passwordカラムが存在すること
  • passwordカラムには、暗号化ライブラリ「bcrypt」を使用してハッシュ化されたパスワードが保存されていること

ログイン API 実装

1. エンドポイント・コントローラーの定義

HTTP メソッドはPOST、エンドポイントは/loginとします。
リクエストボディで、メールアドレス(email)、パスワード(password)を受け取りましょう。

エンドポイントの定義をrouter.tsにまとめ、実処理はコントローラー(AuthsController.ts)に記載する形にしています。必要に応じてディレクトリ構成を変更してください。

router.ts
import { Router } from "express";
import { AuthsController } from "./controllers/AuthsController";

export const router: Router = Router();

// ログイン
router.post("/login", AuthsController.login);

コントローラーを作成しましょう。ログイン処理で例外が投げられた場合は、ステータス 401 を返してあげて、処理を終了するような形にしたいと思います。

controllers/AuthsController.ts
import { NextFunction, Request, Response } from "express";

export class AuthsController {
  static async login(
    request: Request<any, any, { email: string; password: string }>,
    response: Response<string>,
    next: NextFunction,
  ): Promise<void> {
    try {
      // ログイン処理
    } catch {
      response.status(401).send("Login failed!🥹");
    }
  }
}

2. ログインユーザーの取得

まずは E メールを元にユーザーが存在するかどうかチェックします。
リクエストボディemailと、E メールが一致するデータを取得し、ここで取得結果が無い(該当ユーザーが存在しない)場合は例外を投げます。

テーブルからデータを取得する処理は、使用する ORM によって異なります。使用している ORM に沿って「UserRepository.findOne()」の部分を書き換えてください。

controllers/AuthsController.ts
import { NextFunction, Request, Response } from "express";

export class AuthsController {
  static async login(
    request: Request<any, any, { email: string; password: string }>,
    response: Response<string>,
    next: NextFunction,
  ): Promise<void> {
    try {

+     const { email, password } = request.body;
+
+     const loggingInUser = await UserRepository.findOne({
+       where: {
+         email: payload.email,
+       },
+     });
+
+     if (!loggingInUser) throw new Error();

    } catch {
      response.status(401).send("Login failed!🥹");
    }
  }
}

3. パスワードの照合

次に、bcrypt のcompareメソッドを用いてパスワードの照合を行います。
リクエストボディpasswordを第一引数に、前工程で取得したユーザーのpassword(DB に登録されているハッシュ化されたパスワード)を第二引数に渡します。

パスワードが不正な場合は、compare メソッドはfalseを返すので、例外を投げましょう。

controllers/AuthsController.ts
import { NextFunction, Request, Response } from "express";
+ import * as bcrypt from "bcrypt";

export class AuthsController {
  static async login(
    request: Request<any, any, { email: string; password: string }>,
    response: Response<string>,
    next: NextFunction,
  ): Promise<void> {
    try {
      const { email, password } = request.body;

      const loggingInUser = await UserRepository.findOne({
        where: {
          email: payload.email,
        },
      });

      if (!loggingInUser) throw new Error();

+     const isPasswordCorrect = await bcrypt.compare(password, loggingInUser.password);
+
+     if (!isPasswordCorrect) throw new Error();

    } catch {
      response.status(401).send("Login failed!🥹");
    }
  }
}

4. 認証トークンの生成

パスワードチェックを問題なくパスできたら、認証トークンを作成してあげましょう。
ここで生成されたトークンを、Cookieに保持することで、ユーザーの認証状況を保持します。

トークンの生成には、jsonwebtoken のsignメソッドを使用します。
第一引数には任意のペイロード、第二引数には秘密鍵、第三引数にはオプションを指定します。

ペイロードに入れる値は自由です。今回は、ログイン中のユーザー ID を持たせてあげましょう。

秘密鍵には任意の文字列を指定します。トークンの生成・デコードに使用する大事な値なので、実際に運用する際には環境変数にしてあげるとよいです。

オプションでは、トークンの有効期限(1 時間)を指定しましょう。他にも、使用する暗号化アルゴリズムなども設定することができるので、必要に応じて設定してください。

controllers/AuthsController.ts
import { NextFunction, Request, Response } from "express";
import * as bcrypt from "bcrypt";
+ import { sign } from "jsonwebtoken";

export class AuthsController {
  static async login(
    request: Request<any, any, { email: string; password: string }>,
    response: Response<string>,
    next: NextFunction,
  ): Promise<void> {
    try {
      const { email, password } = request.body;

      const loggingInUser = await UserRepository.findOne({
        where: {
          email: payload.email,
        },
      });

      if (!loggingInUser) throw new Error();

      const isPasswordCorrect = await bcrypt.compare(password, loggingInUser.password);

      if (!isPasswordCorrect) throw new Error();

+     const jwtPayload = {
+       id: loggingInUser.id,
+     };
+
+     const token = sign(jwtPayload, "JWT_SECRET_KEY", {
+       expiresIn: "1h",
+     });

    } catch {
      response.status(401).send("Login failed!🥹");
    }
  }
}

5. Cookie の設定・レスポンス

いよいよ仕上げです。生成されたトークンを Cookie に設定し、レスポンスとしてログインユーザーの ID を返してあげましょう。

Express リクエストオブジェクトのcookieメソッドを使用することで、Cookie を設定することができます。第一引数に Cookie に保存する際のキー名、第二引数に実際の値(生成したトークン)、第三引数に保存する際のオプションを指定します。

この際のポイントとして、オプションhttpOnlysecureをそれぞれtrueにしておくことを忘れないでください。

httpOnlyは、JavaScript から Cookie のトークンにアクセスすることができるオプションです。これを有効にすることで、JavaScript を使用してクッキーの内容を盗み出すこと(XSS 攻撃)から保護することができます。

secureは、セキュア接続 (HTTPS) の場合にのみ Cookie の値が送信されるようにできるオプションです。これを有効にすることで、Cookie のやり取りをセキュアな通信でのみ可能にでき、データの送信中に Cookie の値(トークン)が傍受されるのを防ぎ、盗聴や中間者攻撃から保護することができます。

controllers/AuthsController.ts
import { NextFunction, Request, Response } from "express";
import * as bcrypt from "bcrypt";
import { sign } from "jsonwebtoken";

export class AuthsController {
  static async login(
    request: Request<any, any, { email: string; password: string }>,
    response: Response<string>,
    next: NextFunction,
  ): Promise<void> {
    try {
      const { email, password } = request.body;

      const loggingInUser = await this.userRepository.findOne({
        where: {
          email: payload.email,
        },
      });

      if (!loggingInUser) throw new Error();

      const isPasswordCorrect = await bcrypt.compare(password, loggingInUser.password);

      if (!isPasswordCorrect) throw new Error();

      const jwtPayload = {
        id: loggingInUser.id,
      };

      const token = sign(jwtPayload, "JWT_SECRET_KEY", {
        expiresIn: "1h",
      });

+     response.cookie("token", token, {
+       httpOnly: true,
+       secure: true,
+     });
+
+     response.send(loggingInUser.id);
    } catch {
      response.status(401).send("Login failed!🥹");
    }
  }
}

ログイン API の完成です!👏

HTTP クライアントで Axios を使用している場合は、Cookie の値をリクエストに付与する為、Axios のwithCredentialsオプションを有効にする必要があります。(参考: https://stackoverflow.com/a/43178070)

認証チェックミドルウェアの作成

Cookie に保存されたトークンの整合性をチェックするミドルウェアauthCheckを作りましょう。

1. Cookie からトークンを取得

まずは、リクエストヘッダーに付与されたCookieを参照することができるように、cookie-parserをインストールする必要があります。

npm i cookie-parser

インストールが完了したら、Expressアプリケーションに取り込みましょう。Expressの初期化を行なっている箇所で、useメソッドを使用してcookie-parserを取り込みます。

import express from "express";
import cookieParser from "cookie-parser";

const app = express();

app.use(cookieParser());

これでリクエストオブジェクトのcookiesプロパティから、Cookie の値を参照することができます。今回は、tokenという名前で認証トークンを保存しているので、cookiesからtokenを取得します。

tokenが存在しない場合は不正なリクエストなので、例外を投げましょう。

チェック処理の中で例外が投げられた場合は、Cookie からtokenを削除し、エラーレスポンスを返します。

middlewares/authCheck.ts
import { JwtPayload } from "@/types/auths";
import { NextFunction, Request, Response } from "express";
import { verify } from "jsonwebtoken";

export const authCheck = (
  request: Request,
  response: Response,
  next: NextFunction,
) => {
  try {
    const { token } = request.cookies;

    if (!token) throw new Error();

    next();
  } catch {
    response.clearCookie("token");
    response.status(401).send("Authentication failed!🥹");
  }
};

2. 認証チェック・リクエストオブジェクトにログインユーザー ID を付与

トークンが取得できたら、トークンの整合性をチェックします。jsonwebtoken のverifyメソッドを使用します。
第一引数にはチェック対象のトークン、第二引数にはトークン生成時に使用した秘密鍵を指定してください。

整合性チェックを問題なくパスした場合は、verify メソッドはトークンから取得したペイロードの値を返します。

リクエストオブジェクトのloggedInUserIdに、ログインしているユーザーの ID を付与してあげましょう。
これにより、後続処理ではrequest.loggedInUserIdにアクセスすることで、どのユーザーがログインしているかを参照することができます。「ログインしているユーザーの〜」みたいな処理が多いので、何かと役に立ちます。

トークンが不正な場合は、verify メソッドが例外を投げます。catch の中に、リクエストオブジェクトのloggedInUserIdを空にする処理を追加しておきましょう。

middlewares/authCheck.ts
import { JwtPayload } from "@/types/auths";
import { NextFunction, Request, Response } from "express";
import { verify } from "jsonwebtoken";

export const authCheck = (
  request: Request,
  response: Response,
  next: NextFunction,
) => {
  try {
    const { token } = request.cookies;

    if (!token) throw new Error();

+   const { id } = verify(token, "JWT_SECRET_KEY") as JwtPayload;
+   request.loggedInUserId = id;

    next();
  } catch {
+   request.loggingInUserId = "";
    response.clearCookie("token");
    response.status(401).send("Authentication failed!🥹");
  }
};

作成したミドルウェアを必要に応じて以下のようにルートに適用すると、コントローラーに移る前に Cookie に保存されたトークンの整合性をチェックします。これにより、不正なユーザーが API にアクセスすることを防ぐことができます。

router.post("/users", authCheck, UsersController.fetchById);

3. Request型の拡張

現状の実装では、loggingInUserIdを付与している箇所で、以下のような型エラーが出ていると思います。

Property 'loggingInUserId' does not exist on type 'Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>'.

Express の Request 型にはloggedInUserIdというプロパティは存在したい為、このようなエラーが出ています。

これは Request 型を拡張することで回避することができます。
プロジェクトのルートディレクトリに型定義ファイルindex.d.tsを作成し、以下のように拡張を定義することで、上記の型エラーが解消されます。

index.d.ts
import { Express } from "express-serve-static-core";

declare module "express-serve-static-core" {
  interface Request {
    loggedInUserId: string;
  }
}

認証チェックミドルウェアの完成です!👏

最終的なコード

エンドポイント

router.ts
import { Router } from "express";
import { AuthsController } from "./controllers/AuthsController";

export const router: Router = Router();

// ログイン
router.post("/login", AuthsController.login);

ログイン実処理

controllers/AuthsController.ts
import { NextFunction, Request, Response } from "express";
import * as bcrypt from "bcrypt";
import { sign } from "jsonwebtoken";

export class AuthsController {
  static async login(
    request: Request<any, any, { email: string; password: string }>,
    response: Response<string>,
    next: NextFunction,
  ): Promise<void> {
    try {
      const { email, password } = request.body;

      const loggingInUser = await this.userRepository.findOne({
        where: {
          email: payload.email,
        },
      });

      if (!loggingInUser) throw new Error();

      const isPasswordCorrect = await bcrypt.compare(password, loggingInUser.password);

      if (!isPasswordCorrect) throw new Error();

      const jwtPayload = {
        id: loggingInUser.id,
        email: loggingInUser.email,
      };

      const token = sign(jwtPayload, "JWT_SECRET_KEY", {
        expiresIn: "1h",
      });

      response.cookie("token", token, {
        httpOnly: true,
        secure: true,
      });

      response.send(loggingInUser.id);
    } catch {
      response.status(401).send("Login failed!🥹");
    }
  }
}

認証チェックミドルウェア

middlewares/authCheck.ts
import { JwtPayload } from "@/types/auths";
import { NextFunction, Request, Response } from "express";
import { verify } from "jsonwebtoken";

export const authCheck = (
  request: Request,
  response: Response,
  next: NextFunction,
) => {
  try {
    const { token } = request.cookies;

    if (!token) throw new Error();

    const jwtSecretKey = process.env.JWT_SECRET_KEY;
    if (!jwtSecretKey) throw new Error();

    const { id } = verify(token, jwtSecretKey) as JwtPayload;

    request.loggedInUserId = id;

    next();
  } catch {
    request.loggingInUserId = "";
    response.clearCookie("token");
    response.status(401).send("Authentication failed!🥹");
  }
};

Request型拡張

index.d.ts
import { Express } from "express-serve-static-core";

declare module "express-serve-static-core" {
  interface Request {
    loggedInUserId: string;
  }
}
15
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?