การ hash รหัสผ่าน
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”Persistence จริงใน apps/api/src/users/users.service.ts แทนที่ stub แบบ echo อย่างเดียวจาก Validation ตอนนี้ UsersService.create hash รหัสผ่านที่ส่งเข้ามาด้วย bcrypt แล้วบันทึก User document จริง findByEmail กับ findById ให้ lookup ที่ส่วนอื่นของโมดูลนี้ต้องใช้สำหรับ login และ route guard UsersModule ได้ลงทะเบียน MongooseModule.forFeature สำหรับ schema User จาก Schemas และ UsersController ก็คืน user ที่บันทึกแล้วโดยตัด passwordHash ออก
รหัสผ่านคือ field เดียวในแอปทั้งหมดนี้ที่ต้องไม่มีทางกู้คืนกลับมาได้เลย — ไม่ใช่โดย developer ที่อ่านฐานข้อมูล ไม่ใช่โดย attacker ที่ dump ฐานข้อมูลออกไป การ hash แก้ปัญหานี้: bcrypt.hash(password, 12) ส่งรหัสผ่านผ่านฟังก์ชันทางเดียว แล้วคืน string ที่ encode ทั้ง algorithm version, cost factor, salt แบบสุ่ม และตัว hash ไว้ใน field เดียวกันหมด นี่คือเหตุผลที่ User.passwordHash ใน Schemas เป็น string column ตัวเดียว ไม่มี field salt แยกอยู่ข้าง ๆ การตรวจสอบ login ในภายหลังก็ไม่เคยย้อนกระบวนการนี้กลับเลย JWT & Passport เรียก bcrypt.compare(candidatePassword, user.passwordHash) ซึ่ง hash ตัวที่ส่งเข้ามาใหม่ด้วย salt ที่ฝังอยู่ใน value ที่เก็บไว้แล้ว แล้วเทียบผลลัพธ์กัน
ตัวเลข 12 ใน bcrypt.hash(password, 12) คือ cost factor ทุกครั้งที่เพิ่มขึ้นหนึ่ง งานที่ bcrypt ต้องทำต่อการ hash หนึ่งครั้งจะเพิ่มเป็นสองเท่า ค่านี้ไม่ใช่จำนวนรอบตายตัว แต่หมายถึง 2^cost รอบของฟังก์ชัน key-derivation ที่ตั้งใจให้ช้า ปรับจนการ hash รหัสผ่านหนึ่งตัวใช้เวลาเสี้ยววินาทีที่สังเกตได้บน hardware จริง และการ brute-force รหัสผ่านนับล้านตัวก็ใช้เวลานานขึ้นตามสัดส่วน 12 คือค่า default ที่ใช้กันทั่วไปในตอนนี้ (เอกสารของ bcrypt เองก็ใช้เป็นตัวอย่าง cost factor มาหลายปีแล้ว) — สูงพอที่จะทำให้ attacker ที่ขโมย database dump ไปต้องเสียเวลามาก แต่ต่ำพอที่ login request จริง ๆ จะไม่ช้าจนสังเกตเห็น
UsersService.create ยังต้องกันการลงทะเบียนซ้ำด้วย email เดียวกันตั้งแต่ก่อนไปถึง unique index unique: true บน email ของ MongoDB ถ้าพึ่ง database constraint อย่างเดียว การสมัครซ้ำจะโผล่มาเป็น raw driver error (exception E11000 duplicate key ของ Mongo) แทนที่จะเป็น 409 Conflict ที่สะอาด create จึงเช็ค findByEmail ก่อน แล้ว throw ConflictException เอง เป็น pattern เดียวกับ “throw built-in exception ของ Nest แล้วให้ global filter จัดรูปแบบ response” ที่ Config & exceptions วางไว้ให้กับทุก error อื่นในแอปนี้
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”bcrypt เทียบกับ argon2 เทียบกับ plaintext Plaintext ไม่ใช่ตัวเลือกจริง เขียนไว้ตรงนี้เพราะเป็นความผิดพลาดที่บทเรียนนี้มีไว้เพื่อป้องกัน database dump ที่ถูกขโมยจะกลายเป็นรายการรหัสผ่านที่ถูกขโมยไปด้วยทันที และเพราะคนมักใช้รหัสผ่านซ้ำ การรั่วในแอปนี้อาจลามไปทำให้บัญชีในบริการอื่นที่ไม่เกี่ยวกันถูกเจาะตาม
เหลือสองตัวเลือกจริง argon2 (โดยเฉพาะ argon2id) เป็น algorithm ที่ใหม่กว่าและเป็นแบบ memory-hard คือออกแบบให้กิน RAM มากพอ ๆ กับเวลา CPU จึงต้านการโจมตีแบบ parallel ราคาถูกที่ GPU และ ASIC ถนัด และชนะ Password Hashing Competition ปี 2015 ด้วยเหตุผลนี้เป๊ะ ๆ ส่วน bcrypt เก่ากว่า เป็น CPU-hard อย่างเดียว ไม่มี memory-hardness แต่ผ่านการใช้งานจริงใน production มากว่า 25 ปี มีปัญหาเรื่อง native-build น้อยกว่าในหลาย hosting platform และรูปแบบ hash ที่ฝัง salt ไว้ในตัวและอธิบายตัวเองได้คือสิ่งที่ framework กระแสหลักทุกตัว (รวมถึง recipe ของ Nest เอง) เลือกใช้เป็นอันดับแรก สำหรับ DevBlog ที่มี user base เล็กและมูลค่าต่ำ (author กับ admin หนึ่งคน ไม่ใช่ธนาคาร) ความเรียบง่ายที่ผ่านการพิสูจน์แล้วของ bcrypt คุ้มค่ากว่าความต้านทานที่แข็งแกร่งกว่าของ argon2 ต่อการโจมตีแบบ offline ขนาดใหญ่ — แต่ระบบ production ที่ปกป้อง user base ที่ใหญ่กว่าและมูลค่าสูงกว่าควรใช้ argon2id เป็น default แทน
Cost factor 12 เทียบกับตัวเลขที่สูงกว่า Cost factor ที่สูงกว่า (14, 16) ทำให้การ brute-force แบบ offline มีค่าใช้จ่ายสูงขึ้นตามสัดส่วนถ้าฐานข้อมูลรั่วไหลออกไป แต่ก็เพิ่ม latency จริงแบบเชิงเส้นในทุก login และ registration request เพราะ server ต้องรัน hash ราคาแพงตัวเดียวกันตอน verify เหมือนตอนสร้าง 12 จึงเป็นจุดกึ่งกลางที่ตั้งใจเลือก: แพงพอที่จะมีความหมายต่อ attacker ถูกพอที่ POST /users ด้านล่างยังคง response ได้ในเวลาต่ำกว่าหนึ่งวินาทีมาก
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”สร้าง apps/api/src/users/users.service.ts:
import { ConflictException, Injectable } from '@nestjs/common';import { InjectModel } from '@nestjs/mongoose';import { Model } from 'mongoose';import * as bcrypt from 'bcrypt';import { User, UserDocument } from './schemas/user.schema';
const SALT_ROUNDS = 12;
export interface CreateUserInput { email: string; password: string; displayName: string;}
@Injectable()export class UsersService { constructor( @InjectModel(User.name) private readonly userModel: Model<UserDocument>, ) {}
async create(input: CreateUserInput): Promise<UserDocument> { const existing = await this.findByEmail(input.email); if (existing) { throw new ConflictException('Email already registered'); }
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS); const user = new this.userModel({ email: input.email, passwordHash, displayName: input.displayName, }); return user.save(); }
findByEmail(email: string): Promise<UserDocument | null> { return this.userModel.findOne({ email }).exec(); }
findById(id: string): Promise<UserDocument | null> { return this.userModel.findById(id).exec(); }}SALT_ROUNDS = 12— ตั้งชื่อและยกขึ้นไว้ด้านบนเพื่อให้ cost factor เป็นค่าเดียวที่มีเอกสารกำกับ แทนที่จะเป็น magic number ที่ฝังอยู่ในการเรียกฟังก์ชันbcrypt.hash(input.password, SALT_ROUNDS)— สร้าง salt แบบสุ่มขึ้นภายในและคืนค่าเป็น string ที่ครบในตัวเอง (algorithm tag, cost, salt, hash) ไม่ต้องเก็บอะไรเพิ่มควบคู่ไปด้วยfindByEmailก่อนcreate— เปลี่ยนการสมัครซ้ำให้กลายเป็น409 Conflictที่ตั้งใจ แทนที่จะปล่อยให้ errorE11000ดิบ ๆ ของ MongoDB ไปถึงAllExceptionsFilterในฐานะ 500 ที่ไม่ได้ถูกจัดการfindByIdมีไว้ตอนนี้เพราะ Guards & roles และ Auth resolver & GraphQL setup ทั้งคู่ต้องโหลด user เต็มรูปแบบจาก JWT payload ที่ decode แล้ว ซึ่งมีแค่ id ของ user
อัปเดต apps/api/src/users/users.module.ts ให้ลงทะเบียน schema User และ export UsersService เพื่อให้ JWT & Passport inject เข้า AuthModule ได้:
import { Module } from '@nestjs/common';import { MongooseModule } from '@nestjs/mongoose';import { User, UserSchema } from './schemas/user.schema';import { UsersController } from './users.controller';import { UsersService } from './users.service';
@Module({ imports: [ MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), ], controllers: [UsersController], providers: [UsersService], exports: [UsersService],})export class UsersModule {}อัปเดต apps/api/src/users/users.controller.ts — create เป็น async ตอนนี้ และคืน user ที่บันทึกแล้วโดยไม่มี passwordHash ไม่มีการ echo กลับไปให้ client เลย:
import { Body, Controller, Post } from '@nestjs/common';import { UsersService } from './users.service';import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')export class UsersController { constructor(private readonly usersService: UsersService) {}
@Post() async create(@Body() dto: CreateUserDto) { const user = await this.usersService.create(dto); return { id: user.id, email: user.email, displayName: user.displayName, role: user.role, }; }}CreateUserDto จาก Validation มีรูปร่าง email, password, displayName ตรงกับที่ CreateUserInput ของ UsersService.create ต้องการอยู่แล้ว — ไม่ต้องแก้อะไรตรงนั้นเพิ่ม
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”npm run start:devRegistration request ตอนนี้บันทึก user จริงที่ hash แล้ว แทนที่จะ echo request body กลับมา:
curl -i -X POST localhost:4000/users \ -H "Content-Type: application/json" \ -d '{"email":"author@example.com","password":"correct-horse","displayName":"Ava"}'HTTP/1.1 201 Created{"id":"...","email":"author@example.com","displayName":"Ava","role":"author"}ยืนยันว่าค่าที่เก็บไว้เป็น bcrypt hash ไม่ใช่รหัสผ่าน plaintext โดยเช็คตรงกับ container mongo จาก Compose skeleton:
docker compose exec mongo mongosh -u devblog -p devblog --authenticationDatabase admin devblog \ --eval "db.users.findOne({ email: 'author@example.com' }, { passwordHash: 1 })"{ _id: ..., passwordHash: '$2b$12$K8Z...' }$2b$12$ คือ format tag ของ bcrypt เอง — version 2b, cost factor 12 — ยืนยันว่า SALT_ROUNDS มีผลจริง และ field นี้ไม่ใช่ plaintext ที่กู้คืนได้จริง ๆ
การสมัคร email เดียวกันซ้ำสองครั้งตอนนี้ fail แบบสะอาด แทนที่จะเป็น raw driver error:
curl -i -X POST localhost:4000/users \ -H "Content-Type: application/json" \ -d '{"email":"author@example.com","password":"correct-horse","displayName":"Ava"}'HTTP/1.1 409 Conflict{"statusCode":409,"timestamp":"...","path":"/users","message":"Email already registered"}UsersService.create hash รหัสผ่านทุกตัวที่ส่งเข้ามาด้วย bcrypt.hash(password, 12) ตั้งแต่ก่อนแตะ User document โดย cost factor เป็นค่าคงที่ที่ตั้งชื่อไว้ ไม่ใช่ magic number และ string ที่ได้ก็ครบในตัวเอง (มี salt อยู่ข้างใน) จึงไม่ต้องมี salt column แยก findByEmail ป้องกันการสมัครซ้ำด้วย 409 ที่สะอาด แทนที่จะปล่อยให้ unique index ของ MongoDB throw raw driver error และ findById มีไว้ล่วงหน้าสำหรับ lookup ที่อิง JWT ที่ส่วนอื่นของโมดูลนี้จะสร้างต่อ UsersController ตอนนี้คืน user ที่บันทึกแล้วโดยตัด passwordHash ออก — รูปร่างที่ Auth resolver & GraphQL setup จะสะท้อนซ้ำเป๊ะ ๆ ในภายหลังในฐานะ GraphQL type User
ถัดไป: JWT & Passport →