404 Not Found

404 Not Found


nginx

「Node.js の認証とセキュリティ:JWT、bcrypt、および実用的なセキュリティ対策」

1. 事例:保護されていないAPI

アリスは2週間かけてユーザー管理APIのデプロイを行ったが、その翌日、データベースが削除されていたことに気づいた。彼女のAPIには認証機能がなく、誰でも直接呼び出すことができたからだDELETE /api/users。この教訓を活かし、彼女はJWT認証を実装した。ユーザーはまずログインしてトークンを取得する必要があり、その後、保護されたルートにアクセスするにはリクエストにそのトークンを含めなければならない。同時に、管理者操作用のロールベースのミドルウェアを追加し、トークンを持っているユーザーであっても危険な操作を実行できないようにしました。1週間後、彼女はHelmetを使用してレスポンスヘッダーにセキュリティ対策を施し、APIはついに要塞のように堅固なものとなりました。


2. 基本概念

(1) 認証と認可の違い

(2) JWTの原則の概要

(3) bcrypt によるパスワードハッシュ化


3. 技術的な詳細

(1) JWTの構造と動作原理

JWTは、.で連結された3つの部分で構成されています:

TEXT
Header.Payload.Signature
セクション 内容 説明
ヘッダー { "alg": "HS256", "typ": "JWT" } アルゴリズムとデータ構造
積荷 { "userId": 1, "role": "admin", "exp": ... } カスタム申告 + 標準申告
署名 HMACSHA256(base64(header) + "." + base64(payload), secret) 改ざん防止署名

ワークフロー:

  1. ユーザーがログインすると、サーバーが認証情報を確認する
  2. 認証が成功した場合は、JWTを発行し、それをクライアントに返す
  3. クライアントはトークンをlocalStorageまたはクッキーに保存する
  4. それ以降の要求では、Authorization: Bearer <token>ヘッダーに以下の内容を含めます:
  5. サーバーサイドのミドルウェアが署名と有効期限を確認する

▶ 例:JWTの発行と検証

JAVASCRIPT
const jwt = require('jsonwebtoken');

const SECRET = 'my_super_secret_key';

const token = jwt.sign(
  { userId: 42, role: 'admin' },
  SECRET,
  { expiresIn: '2h' }
);

console.log('Token:', token);

const decoded = jwt.verify(token, SECRET);
console.log('Decoded:', decoded);

▶ 例:JWTをデコードしてペイロードを表示する(署名の検証は行わない)

JAVASCRIPT
const decoded = jwt.decode(token, { complete: true });
console.log('Header:', decoded.header);
console.log('Payload:', decoded.payload);

(2) bcrypt のパラメータと使用方法

パラメータ 推奨値 説明
saltRounds 10-12 ラウンド数。数値が大きいほどセキュリティは高くなりますが、処理速度は低下します
パスワードの長さ 8文字以上 フロントエンドで検証
アルゴリズム Blowfish bcrypt の基盤となるアルゴリズム

▶ 例:パスワードのハッシュ化と検証

JAVASCRIPT
const bcrypt = require('bcrypt');

async function hashPassword(plainPassword) {
  const saltRounds = 10;
  const hash = await bcrypt.hash(plainPassword, saltRounds);
  console.log('Hash:', hash);
  return hash;
}

async function verifyPassword(plainPassword, hash) {
  const match = await bcrypt.compare(plainPassword, hash);
  console.log('Match:', match);
  return match;
}

(async () => {
  const hash = await hashPassword('MyPassword123');
  await verifyPassword('MyPassword123', hash);
  await verifyPassword('WrongPassword', hash);
})();

(3) HTTPセキュリティヘッダーとHelmet

Helmetは、HTTPレスポンスヘッダーを設定することでセキュリティを強化します:

セキュリティヘッダー 目的 デフォルトで有効
Content-Security-Policy XSSを防止し、リソースの読み込み元を制限する いいえ(手動での設定が必要)
X-Frame-Options クリックジャッキングの防止 はい
X-Content-Type-Options MIMEスニッフィングの防止 はい
Strict-Transport-Security HTTPSを強制 はい
X-XSS-Protection ブラウザによるXSSフィルタリング 非推奨(Helmetではデフォルトで有効化されなくなりました)

▶ 例:Helmetの統合

JAVASCRIPT
const express = require('express');
const helmet = require('helmet');

const app = express();

app.use(helmet());

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'"],
    styleSrc: ["'self'", "'unsafe-inline'"],
  }
}));

app.get('/', (req, res) => {
  res.send('Secured with helmet!');
});

app.listen(3000);

(4) 一般的なセキュリティ上の脅威と対策

脅威 仕組み 対策
SQLインジェクション ユーザー入力を連結して悪意のあるSQLを構築する パラメータ化されたクエリ / ORM
XSS(クロスサイトスクリプティング) ページへの悪意のあるスクリプトの注入 出力のエスケープ / CSP / Helmet
CSRF(クロスサイト・リクエスト・フォージェリー) 認証済みユーザーになりすましてリクエストを送信すること CSRFトークン/SameSiteクッキー
ブルートフォース攻撃 パスワードの繰り返し入力 レート制限 / 高いbcryptラウンド数 / アカウントのロックアウト
マン・イン・ザ・ミドル攻撃 通信データの傍受 HTTPS / HSTS

▶ 例:SQLインジェクションの防止(パラメータ化されたクエリ)

JAVASCRIPT
const { Pool } = require('pg');
const pool = new Pool();

app.post('/login', async (req, res) => {
  const { username, password } = req.body;

  const result = await pool.query(
    'SELECT * FROM users WHERE username = $1',
    [username]
  );

  if (result.rows.length === 0) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const user = result.rows[0];
  const match = await bcrypt.compare(password, user.password_hash);
  if (!match) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const token = jwt.sign({ userId: user.id, role: user.role }, SECRET, { expiresIn: '2h' });
  res.json({ token });
});

▶ 例:XSSの防止(出力のエスケープ)

JAVASCRIPT
function escapeHtml(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}

const userInput = '<script>alert("xss")</script>';
console.log(escapeHtml(userInput));

▶ 例:CSRF対策(csurfミドルウェア)

JAVASCRIPT
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });

app.get('/form', csrfProtection, (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

app.post('/submit', csrfProtection, (req, res) => {
  res.json({ message: 'Form submitted successfully' });
});

4. 実践演習

(1) 登録API

▶ 例:ユーザー登録API

JAVASCRIPT
app.post('/api/register', async (req, res) => {
  const { username, password, role } = req.body;

  if (!username || !password) {
    return res.status(400).json({ error: 'Username and password are required' });
  }

  if (password.length < 8) {
    return res.status(400).json({ error: 'Password must be at least 8 characters' });
  }

  const existingUser = await pool.query(
    'SELECT id FROM users WHERE username = $1',
    [username]
  );
  if (existingUser.rows.length > 0) {
    return res.status(409).json({ error: 'Username already exists' });
  }

  const saltRounds = 10;
  const passwordHash = await bcrypt.hash(password, saltRounds);

  const result = await pool.query(
    'INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3) RETURNING id, username, role',
    [username, passwordHash, role || 'user']
  );

  const user = result.rows[0];
  const token = jwt.sign(
    { userId: user.id, role: user.role },
    SECRET,
    { expiresIn: '2h' }
  );

  res.status(201).json({ user: { id: user.id, username: user.username, role: user.role }, token });
});

(2) ログインAPI

▶ 例:ユーザーログインAPI

JAVASCRIPT
app.post('/api/login', async (req, res) => {
  const { username, password } = req.body;

  if (!username || !password) {
    return res.status(400).json({ error: 'Username and password are required' });
  }

  const result = await pool.query(
    'SELECT id, username, password_hash, role FROM users WHERE username = $1',
    [username]
  );

  if (result.rows.length === 0) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const user = result.rows[0];
  const match = await bcrypt.compare(password, user.password_hash);

  if (!match) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const token = jwt.sign(
    { userId: user.id, username: user.username, role: user.role },
    SECRET,
    { expiresIn: '2h' }
  );

  res.json({
    user: { id: user.id, username: user.username, role: user.role },
    token
  });
});

(3) JWT認証ミドルウェア

▶ 例:authenticate ミドルウェア

JAVASCRIPT
function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Access denied. No token provided.' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(403).json({ error: 'Invalid token' });
  }
}

(4) ロールベースのアクセス制御ミドルウェア

▶ 例:「authorize」ロールのミドルウェア

JAVASCRIPT
function authorize(...roles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }

    next();
  };
}

app.get('/api/profile', authenticate, (req, res) => {
  res.json({ user: req.user });
});

app.delete('/api/users/:id', authenticate, authorize('admin'), async (req, res) => {
  await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
  res.json({ message: 'User deleted' });
});

app.get('/api/admin/dashboard', authenticate, authorize('admin'), (req, res) => {
  res.json({ message: 'Welcome to admin dashboard' });
});

5. 包括的な例:完全な認証システム

TEXT
project/
├── server.js
├── middleware/
│   ├── auth.js
│   └── role.js
├── routes/
│   ├── auth.js
│   └── users.js
└── package.json

middleware/auth.js

JAVASCRIPT
const jwt = require('jsonwebtoken');

const SECRET = process.env.JWT_SECRET || 'fallback_dev_secret';

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Access denied. No token provided.' });
  }

  const token = authHeader.split(' ')[1];
  try {
    const decoded = jwt.verify(token, SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired. Please login again.' });
    }
    return res.status(403).json({ error: 'Invalid token.' });
  }
}

module.exports = { authenticate, SECRET };

middleware/role.js

JAVASCRIPT
function authorize(...roles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required.' });
    }
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Forbidden. Insufficient permissions.' });
    }
    next();
  };
}

module.exports = { authorize };

routes/auth.js

JAVASCRIPT
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { authenticate, SECRET } = require('../middleware/auth');

const router = express.Router();

const users = [];

router.post('/register', async (req, res) => {
  const { username, password, role } = 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.' });
  }
  const exists = users.find(u => u.username === username);
  if (exists) {
    return res.status(409).json({ error: 'Username already exists.' });
  }

  const saltRounds = 10;
  const passwordHash = await bcrypt.hash(password, saltRounds);
  const newUser = {
    id: users.length + 1,
    username,
    passwordHash,
    role: role || 'user'
  };
  users.push(newUser);

  const token = jwt.sign(
    { userId: newUser.id, username: newUser.username, role: newUser.role },
    SECRET,
    { expiresIn: '2h' }
  );

  res.status(201).json({
    user: { id: newUser.id, username: newUser.username, role: newUser.role },
    token
  });
});

router.post('/login', async (req, res) => {
  const { username, password } = req.body;
  if (!username || !password) {
    return res.status(400).json({ error: 'Username and password required.' });
  }

  const user = users.find(u => u.username === username);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials.' });
  }

  const match = await bcrypt.compare(password, user.passwordHash);
  if (!match) {
    return res.status(401).json({ error: 'Invalid credentials.' });
  }

  const token = jwt.sign(
    { userId: user.id, username: user.username, role: user.role },
    SECRET,
    { expiresIn: '2h' }
  );

  res.json({
    user: { id: user.id, username: user.username, role: user.role },
    token
  });
});

router.get('/profile', authenticate, (req, res) => {
  const user = users.find(u => u.id === req.user.userId);
  if (!user) {
    return res.status(404).json({ error: 'User not found.' });
  }
  res.json({ id: user.id, username: user.username, role: user.role });
});

module.exports = router;

routes/users.js

JAVASCRIPT
const express = require('express');
const { authenticate } = require('../middleware/auth');
const { authorize } = require('../middleware/role');

const router = express.Router();

const users = [];

router.get('/', authenticate, authorize('admin'), (req, res) => {
  const safeList = users.map(u => ({ id: u.id, username: u.username, role: u.role }));
  res.json(safeList);
});

router.delete('/:id', authenticate, authorize('admin'), (req, res) => {
  const index = users.findIndex(u => u.id === parseInt(req.params.id));
  if (index === -1) {
    return res.status(404).json({ error: 'User not found.' });
  }
  users.splice(index, 1);
  res.json({ message: 'User deleted.' });
});

module.exports = router;

server.js

JAVASCRIPT
const express = require('express');
const helmet = require('helmet');
const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');

const app = express();

app.use(helmet());
app.use(express.json());

app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error.' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

テストプロセス

BASH
# Register
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"SecurePass123","role":"admin"}'

# Log In
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"SecurePass123"}'

# Accessing Protected Routes
curl http://localhost:3000/api/auth/profile \
  -H "Authorization: Bearer <your_token>"

# Administrator Actions
curl -X DELETE http://localhost:3000/api/users/2 \
  -H "Authorization: Bearer <admin_token>"
TEXT
# Registration Response
{
  "user": { "id": 1, "username": "alice", "role": "admin" },
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

# Login Response
{
  "user": { "id": 1, "username": "alice", "role": "admin" },
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

# Insufficient Permissions Response
{ "error": "Forbidden. Insufficient permissions." }

6. JWT認証のフローチャート

100%
sequenceDiagram
    participant Client as Client
    participant Server as Server-side
    participant DB as Database

    Client->>Server: POST /api/login {username, password}
    Server->>DB: Query User Records
    DB-->>Server: Return User Data
    Server->>Server: bcrypt.compare() Confirm Password
    alt The password is correct
        Server->>Server: jwt.sign() Issued Token
        Server-->>Client: Back { token }
        Client->>Client: Storage Token
        Client->>Server: GET /api/profile<br/>Authorization: Bearer <token>
        Server->>Server: jwt.verify() Verification Token
        alt Token Valid
            Server-->>Client: 200 Return User Data
        else Token Invalid or Expired
            Server-->>Client: 401/403 Access Denied
        end
    else Incorrect password
        Server-->>Client: 401 Invalid credentials
    end

7. 比較と参考文献

(1) JWT 対 セッション 対 OAuth

ディメンション JWT セッション OAuth 2.0
保存場所 クライアント サーバー サーバー + クライアント
ステートレス はい いいえ いいえ
スケーラビリティ 分散システムをネイティブにサポート 共有セッションストレージが必要 認証サーバーが必要
ユースケース API認証、マイクロサービス 従来のWebアプリケーション サードパーティログイン
セキュリティリスク 取り消せないトークンの漏洩 セッションハイジャック リプレイ攻撃
複雑さ

(2) bcrypt パラメータの選択

saltRounds おおよその所要時間 活用例
8 約40 ms 開発およびテスト
10 約160 ms 本番環境での使用を推奨
12 約640 ms 高い安全要件
14 約2.5秒 極めて安全なシナリオ

(3) 一般的なセキュリティ上の脅威と対策

脅威 攻撃例 防御方法 ツール/ライブラリ
SQLインジェクション ' OR 1=1 -- パラメータ化クエリ pg/mysql2
XSS <script>document.cookie</script> エスケープ + CSP helmet/xss
CSRF フォーム送信の偽装 CSRFトークン + SameSite csurf
ブルートフォース攻撃 パスワードに対する辞書攻撃 レート制限 + 高ラウンド数のbcrypt express-rate-limit
マン・イン・ザ・ミドル攻撃 HTTPトラフィックのスニッフィング HTTPS + HSTS helmet/Let's Encrypt

(4) JWTペイロードの共通フィールド

項目 正式名称 説明
iss 発行体 発行体識別子
sub 件名 件名(通常はユーザーID)
aud 視聴者 受信者ID
exp 有効期限 有効期限時刻 (Unix タイムスタンプ)
iat 発行場所 発行日
jti JWT ID 一意の識別子(リプレイ防止)
userId カスタム ビジネスユーザーID
役割 カスタム ユーザー役割

❓ よくある質問

Q JWTはどこに保存されますか?
A XSS攻撃による読み取りを防ぐため、httpOnlyクッキーに保存することを推奨します。localStorageは便利ですが、XSSによる盗み出しのリスクがあるため、CSPと組み合わせて使用する必要があります。
Q トークンの有効期限が切れた場合はどうすればよいですか?
A 一般的な「2トークン方式」では、有効期間が短いアクセストークンと、有効期間が長いリフレッシュトークンを併用します。アクセストークンの有効期限が切れたら、リフレッシュトークンを使用して新しいトークンを取得します。リフレッシュトークンはデータベースに保存されるため、事前に無効化することができます。
Q なぜbcryptはそれほど遅いのですか?
A bcryptのコスト係数はハッシュ処理のラウンド数を制御しており、ラウンドが1つ増えるごとに処理時間が2倍になります。この「遅さ」は設計上の仕様であり、ブルートフォース攻撃を行う際に、パスワードを1つ推測するたびに多大な計算コストがかかるようにするためです。
Q HTTPSは必須ですか?
A 本番環境では必須です。HTTPでは、トークンやパスワードが平文で送信されるため、中間者によって傍受される可能性があります。HTTPSによる暗号化通信は、最低限のセキュリティ要件です。
Q ブルートフォース攻撃はどのように防げますか?
A 多層防御です。具体的には、bcryptを用いてラウンド数を増やすことで1回の試行あたりの計算コストを高め、rate-limitでアクセス制限を行い、アカウントロック機能に加え、ログイン失敗後の応答遅延を設けることです。
Q JWTは事前の措置として無効化できますか?
A JWTは本質的にステートレスであるため、事前の措置として無効化することはできません。一般的な解決策としては、ブラックリストの管理(無効化されたトークンのJTIを有効期限が切れるまでRedisに保存する)、リフレッシュトークンと組み合わせて有効期間を短縮すること、および機密性の高い操作に対して二次認証を要求することが挙げられます。
Q HelmetでCSPを設定する際、どのような点に注意すべきですか?
A CSPルールが厳しすぎると、正当なリソースの読み込みがブロックされる可能性があります。開発中はContent-Security-Policy-Report-Onlyを使用して違反レポートを監視し、徐々にポリシーを厳格化することをお勧めします。

📖 まとめ

📝 練習問題

  1. 登録画面を実装してください。パスワードは8文字以上で、数字を1つ以上含んでいる必要があります。また、bcryptを使用して暗号化し、データベースに保存する必要があります。
  2. パスワードの検証後に、有効期間が1時間のJWTを発行するログインAPIを実装すること。ペイロードには、userIdおよびroleを含める必要がある。
  3. Authorization: Bearer <token> から JWT を抽出して検証するための authenticate ミドルウェアを作成する
  4. authorize('admin') ミドルウェアを作成し、DELETE /api/users/:id へのアクセスを「admin」ロールのユーザーに限定するようにします。
  5. Helmetをプロジェクトに組み込み、CSPを設定して同一オリジンのスクリプトとスタイルのみが読み込まれるようにし、curlを使用してレスポンスヘッダーの変更を確認する

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%