Kodokon kodokon.com

การพิสูจน์ตัวตน: การแฮช, session กับ JWT

แฮชรหัสผ่านด้วยฟังก์ชันที่มีต้นทุนสูง เลือกระหว่าง session กับ JWT อย่างเข้าใจถ่องแท้ และปกป้อง route ของคุณด้วย middleware

10 นาที · 3 คำถาม

เปิดบทเรียนนี้ใน Kodokon

การเก็บรหัสผ่านหมายถึงการเตรียมพร้อมรับการรั่วไหลในอนาคต จงออกแบบการแฮชให้อยู่รอดได้แม้ฐานข้อมูลถูกขโมย แฮชที่เร็วอย่าง SHA-256 สามารถถูกเดาแบบ brute-force ได้หลายพันล้านครั้งต่อวินาทีบน GPU คุณจำเป็นต้องมีฟังก์ชันที่ จงใจให้มีต้นทุนสูง อย่าง scrypt ที่มากับ node:crypto หรือ bcrypt/argon2 พร้อมด้วย salt แบบสุ่มต่อผู้ใช้แต่ละคน ซึ่งทำให้ตารางที่คำนวณไว้ล่วงหน้าไร้ประโยชน์ salt ไม่ใช่ความลับ มันถูกเก็บเป็นข้อความธรรมดาไว้ข้าง ๆ แฮช

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);
}
salt แบบสุ่ม, การอนุพัทธ์ที่มีต้นทุนสูง, การเปรียบเทียบแบบเวลาคงที่

มีสองแบบจำลองสำหรับการทำให้ผู้ใช้ยังคงล็อกอินอยู่ session คือ ตัวระบุที่ทึบในคุกกี้ httpOnly โดยมีสถานะอยู่ที่ฝั่งเซิร์ฟเวอร์ เพิกถอนได้ทันที คุกกี้ที่ JavaScript มองไม่เห็น แต่ที่เก็บร่วม (Redis) จะกลายเป็นสิ่งจำเป็นทันทีที่คุณเพิ่มอินสแตนซ์ตัวที่สอง ส่วน JWT คือ สถานะที่ถูกเซ็นฝังอยู่ในโทเคน ตรวจสอบได้โดยไม่ต้องมีที่เก็บใด ๆ เหมาะอย่างยิ่งระหว่างเซอร์วิส แต่ เพิกถอนไม่ได้ ก่อนหมดอายุ การแลกเปลี่ยนตามจริงคือ ใช้ session สำหรับเว็บแอปพลิเคชันแบบคลาสสิก และใช้ JWT อายุสั้นสำหรับ API ที่ถูกเรียกใช้โดยบุคคลที่สาม หรือสำหรับสถาปัตยกรรมแบบกระจาย

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' },
  );
}
ต้องมี secret ตอนเริ่มต้น และโทเคนที่มีอายุสั้น

middleware ป้องกันดึงเอาการควบคุมการเข้าถึงออกมาเป็นส่วนกลาง มันดึงโทเคนจาก header Authorization: Bearer ตรวจสอบมัน แนบตัวตนที่ถอดรหัสแล้วไว้ที่ req.user หรือตัดจบด้วย 401 ที่ถูกส่งไปยัง error middleware จากบทที่แล้ว จงใช้มันในระดับ router (router.use(requireAuth)) เพื่อปกป้องกลุ่ม route ทั้งกลุ่ม แทนที่จะทำทีละ route

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 ทดสอบตัวเลือกได้หลายพันล้านตัวต่อวินาที คุณจึงต้องใช้ฟังก์ชันที่ช้าและมี salt
    • เพราะมันสร้างการชนกัน (collision) กับรหัสผ่านสั้น ๆ
    • เพราะ Node ไม่ได้ทำมันขึ้นมาแบบเนทีฟ
  2. กลไกใดที่ให้คุณเพิกถอนการเข้าถึงได้ทันที ก่อนหมดอายุใด ๆ?
    • JWT เพราะมันฝังวันหมดอายุของมันไว้
    • session ฝั่งเซิร์ฟเวอร์ เพราะคุณเพียงแค่ลบรายการของมันออกจากที่เก็บ
    • ทั้งสองอย่าง เท่าเทียมกัน
  3. timingSafeEqual มีไว้เพื่ออะไรในการตรวจสอบรหัสผ่าน?
    • เพื่อเร่งการเปรียบเทียบแฮชทั้งสอง
    • เพื่อเปรียบเทียบในเวลาคงที่ เพื่อไม่ให้ระยะเวลาตอบสนองเปิดเผยว่ามีกี่ไบต์ที่ตรงกัน
    • เพื่อแปลงแฮชฐานสิบหกให้เป็น Buffer