多ユーザー認証の実装
このトピックを完了すると:
ユーザー登録中にパスワードを安全にハッシュ化し、bcryptの原理を理解し、ロールベースのアクセス制御を実装できるようになります。
パスワードを平文で保存することの危険性
// 絶対にこれを行わないでください
const users = [
{ username: 'alice', password: 'mySecret123' }
];データベースが侵害された場合、すべてのユーザーのパスワードが公開されます。2012年のLinkedInハッキングと2013年のAdobeハッキングでは、平文(または弱いハッシュ)で保存された数億件のパスワードが公開されました。
パスワードを平文で保存しないでください。 ハッシュを使用して保存してください。
ハッシュとは?
ハッシュは一方向変換です。元のデータからハッシュを生成することは可能ですが、ハッシュから元のデータを復元することはできません。
"mySecret123" → ハッシュ化 → "$2b$10$X7kG..." (可能)
"$2b$10$X7kG..." → ??? → "mySecret123" (不可能)ログイン時に、入力されたパスワードをハッシュ化し、保存されているハッシュと比較します。
ユーザー入力: "mySecret123"
保存されているハッシュ: "$2b$10$X7kG..."
"mySecret123" → ハッシュ化 → "$2b$10$X7kG..." (一致 → 認証成功)
"wrongPass" → ハッシュ化 → "$2b$10$Qm1Y..." (不一致 → 認証失敗)bcrypt — パスワードハッシュの標準
npm install bcryptbcryptが、パスワードに対してSHA-256のような一般的なハッシュ関数よりも優れている理由:
- 遅く動作するように設計されている: ハッカーが1秒間に数十億のハッシュを試すのを防ぎます。
- 自動的にソルトを含める: 同じパスワードでも、毎回異なるハッシュを生成します。
- 調整可能なコスト:
roundsの値で、速度とセキュリティのトレードオフを調整できます。
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 10;
// ハッシュ化(登録時)
const hash = await bcrypt.hash('mySecret123', SALT_ROUNDS);
console.log(hash);
// $2b$10$X7kG.fDlUqVnPq5RvWEYq.ZM3Y4Fs8qD1vSrKLhwEcFjnp2fW9Xm.
// 比較(ログイン時)
const isMatch = await bcrypt.compare('mySecret123', hash);
console.log(isMatch); // true
const isWrong = await bcrypt.compare('wrongPass', hash);
console.log(isWrong); // falseSALT_ROUNDS = 10は、2^10 = 1,024回の反復を意味します。より高い値はより安全ですが、処理が遅くなります。現在の推奨範囲は10〜12です。
登録の実装
const express = require('express');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const users = []; // 実際のアプリケーションでは、データベースを使用します。
app.post('/register', async (req, res) => {
const { username, password, displayName } = req.body;
// 入力検証
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
// 重複の確認
if (users.find(u => u.username === username)) {
return res.status(409).json({ error: 'Username already exists' });
}
// パスワードのハッシュ化
const hashedPassword = await bcrypt.hash(password, 10);
// ユーザーの保存
const newUser = {
id: users.length + 1,
username,
password: hashedPassword,
displayName: displayName || username,
role: 'user',
createdAt: new Date().toISOString()
};
users.push(newUser);
res.status(201).json({
message: 'Registration successful',
user: { id: newUser.id, username: newUser.username }
});
});パスワード(ハッシュを含む)をレスポンスに含めないでください。
Passportとの統合
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
passport.use(new LocalStrategy(
async (username, password, done) => {
const user = users.find(u => u.username === username);
if (!user) {
return done(null, false, { message: 'User not found' });
}
// bcryptを使用して比較
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return done(null, false, { message: 'Wrong password' });
}
return done(null, user);
}
));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
const user = users.find(u => u.id === id);
done(null, user);
});前のトピックのStrategyからの唯一の違いは、user.password !== passwordの代わりにbcrypt.compare()を使用することです。
ロールベースのアクセス制御(RBAC)
function requireRole(...roles) {
return (req, res, next) => {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: 'Login required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// すべてのログインユーザー
app.get('/profile', requireRole('user', 'admin'), (req, res) => {
res.json({ user: req.user.displayName });
});
// 管理者のみ
app.get('/admin/users', requireRole('admin'), (req, res) => {
const safeUsers = users.map(u => ({
id: u.id, username: u.username, role: u.role
}));
res.json(safeUsers);
});
// 管理者 - ロールの変更
app.patch('/admin/users/:id/role', requireRole('admin'), (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
user.role = req.body.role;
res.json({ message: `${user.username} role changed to ${user.role}` });
});requireRole(...roles)は、高階関数(関数を返す関数)です。許可されるロールを引数として受け取り、ユーザーに必要なロールを持っていない場合に403を返すミドルウェアを作成します。
セキュリティチェックリスト
| 項目 | 説明 |
|---|---|
| パスワードハッシュ | bcrypt、SALT_ROUNDS 10+ |
| 最小文字数 | 8文字以上(NISTの推奨) |
| ハッシュをレスポンスに含めない | パスワードフィールド(ハッシュを含む)をAPIレスポンスに含めないでください |
| 一般化されたエラーメッセージ | 「無効なユーザー名またはパスワード」(どちらが間違っているかを明示しない) |
| レート制限 | ログイン試行回数を制限する(ブルートフォース攻撃の防止) |
| HTTPS | パスワードがネットワーク経由で平文で送信されないようにする |
エラーメッセージを一般化する理由:ユーザー名が見つからないと伝えると、攻撃者はアカウントの存在を判断できます。これはユーザー列挙攻撃と呼ばれます。
実際のパターン — アカウントの削除
app.delete('/account', requireRole('user', 'admin'), async (req, res) => {
const valid = await bcrypt.compare(req.body.password, req.user.password);
if (!valid) {
return res.status(401).json({ error: 'Password confirmation failed' });
}
const index = users.findIndex(u => u.id === req.user.id);
users.splice(index, 1);
req.logout((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.json({ message: 'Account deleted' });
});
});アカウントの削除はリスクの高い操作であるため、パスワードの再確認が必要です。削除後、req.logout()でセッションをクリアしてください。
主要なポイント
| 概念 | 概要 |
|---|---|
| ハッシュ | 一方向変換。元のデータからハッシュを生成することは可能ですが、ハッシュから元のデータを復元することはできません |
| bcrypt | パスワード専用のハッシュ。遅く動作し、ソルトを含める |
| ソルト | 同じパスワードでも、異なるハッシュを生成するランダムな値 |
| RBAC | ロールベースのアクセス制御 |
bcrypt.hash() | 登録時にパスワードをハッシュ化する |
bcrypt.compare() | ログイン時に入力と保存されているハッシュを比較する |
認証は、「パスワードが正しいかどうか」だけではなく、パスワードを安全に処理する方法です。パスワードを平文で保存することは災害であり、弱いハッシュ(MD5、SHA-1)は意味がなく、bcryptが現在の標準です。