การ Hash รหัสผ่าน
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”password.rs มีสองฟังก์ชัน ตัวแรกคือ hash_password(plain: &str) -> anyhow::Result<String> แปลงรหัสผ่าน plaintext ให้เป็น hash string แบบ Argon2 ที่ปลอดภัยพอจะเก็บใน column users.password_hash ตัวที่สองคือ verify_password(plain: &str, hash: &str) -> bool เอารหัสผ่าน plaintext ตอน login มาเทียบกับ hash ที่เก็บไว้ โดยไม่ต้อง reverse hash กลับมาเลย
ทุกส่วนที่เหลือใน Module 4 ต่อยอดจากสองฟังก์ชันนี้ — register เรียก hash_password ก่อน INSERT ส่วน login เรียก verify_password เทียบกับแถวที่เพิ่ง fetch มา ทั้งคู่ไม่คุยกับฐานข้อมูล Redis หรือ Axum เลย จึงเป็น building block บริสุทธิ์ที่ test ง่าย
รหัสผ่านคือความลับหนึ่งเดียวในทั้งระบบที่ผู้ใช้เลือกเอง และมักเอาไปใช้ซ้ำกับบริการอื่นด้วย ไม่มีใครคาดหวังว่าฐานข้อมูล ของคุณ รั่วแล้วรหัสผ่านจะหลุดตามไปด้วย ถ้า users.password_hash รั่วออกไปเมื่อไหร่ — backup ที่วางผิด S3 bucket, replica ที่โดนเจาะ, SQL injection — ผลลัพธ์ที่ยอมรับได้มีทางเดียวคือข้อมูลที่รั่วออกไปนั้น ไร้ประโยชน์ สำหรับคนที่เอาไป มีแค่ password hashing function ที่รับประกันตรงนี้ได้ การเก็บ plaintext หรือ encryption แบบย้อนกลับได้ทำไม่ได้
Hashing แบบทางเดียว ต่างจาก encryption ตัว encryption ออกแบบมาให้ reverse ได้ — ใครถือ key ก็กู้ plaintext กลับมาได้ key จึงกลายเป็น single point of failure และเป็นเป้าหมายชิ้นโตชิ้นเดียว ส่วน hashing ทิ้ง plaintext ไปทั้งหมด hash_password สร้าง string ขนาดคงที่ที่ไม่มี decrypt ให้ การ verify ตอน login คือการ hash ค่าที่พยายาม login ด้วยวิธีเดียวกันแล้วเอามาเทียบ ไม่เคย decrypt ค่าที่เก็บไว้เลย
Hash function ทั่วไป (SHA-256, MD5) ก็ยังผิดสำหรับรหัสผ่าน ถึงจะเป็นทางเดียวเหมือนกัน แต่ hash function ทั่วไปสร้างมาให้ เร็ว ที่เป็นคุณสมบัติที่คุณต้องการพอดีสำหรับ checksum และไม่ต้องการเลยสำหรับรหัสผ่าน เพราะความเร็วเดียวกันนั้นแหละที่ช่วยให้ผู้โจมตีที่มี GPU brute-force ทุกรหัสผ่านที่เป็นไปได้อย่างรวดเร็ว
ส่วน Argon2 คือ password hashing function จงใจให้ช้าและกิน memory มาก ปรับจูนมาให้การ hash รหัสผ่านหนึ่งตัวใช้เวลาเสี้ยววินาทีบน server ของคุณ แต่ทำให้การ hash การเดารหัสหลายพันล้านครั้งแพงเกินไปสำหรับผู้โจมตี
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Argon2 (ตัวที่เราใช้) เทียบกับ bcrypt
- ข้อดี: Argon2 เป็น memory-hard คือมีค่าใช้จ่าย RAM ที่ปรับได้ต่อการ hash หนึ่งครั้ง ไม่ใช่แค่เวลา CPU อย่างเดียว จุดนี้เจาะจงเอาชนะ rig สำหรับ crack ด้วย GPU และ ASIC ที่ได้เปรียบด้านความเร็วจากการขนานจำนวนมากบน memory ราคาถูก และยังเป็นผู้ชนะการแข่งขัน Password Hashing Competition ปี 2015 กับเป็น algorithm ที่ OWASP แนะนำอย่างจริงจังสำหรับระบบใหม่
argon2 = "0.5"ให้เราArgon2::default()— Argon2id พร้อม parameter ที่ OWASP แนะนำ — โดยไม่ต้อง tune เองเลย - ข้อเสีย: bcrypt มีประวัติการใช้งานจริงยาวนานกว่า (ใช้กันแพร่หลายตั้งแต่ปี 1999) และมี API ที่เรียบง่ายกว่า ตั้งค่าผิดยากกว่าในบาง ecosystem อีกอย่างคือ bcrypt truncate input ที่เกิน 72 byte แบบเงียบ ๆ ส่วน Argon2 ไม่ทำ — เท่ากับ footgun น้อยลงหนึ่งอัน แต่ก็ไม่มากพอที่จะยอมสละความ memory-hard ของ Argon2 สำหรับระบบใหม่ในปี 2026
รูปแบบ PHC string — hash พก parameter ของตัวเองไปด้วย (ตัวที่เราใช้) เทียบกับเก็บ salt/parameter แยก column
- ข้อดี:
Argon2::default().hash_password(...)คืน string ที่อธิบายตัวเองได้อย่าง$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>— algorithm, version, ค่าใช้จ่ายด้าน memory/time/parallelism, salt และ hash อยู่ใน string เดียวนั้นทั้งหมด และPasswordHash::new(hash)parse กลับออกมาได้verify_passwordจึงใช้ parameter ชุดเดิมเป๊ะกับตอนที่ hash รหัสผ่านนั้นไว้เสมอ ต่อให้เราปรับ cost parameter ของArgon2สำหรับ hash ใหม่ ในภายหลังก็ตาม ฝั่ง tableusersก็ต้องการแค่ columntextเดียว (password_hash) - ข้อเสีย: ถ้าอยาก query ว่า “ผู้ใช้คนไหน hash ด้วย parameter ที่ล้าสมัยแล้วบ้าง” เพื่อทำ rehash migration ก็ต้องไล่ parse PHC string ทีละแถว แทนที่จะเขียน SQL ตรง ๆ ว่า
WHERE cost_param < Nแลกกันแบบนี้คุ้มสำหรับโปรเจกต์ขนาดคอร์ส ส่วนระบบขนาดใหญ่อาจแยก cost parameter ออกมาเป็น column ของตัวเอง
salt แบบสุ่มต่อรหัสผ่านหนึ่งตัว (ตัวที่เราใช้ ผ่าน SaltString::generate) เทียบกับไม่มี salt / salt ที่ใช้ร่วมกัน
- ข้อดี: ผู้ใช้สองคนที่ตั้งรหัสผ่าน
hunter42เหมือนกันเป๊ะ จะได้password_hashที่ต่างกันคนละเรื่อง เพราะแต่ละ hash ฝัง salt สุ่มของตัวเองไว้ แบบนี้เอาชนะ rainbow table (ตาราง hash ที่คำนวณไว้ล่วงหน้าเพื่อเปิดหา) และกันไม่ให้ผู้โจมตีที่ crack รหัสผ่านของคนหนึ่งได้ พลอยได้รหัสของทุกคนที่ใช้รหัสซ้ำกันไปฟรี ๆ - ข้อเสีย: แทบไม่มีอะไรให้พูดถึง hash ที่ไม่มี salt หรือใช้ salt ร่วมกันคือช่องโหว่ตรง ๆ ไม่ใช่ trade-off และการเรียก
SaltString::generate(&mut OsRng)ให้ถูกต้องก็ไม่มีต้นทุนเพิ่มอะไรเลย
ลงมือสร้าง
หัวข้อที่มีชื่อว่า “ลงมือสร้าง”1. เพิ่ม dependency argon2
หัวข้อที่มีชื่อว่า “1. เพิ่ม dependency argon2”cd taskflow/backendcargo add argon2 -p apiคำสั่งนี้เพิ่ม argon2 = "0.5" ใต้ [dependencies] ใน api/Cargo.toml
2. password.rs
หัวข้อที่มีชื่อว่า “2. password.rs”สร้าง taskflow/backend/api/src/auth/password.rs:
use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2,};
pub fn hash_password(plain: &str) -> anyhow::Result<String> { let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default() .hash_password(plain.as_bytes(), &salt) .map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))? .to_string();
Ok(hash)}
pub fn verify_password(plain: &str, hash: &str) -> bool { let Ok(parsed_hash) = PasswordHash::new(hash) else { return false; };
Argon2::default() .verify_password(plain.as_bytes(), &parsed_hash) .is_ok()}
#[cfg(test)]mod tests { use super::*;
#[test] fn hash_and_verify_round_trip() { let hash = hash_password("correct horse battery staple").unwrap(); assert!(verify_password("correct horse battery staple", &hash)); assert!(!verify_password("wrong password", &hash)); }
#[test] fn rejects_a_malformed_hash() { assert!(!verify_password("anything", "not-a-phc-string")); }}มีรายละเอียดสองสามอย่างที่ควรพูดถึง:
SaltString::generate(&mut OsRng)ใช้ตัวสร้างเลขสุ่มที่ปลอดภัยเชิง cryptographic ของ OS — ไม่ใช้ salt ที่คาดเดาได้หรือค่าคงที่เด็ดขาดhash_passwordคืนค่าanyhow::Result<String>ตรงกับ pattern ของทุกฟังก์ชันที่ fail ได้และไม่เกี่ยวกับ HTTP ที่เราเขียนมาตั้งแต่ config-tracing ตัว error typeargon2::password_hash::Errorไม่ได้ implementstd::error::Errorในแบบที่?จะแปลงให้อัตโนมัติ เราจึงห่อด้วยanyhow::anyhow!พร้อมข้อความสั้น ๆverify_passwordคืนค่าboolธรรมดา ไม่ใช่Result— ทั้ง hash ที่เก็บไว้แบบผิดรูปและรหัสผ่านที่ผิดจริง ๆ ต่างก็แปลว่า “ยืนยันตัวตนไม่ผ่าน” เหมือนกัน patternlet ... else { return false }เขียนความหมายนี้ออกมาชัด ๆ แทนที่จะบังคับให้ทุกจุดที่เรียกต้องจัดการ parse error เอง- module
#[cfg(test)]ยืนยันว่า round trip ทำงาน และ input ขยะจะ fail แบบปิด (คืนfalse) ไม่ใช่ panic
3. เริ่ม module auth
หัวข้อที่มีชื่อว่า “3. เริ่ม module auth”สร้าง taskflow/backend/api/src/auth/mod.rs:
pub mod password;เพิ่ม mod auth; ใน taskflow/backend/api/src/main.rs:
mod auth;mod config;mod db;mod error;mod state;auth คือ directory module โดยมี auth/mod.rs เป็นจุดเริ่มต้น ทุกบทเรียนใน module นี้จะเพิ่มบรรทัด pub mod ...; เข้าไปอีกหนึ่งบรรทัด ตอนที่เราสร้าง jwt.rs, middleware.rs และ handlers.rs
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”cargo check -p apiจากนั้นรัน unit test ใหม่:
cargo test -p api auth::passwordผลลัพธ์ที่ควรได้:
running 2 teststest auth::password::tests::rejects_a_malformed_hash ... oktest auth::password::tests::hash_and_verify_round_trip ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered outตอนนี้ cargo check -p api จะ compile ผ่านพร้อม warning ประเภท dead_code/never constructed อยู่บ้าง เพราะยังไม่มี handler ตัวไหนเรียก password.rs จนกว่าจะถึง handlers ที่เชื่อม register และ login เข้ามา ถือเป็นเรื่องปกติในขั้นนี้ เหมือนกับทุก module ที่เราวางโครงไว้ก่อนหน้า
คุณสร้าง hash_password และ verify_password ใน password.rs ด้วย Argon2id พร้อม salt สุ่มต่อรหัสผ่านหนึ่งตัวผ่าน SaltString::generate รูปแบบ PHC string ทำให้ password_hash เป็น column text เดียวที่พก parameter ของ algorithm ติดไปในตัว verify_password จึงเช็คด้วย parameter ชุดเดียวกับตอนสร้าง hash เสมอ นอกจากนี้คุณยังเริ่ม directory module auth ที่ Module 4 ส่วนที่เหลือจะต่อยอดไป บทถัดไปเราจะออกและ verify signed session token ใน jwt