目標
- メールアドレス・パスワードを用いて、ユーザー認証ができる 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
)に記載する形にしています。必要に応じてディレクトリ構成を変更してください。
import { Router } from "express";
import { AuthsController } from "./controllers/AuthsController";
export const router: Router = Router();
// ログイン
router.post("/login", AuthsController.login);
コントローラーを作成しましょう。ログイン処理で例外が投げられた場合は、ステータス 401 を返してあげて、処理を終了するような形にしたいと思います。
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()
」の部分を書き換えてください。
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
を返すので、例外を投げましょう。
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 時間)を指定しましょう。他にも、使用する暗号化アルゴリズムなども設定することができるので、必要に応じて設定してください。
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 の値(トークン)が傍受されるのを防ぎ、盗聴や中間者攻撃から保護することができます。
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
を削除し、エラーレスポンスを返します。
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
を空にする処理を追加しておきましょう。
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
を作成し、以下のように拡張を定義することで、上記の型エラーが解消されます。
import { Express } from "express-serve-static-core";
declare module "express-serve-static-core" {
interface Request {
loggedInUserId: string;
}
}
認証チェックミドルウェアの完成です!👏
最終的なコード
エンドポイント
import { Router } from "express";
import { AuthsController } from "./controllers/AuthsController";
export const router: Router = Router();
// ログイン
router.post("/login", AuthsController.login);
ログイン実処理
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!🥹");
}
}
}
認証チェックミドルウェア
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
型拡張
import { Express } from "express-serve-static-core";
declare module "express-serve-static-core" {
interface Request {
loggedInUserId: string;
}
}