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

![5794fd54cdd1c630a06c25c76826d0cc.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/e9e71330-0d66-2154-fdbd-95a9f39f08e6.gif)

## 目標

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

## 前提

- パスワードのハッシュ化・照合に使用するライブラリ「[bcrypt](https://www.npmjs.com/package/bcrypt)」、トークンの生成・照合に使用するライブラリ「[jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken)」がインストールされていること
- `users`テーブルが作成されていること
- `users`テーブルには`email`カラム、`password`カラムが存在すること
- `password`カラムには、暗号化ライブラリ「[bcrypt](https://www.npmjs.com/package/bcrypt)」を使用してハッシュ化されたパスワードが保存されていること

## ログイン API 実装

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

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

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

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

export const router: Router = Router();

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

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

```typescript: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()`」の部分を書き換えてください。

```diff_typescript: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](https://www.npmjs.com/package/bcrypt#to-check-a-password)メソッドを用いてパスワードの照合を行います。
リクエストボディ`password`を第一引数に、前工程で取得したユーザーの`password`（DB に登録されているハッシュ化されたパスワード）を第二引数に渡します。

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

```diff_typescript: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](https://www.npmjs.com/package/jsonwebtoken?activeTab=readme#jwtsignpayload-secretorprivatekey-options-callback)メソッドを使用します。
第一引数には任意のペイロード、第二引数には秘密鍵、第三引数にはオプションを指定します。

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

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

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

```diff_typescript: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 に保存する際のキー名、第二引数に実際の値（生成したトークン）、第三引数に保存する際のオプションを指定します。

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

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

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

```diff_typescript: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 の完成です！👏**

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

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

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

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

まずは、リクエストヘッダーに付与されたCookieを参照することができるように、[cookie-parser](https://www.npmjs.com/package/cookie-parser)をインストールする必要があります。
```bash
npm i cookie-parser
```
インストールが完了したら、Expressアプリケーションに取り込みましょう。Expressの初期化を行なっている箇所で、`use`メソッドを使用してcookie-parserを取り込みます。
```javascript
import express from "express";
import cookieParser from "cookie-parser";

const app = express();

app.use(cookieParser());
```

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

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

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

```diff_typescript: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](https://www.npmjs.com/package//jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback)メソッドを使用します。
第一引数にはチェック対象のトークン、第二引数にはトークン生成時に使用した秘密鍵を指定してください。

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

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

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

```diff_typescript: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 にアクセスすることを防ぐことができます。

```typescript
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`を作成し、以下のように拡張を定義することで、上記の型エラーが解消されます。
```typescript:index.d.ts
import { Express } from "express-serve-static-core";

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

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

## 最終的なコード

### エンドポイント

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

export const router: Router = Router();

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

### ログイン実処理

```typescript: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!🥹");
    }
  }
}
```

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

```typescript: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`型拡張

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

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