Passportを使ってUser Login機能を追加する(パスワードハッシュ編)
前回から引き続きUser Login画面を追加する方法を勉強しながら教わったことや調べたことをメモしていく。ユーザーの認証機能にはPassportを使用して、普通のシンプルなユーザー登録とユーザーログインの機能を追加したい。
前回はいよいよPassport.jsの設定をする??といったところで終わったが、結論から言うと今日もPassport.jsの設定には辿りつけなかった。でも勉強すること多いのでしょうがないよね。
ユーザー登録(Sign Up)
まず、handleUserSignup functionの中でやりたいユーザー登録の流れについて文字で書き出してみる。
- ユーザーが入力したusernameとpasswordをrequest bodyから受け取る。
- もしどちらか1つでも欠けていたら400 Bad Requestのエラーを返す
- usernameが既存のものと重複していないかを確認する
- usernameはユニークであってほしいのでデータベースに同じusernameの情報がすでに存在していたらエラーを返す
- Passwordをハッシュ化する
- Passwordはそのままデータベースに保存するのではなく、ハッシュ化して保存する
- ここまで無事にきたらデータベースに新規ユーザーを追加する
- idはnanoidというライブラリーを使ってランダムなidを割り当てる
- nameとusernameという項目をデータベースに用意している。nameはいったんusernameをそのまま登録し、後で変更できるような仕様を考えている
- 最後まで問題なければ200 OKのステータスを返し、エラーがあれば500 Internal Server Errorを返す
これをhandleUserSignupの中で行うのだが、上記でハイライトしたパスワードのハッシュ化について調べる。
パスワードのハッシュ化
そもそもハッシュ化とは何か?ハッシュ化とは、パスワードなどのデータを、「固定の長さの別の文字列に置き変えること」をいい、以下のような特徴がある。
- アルゴリズムを使って、別の文字列に置き換えるので、同じパスワードは毎回同じ文字列に置き換わる
- 暗号化みたいだけど、厳密にはハッシュ化は暗号化とは異なる。ハッシュ化は変換後の文字列からパスワードを導き出すことはできない。
ユーザー認証のためにはログインの際にユーザーが入力したパスワードが、データベースに登録してあるパスワードと一致するかどうかのチェックが欠かせないが、ユーザーのパスワードをそのままデータベースに保存するのは危険である。もしデータベースが漏洩した場合、ユーザーのパスワードが全て知られてしまうからである。ハッシュ後の値から本当のパスワード自体への逆変換はできないという性質がポイントで、セキュリティを高めるというのがパスワードをハッシュ化する目的である。
だとすると、E-mailや名前なんかも全てハッシュ化したほうがより安全で良いのでは?
と思って調べるとケースバイケースのよう。個人情報なのでハッシュ化することでセキュリティー対策にはなるが、認証目的以外に例えばユーザー名を画面に表示したり、メールで何かを通知する、といったことはハッシュする前の正しいデータは分からないためできなくなる。もし復元が必要なのであればハッシュ化ではなく暗号化(復号可能な形式)を使うといいとのこと。なるほどねー
次にどのようにパスワードをハッシュ化するのか?
とりあえずGoogleで”password hash 2024″のように検索してみて、良さそうなライブラリーを使おうかと思ったし、実際そうしてbcryptやArgon2を使っていたほうがコードも簡単だったかもしれないが、Node.jsのcryptoを使ってみることにした。
crypto (Node.js) (https://nodejs.org/api/crypto.html)
cryptoはすでにNodeの中に標準ライブラリとして入っているので、新たにインストールする必要がないというのがメリットらしい。cryptoを使用して、functionを2つ用意する。(verifyPasswordももう作っておく)
hashPasswordは新規ユーザー登録時にhandleUserSignupの中で使用し、
verifyPasswordは既存ユーザーログイン時にhandleUserLoginの中で使用する
import crypto from "node:crypto";
import { promisify } from "node:util";
// PBKDF2 を コールバック関数ではなくPromise で使えるように変換
// 変換するための関数がpromisify
// Promise形式で扱えるようにすると await を使った簡単な書き方が可能になる
const pbkdf2Promise = promisify(crypto.pbkdf2);
export async function hashPassword(password: string) {
// 1. ランダムな salt(16バイト)を作成
const salt = crypto.randomBytes(16).toString("hex");
// 2. パスワードをハッシュ化(PBKDF2 を使用)
// note: iterationを 1000から 10000に変えるとより安全
const hashBuffer = await pbkdf2Promise(password, salt, 1000, 64, "sha512");
// 3. salt とハッシュを結合して返す
const hash = hashBuffer.toString("hex");
return `${salt}:${hash}`;
}
export async function verifyPassword(password: string, storedHash: string) {
// 1. 保存されたデータ(salt とハッシュ)を分割
const [storedSalt, storedHashValue] = storedHash.split(":");
// 2. 入力されたパスワードを同じ方法でハッシュ化
const hashBuffer = await pbkdf2Promise(
password,
storedSalt,
1000,
64,
"sha512"
);
// 3. 計算したハッシュと保存されたハッシュを比較
const computedHash = hashBuffer.toString("hex");
// 単純に文字列として比較する方法で、セキュリティ的にはやや劣るが今回はこれで十分
return computedHash === storedHashValue;
}
より安全なハッシュの比較方法(timingSafeEqual)について
verifyPassword内でのハッシュの比較方法について、上記コードでは単純に文字列が一致しているかを見ているが、timingSafeEqualという比較方法がcryptoに用意されていて、こちらを使うとさらに安全とのこと。今回は単純な比較で進めるが、参考までにメモしておく。31行目のところから以下に書き換えるイメージ。同じかどうかの確認で失敗するタイミングのスピードで何文字目まで合っているかがバレてハッキングされてしまわないようにするためのfunctionらしい。
// 3. 両方のハッシュを Uint8Array に変換して比較
const computedHashBuffer = Buffer.from(hashBuffer.toString("hex"), "utf8");
const storedHashBuffer = Buffer.from(storedHashValue, "utf8");
// 4. タイミングセーフな比較を実行
try {
return crypto.timingSafeEqual(
new Uint8Array(computedHashBuffer),
new Uint8Array(storedHashBuffer)
);
} catch (error) {
return false;
}
新規ユーザー登録のfunctionを書いた。
hashPassword functionを使ったパスワードのハッシュ化やデータベースへの登録はできたように見えたが、エラーメッセージが正しく飛ばないバグの修正や、ユーザーに優しい表示にするためのUIを手直ししていたら、動いていたはずの部分まで動かなくなってしまった(responseの型エラー)。AIやメンターに聞いて最終的にまた動くようになったのがコレ↓
import { db } from "../../db";
import nanoidGenerator from "../../lib/nanoidGenerator";
import { ChallengeAcceptedApiRequest, UserSignupApiRequest } from "../../types";
import { hashPassword } from "./auth";
export const handleUserSignup: ChallengeAcceptedApiRequest<
never, // URL parameters
{ message: string }, // Response body
UserSignupApiRequest // Request body
> = async function (req, res) {
const { username, password } = req.body;
res.setHeader("Content-Type", "application/json");
if (!username || !password) {
return res.status(400).json({
message: "Username and password are required",
error: "VALIDATION_ERROR",
});
}
try {
// Check if the username already exists
const existingUser = await db.user.findUnique({
where: { username },
});
if (existingUser) {
return res.status(400).json({
message: "Username unavailable",
error: "USERNAME_TAKEN",
});
}
// Create a new user in the database
const hashedPassword = await hashPassword(password);
await db.user.create({
data: {
id: nanoidGenerator("U-"),
name: username,
username,
password: hashedPassword,
},
});
return res.status(200).json({ message: "User registered successfully" });
} catch (error) {
return res.status(500).json({
message: "Registration failed",
error: "SERVER_ERROR",
});
}
};
RequestHandlerのTypeを書き換える方法が勉強になった。Responseにいちいち | ApiErrorと書かなくて良くなる。
export type ApiError = {
error: string;
message?: string;
};
export type ChallengeAcceptedApiRequest<
P = undefined,
ResBody = undefined,
ReqBody = undefined
> = RequestHandler<P, ResBody | ApiError, ReqBody>;
登録ができるようになったので、既存ユーザーのログインのためのコードも書いていく。フロントのUIはMUIのテンプレートを拝借。functionもSignupを少し変えていくだけなのでサクサク進んだ。
そして力尽きた・・・
本題のPassportに全然辿り着けない💦
🎲 今年ハマっているボードゲーム 🎲
『SKY TEAM』
2人専用のゲーム。仲良し夫婦におすすめです。
