Kodokon kodokon.com

認証:ハッシュ化、セッション対JWT

コストの高い関数でパスワードをハッシュ化し、セッションとJWTを十分に理解したうえで選び、ミドルウェアでルートを保護しましょう。

10 分 · 3 問

このレッスンを Kodokon で開く

パスワードを保存するということは、未来の侵害に備えるということです。データベースが盗まれても生き延びるように、ハッシュ化を設計しましょう。SHA-256のような速いハッシュは、GPU上で毎秒数十億回の試行で総当たりされてしまいます。必要なのは意図的にコストの高い関数、node:cryptoが提供するscryptか、bcrypt/argon2、そしてユーザーごとのランダムなソルトです。ソルトは事前計算されたテーブルを無用にします。ソルトは秘密ではありません。ハッシュのすぐ隣に平文で保存されます。

JAVASCRIPT
import {
  randomBytes,
  scryptSync,
  timingSafeEqual,
} from 'node:crypto';

export function hashPassword(password) {
  const salt = randomBytes(16).toString('hex');
  const hash = scryptSync(password, salt, 64)
    .toString('hex');
  return `${salt}:${hash}`;
}

export function verifyPassword(password, stored) {
  const [salt, hash] = stored.split(':');
  const candidate = scryptSync(password, salt, 64);
  const expected = Buffer.from(hash, 'hex');
  return timingSafeEqual(candidate, expected);
}
ランダムなソルト、コストの高い導出、定数時間の比較。

ユーザーをログインしたままにする二つのモデルがあります。セッションは、httpOnlyクッキーの中の不透明な識別子で、状態はサーバー側にあります。即時の失効、JavaScriptから見えないクッキー。しかし二つ目のインスタンスを追加した途端に、共有ストア(Redis)が必要になります。JWTは、署名された状態がトークンに埋め込まれ、いかなるストアもなしに検証できます。サービス間には理想的ですが、有効期限が切れる前は失効できません。正直なトレードオフはこうです。古典的なウェブアプリケーションにはセッションを、サードパーティに消費されるAPIや分散アーキテクチャには短命のJWTを、です。

JAVASCRIPT
import jwt from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET;
if (!SECRET) {
  throw new Error('JWT_SECRET must be set');
}

export function signToken(user) {
  return jwt.sign(
    { sub: user.id, email: user.email },
    SECRET,
    { expiresIn: '15m' },
  );
}
起動時に必須のシークレット、短い寿命のトークン。

保護ミドルウェアはアクセス制御をくくり出します。Authorization: Bearerヘッダーからトークンを取り出し、それを検証し、デコードされた身元をreq.userに添付するか、あるいは前のレッスンのエラーミドルウェアに回される401で話を打ち切ります。ルートごとにではなく、ルートのグループ全体を保護するために、これをルーターのレベル(router.use(requireAuth))で適用しましょう。

JAVASCRIPT
import jwt from 'jsonwebtoken';
import { HttpError } from './http-error.js';

const SECRET = process.env.JWT_SECRET;

export function requireAuth(req, res, next) {
  const header = req.headers.authorization ?? '';
  const [scheme, token] = header.split(' ');
  if (scheme !== 'Bearer' || !token) {
    return next(new HttpError(401, 'Missing token'));
  }
  try {
    req.user = jwt.verify(token, SECRET);
    next();
  } catch {
    next(new HttpError(401, 'Invalid or expired token'));
  }
}
検証し、req.userを添付する、さもなくば401。それ以外は何もしません。

理解度チェック

このレッスンの要点をしっかり覚えているか確認しましょう。

  1. なぜSHA-256だけではパスワードの保存に不向きなのですか?
    • アルゴリズムが暗号学的に破られているから
    • 速すぎるから。GPUは毎秒数十億の候補を試す。遅くてソルトの付いた関数が必要だ
    • 短いパスワードで衝突を生むから
    • Nodeがそれをネイティブに実装していないから
  2. 有効期限より前に、アクセスを即座に失効させられる仕組みはどれですか?
    • 有効期限を埋め込んでいるJWT
    • ストアからそのエントリを削除するだけでよい、サーバー側のセッション
    • どちらも同じように
  3. パスワードの検証において、timingSafeEqualは何のためにありますか?
    • 二つのハッシュの比較を速くするため
    • 定数時間で比較し、応答時間が何バイト一致したかを明かさないようにするため
    • 十六進のハッシュをBufferに変換するため