JWTをexpress, TypeScript, mongooseで実装する
【概要】
- ログインとかの機能が謎だったので、どんなことをやっているのか理解したかった。
- そもそもバックエンドが何をやっているのか謎だったので、とりあえず動くものを作りたかった。
- ユーザー作成、ログイン、ログアウト、ユーザー情報更新だけを実装
- チュートリアルでやった内容のおさらい。
- httpなのは気にしない。
- クライアントもちゃちゃっと作ろうとしたけど、デプロイ後の環境で動くようにするのが面倒で、ローカル以外で動かない。駄作。
開発環境
とりあえずTypeScriptを使うので、諸々の初期設定をする。開発用のコマンドはts-node-devで、セーブするたびにオートリロードが走る。ビルドのことはあまり気にせず、tsファイルをトランスパイルするだけ。
また、dbの接続先などは環境変数として持っておきたいので、env-cmdを入れる。欲しい環境の分だけファイルをわけて、npm scriptで分けるだけでいいので使いやすい
// package.json"scripts": {
"dev": "env-cmd -f ./env/dev.env ts-node-dev --respawn src/index.ts",
"build": "rm -rf dist && tsc",
},
環境変数
// ./denv/dev.envMONGODB_URL=mongodb://127.0.0.1:27017/jwt
JWT_SECRET=
FQDN=http://localhost:3000
親ファイル
// src/index.tsimport { app } from "./app";const port = process.env.PORT ?? 3000;app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
アプリケーションの親ファイル。json形式でクライアントのやりとりをしたいので .json()
を最初に行う。notFoundも最初につくらないと凡ミスするので作る。今回のルートはuserのみ。
// src/app.tsimport express from "express";
import path from "path";
import "./db/index";
import { logErrors, errorHandler } from "./middleware/errorHandler";
import * as user from "./routers/user";
import * as notFound from "./routers/notFound";export const app = express();
app.use(express.json());app.use(user.router);
app.use(notFound.router);app.use(logErrors);
app.use(errorHandler);
dbは設定だけ。あまりよくわかってないが、警告などはこの指定出でなくなった。最初は環境変数にドキュメント名(テーブル名)を含まず、ここでベタ書きしていたが、同じローカルでもテスト用のドキュメントを別にする時に差し支えがあることがわかったので、ドキュメント名も環境変数にした方が便利。
// src/db/index.tsimport mongoose from "mongoose";mongoose.connect(process.env.MONGODB_URL ?? "", {
useNewUrlParser: true,
useCreateIndex: true,
useUnifiedTopology: true,
});
共通エラー処理もやっておかないと実装中にハマる可能性が高くなるので最初にやる。(実際は失敗していたが)何も設定しなくてもexpressがデフォルトで拾ってくれるエラー処理もあるらしいが、非同期処理だとうまく拾えなかったりでちょっとよくわからなかった。とりあえずtry catchで囲めば全部拾ってくれる。
個別のエラー処理は各所で指定しているが、そこで引っかからなかったエラーはここを通過すると思っている。特にカスタマイズ的なことはしていないので、今回は必要なかったかもしれないが、とりあえずこうできるということがわかって勉強になった。
ハマった部分は、引数を4つ指定しないと機能しないという点。
// src/middleware/errorHandler.tsimport type {
ErrorRequestHandler,
Request,
Response,
NextFunction,
} from "express";export const logErrors = (
err: ErrorRequestHandler,
_: Request,
__: Response,
next: NextFunction
) => {
console.error(err);
next(err);
};export const errorHandler = (
_: ErrorRequestHandler,
__: Request,
res: Response,
___: NextFunction
) => {
res.status(500).send("internal server error");
};
userモデルを作る
型指定が難しかった。思い通りに行ってない。こういうので不足してる引数があっても型エラーにならない。そういうものなかもしれない。
const user = new User({ username, email, password });
userモデルのフィールド、関数、スタティック関数を指定。フィールドと関数を分けてるのは別の箇所で型定義をしたいケースが有ったから。
カスタムバリデーションも指定できる。validatorというライブラリが便利。
// src/models/user.tsimport { model, Schema, Document, Model } from "mongoose";
import validator from "validator";export type UserField = {
username: string;
email: string;
password: string;
tokens: { token: string }[];
};export type User = UserField & {
generateAuthToken: () => Promise<string>;
} & Document;export type UserStatics = {
findByCredentials: (params: {
email: string;
password: string;
}) => Promise<User>;
} & Model<User>;const userSchema: Schema<User> = new Schema(
{
username: {
type: String,
required: true,
trim: true,
},
email: {
type: String,
unique: true,
required: true,
trim: true,
validate(value: any) {
if (!validator.isEmail(value)) {
throw new Error("Email is invalid");
}
},
},
password: {
type: String,
required: true,
trim: true,
minLength: 8,
validate(value: any) {
if (!validator.isStrongPassword(value)) {
throw new Error("Password is invalid");
}
},
},
tokens: [{ token: { type: String, required: true } }],
},
{
timestamps: true,
});
ここで謎だったのはmaxLengthの指定。maxLengthを短く指定してしまうと、ハッシュ化された時に差し支えが出る。おそらくDBでバリデーションしないのが正しいと思ったので、特に気にせず。
user作成
- パスワードのハッシュ化
まずはパスワードをハッシュ化する。pre
を使えば、suerSchemaを操作する時に最初にこの処理が走る。ミドルウェア的な感じ。
isModified
でpassword
を変える時、という条件にしているので、必ずハッシュ化される。thisのスコープの関係でアロー関数は使えない。bcryptというライブラリに全部やってもらう。
// src/models/user.ts// hash password
userSchema.pre("save", async function (next) {
const user = this;
if (user.isModified("password")) {
user.password = await bcrypt.hash(user.password, 8);
}
next();
});
- tokenを生成する。
tokenを生成するメソッドをuserモデルに作る。これはjsonwebtokenというライブラリに全部やってもらう。有効期限5分のtokenを発行して、userのtokensフィールドに保存する。
// src/models/user.tsuserSchema.methods.generateAuthToken = async function (): Promise<string> {
const user = this;
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("cannot read secret from environment variables");
}
const token = jwt.sign({ _id: String(user.id) }, secret, {
expiresIn: 5 * 60, // 5 minutes,
});
user.tokens = [...user.tokens, { token }];
await user.save();
return token;
};
userエンドポイントの処理を行うrouters/user.ts
で、新規ユーザーがpostされたときの処理を行う。パスは一応定数化しておく。入れ子にすることを想定した分け方をするべきだったかもしれないけど、今回はこれで良し。
// src/routers/constants.tsexport const routers = {
USER: "/users",
USER_PROFILE: "/users/profile",
LOGIN: "/users/login",
LOGOUT: "/users/logout",
NOT_FOUND: "*",
} as const;
- 最初にpreが実行されるので、パスワードのハッシュ化が行われる
- user.save()でユーザーを登録する
- tokenを発行してdbに保存する
- クライアントにメッセージとtokenを返却する。
上記の流れ。MongoDBのエラー処理をもっといい感じにできそうなものだけど、今回は気にしなかった。(NoSQL使うにしてもAWSやFireBaseのサービスを使うだろうし、MongoDB使う機会殆ど無いだろうから)
// src/routers/user.tsimport { Router } from "express";
import { routers } from "./constants";export const router = Router();router.post(routers.USER, async (req, res, next) => {
const { username, email, password } = req.body;
try {
const user = new User({ username, email, password });
await user.save();
const token = await user.generateAuthToken();
res.status(201).send({ message: "created a user", token });
} catch (err) {
// duplicate email
if (err?.code === 11000) {
res.status(400).send({ "Bad Request": err.message });
}
// invalid field
if (err?.errors?.email || err?.errors?.password) {
res.status(400).send({ "Bad Request": err.message });
}
next(err);
}
});
これで良し。他に謎だったのは
req, res, nextの型指定をどこまでやるべきかが謎だった。
→genericで色々指定出来る。
- resに関しては決まった形式で返却する場合は指定した方がよさそう。(こちらも今回はあまり気にしてない)
- reqに関しては、paramsやqueryは指定した方がよさそう。
- ただbodyに関してはどんな値が来るかはわからないことを考えると、指定するメリットも薄い気がした。どちらかといえばuserモデル側がどの値が必須なのかを教えて欲しい(それも出来てない)
userログイン機能
メールアドレスとパスワードから自分を探すメソッドをuserモデルで作る。
- メールアドレスからuserを探す
- そのユーザーのハッシュ化されたパスワードと、クライアントから送られてきたパスワードを照合する
エラーで何も返してないのは、詳しくエラーを返すと「このメールアドレスは登録されてるんだな。」ってことまでわかってしまったりするため。ルーター側で共通したメッセージを作成する。
// src/models/user.tsuserSchema.statics.findByCredentials = async (params: {
email: string;
password: string;
}) => {
try {
const { email, password } = params;
const user = await User.findOne({ email });
if (!user) {
throw new Error();
}const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
throw new Error();
}return user;
} catch (err) {
return null;
}
};
ルーター側は、メールアドレスとパスワードで認証出来たかを確認し、確認できたらtokenを生成してクライアントに返す。
// src/routers/user.tsrouter.post(ROUTES.LOGIN, async (req, res, next) => {
try {
const { email, password } = req.body;
const user = await User.findByCredentials({ email, password });
if (!user) {
res.status(404).send("cannot found user");
}
const token = await user.generateAuthToken();
res.send({ message: "success login!", token });
} catch (err) {
next(err);
}
});
これでOK。認証できていないときの処理をcatchでやるべきかはよくわからなかった(先程の作成のときは、DBエラーはキャッチで処理した)が、「認証できない」のは通常処理な気がしたのでtryに入れてみた。
ログインしているユーザーの情報を取得する
ユーザー作成、ログインは認証されているかどうかを気にする必要がなかったが、ログインしたユーザーの情報のみが取得できるようにする必要がある。
- 認証のミドルウェアを作成
認証もハッシュ化と同じように、するべきところでは共通して必ず行う必要があるので、ミドルウェアを作る。
今回はuserしか作っていないが、基本的にログインがあるということは、ログインしたユーザーに絡まない情報にはアクセス出来ない必要が出てくる。例えば購入商品のコレクションがあるとしたら、ログイン中のユーザーが購入した商品以外は取得できてはいけない。
そのような場合でもだいたい同じ作りと思われる。
- クライアントからはヘッダーに
Authoriztion: Bearer xxxxxxxx
のようなものを載せてもらう - 文字を加工する
- デコードしてuserのidと、tokenを取得する
- userのidとtokenからユーザーを探す
- tokenが無効ならここでエラーを返す
- 有効ならlocalsにuserとtokenを乗せて、ルーターにわたす。localsとはresponseを返すまでサーバー側で保持する一時的な変数のことのようだ。
これでログインしたユーザー以外は次の処理に進めなくなる。これとリダイレクト処理を組み合わせれば、ログインしている場合は、ログインページにアクセスした時に、ログイン後のページにリダイレクトする。ログインしていない場合はログインページにリダイレクトする。などもできるはず。
// src/middlewares/auth.tsimport type { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { User } from "../models/user";export const auth = async (
req: Request,
res: Response<any, { token: string; user: User }>,
next: NextFunction
) => {
try {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
throw new Error("Authoriztion header is not attached");
}
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("cannot read secret from environment variables");
}
const decoded: any = jwt.verify(token, secret);
const user = await User.findOne({
_id: (decoded as { _id: string; iat: number })._id,
"tokens.token": token,
});
if (!user) {
throw new Error();
}
res.locals.token = token;
res.locals.user = user;
next();
} catch (err) {
res.status(401).send({ error: "You are not authorized" });
}
};
- クライアントに返すべきでない情報をフィルタリングする。
パスワードなどは返さないほうが良い。toJSONメソッドがミドルウェアとなる。今回はpasswordとtokensを返すべきでないので、ここで共通してフィルタリングする。
// src/models/user.tsuserSchema.methods.toJSON = function () {
const user = this;
const { password, tokens, ...userObject } = user.toObject();
return userObject;
};
最後にルーターの処理が実行される 。ミドルウェアは第2引数に入れる。
今回は特に使い道をがない(ログイン出来てるかどうかの確認でしか使っていない)ので、ルータの処理はほぼなにもない。レスポンスを返してあげるくらい。ここで返すレスポンスでパスワードやトークンはフィルタリングされている。
// src/routers/user.tsimport { auth } from "../middleware/auth";router.get(ROUTES.USER_PROFILE, auth, async (_, res, next) => {
try {
res.send(res.locals.user);
} catch (err) {
next(err);
}
});
ログアウト
DBからtokenを削除するのみ。今回は同じアカウントで複数箇所からログイン出来る仕様を想定。
filter関数のところで、空配列を返せば全てのuserがログアウトされる。
// src/routers/user.tsrouter.post(ROUTES.LOGOUT, auth, async (_, res, next) => {
try {
const { user, token: newToken } = res.locals;
user.tokens = user.tokens.filter((token) => token.token !== newToken);await user.save();
res.send({ message: "logout" });
} catch (err) {
next(err);
}
});
ユーザー情報の修正
ここまでやったので修正もする。今回はメールアドレスは変更できない仕様にしてみる。
- リクエストbodyのkeyを抽出
- 変更できる項目を定義
- 全てのkeyが変更できる項目に含まれるかを確認
- 変更して保存
// src/routers/user.tsrouter.patch(ROUTES.USER_PROFILE, auth, async (req, res, next) => {
try {
const { user } = res.locals;
const requestKeys = Object.keys(req.body);
const allowedUpdates: (keyof Pick<UserField, "username" | "password">)[] = [
"username",
"password",
];
const isValidRequest = requestKeys.every((key) =>
((allowedUpdates as any) as string[]).includes(key)
);if (!isValidRequest) {
return res.status(400).send({ error: "Invalid request" });
}
requestKeys.forEach((key) => {
user[key as "username" | "password"] = req.body[key];
});
await user.save();
res.send("user status was updated!");
} catch (err) {
next(err);
}
});
以上。
感想
実際にこのような実装をする機会は少ないだろうが、1つ目のいい機会となった。謎も出てきた。
今回tokenの破棄をちゃんと考えなかったが、1カウントにつき同時接続が1つまでとするなら、「有効なtokenが保存されているならログインできない」という条件を入れれば良さそうだし、「他の誰かがログインしてきたら次にリクエストを投げた時にログアウトさせる」とかもできそう。
次はdockerで開発環境を作る、RDBを使うなどをやってみたい。