guest@websec ~ % man 05-authentication
05 · การยืนยันตัวตน (Authentication)
Authentication ถามเรื่องเดียว: คุณคือคนนั้นจริงไหม ถ้าทำถูก ฐานข้อมูลที่ถูกขโมยก็ไร้ค่า หน้าล็อกอินไม่หลุดอะไร และการรีเซ็ตรหัสไม่กลายเป็นประตูหลัง
bin/rails generate authentication (Rails 8) ให้รหัสผ่านที่ hash แล้ว, session ในฐานข้อมูล, ล็อกอินที่จำกัดอัตรา และลิงก์รีเซ็ตที่ปลอดภัย ที่ต้องเพิ่มเอง: ความยาวขั้นต่ำ, ตรวจรหัสที่เคยรั่ว, MFA5.1 ห้ามเก็บรหัสผ่านตรงๆ
ฐานข้อมูลรั่วได้ และถ้ารหัสผ่านเก็บแบบตรงๆ ก็รั่วตาม คนชอบใช้รหัสซ้ำด้วย เราจึงเก็บ hash คือการแปลงทางเดียว ตอนล็อกอินก็ hash ที่พิมพ์มาแล้วเทียบ
แต่ไม่ใช่ hash อะไรก็ได้ MD5 กับ SHA-256 เร็ว เกินไป GPU เดาได้เป็นพันล้านครั้งต่อวินาที password hash ต้อง ช้า และ มี salt:
- ช้า: bcrypt หรือ Argon2 ทำให้การเดาแต่ละครั้งใช้เวลาเป็นมิลลิวินาที
- มี salt: รหัสเดียวกัน แต่ hash ต่างกันในแต่ละคน ตารางที่คำนวณไว้ล่วงหน้าจึงใช้ไม่ได้
สองคนใช้รหัสเดียวกัน ลองดูว่าแต่ละวิธีเก็บ โจรจะเห็นอะไร
| วิธี | alice | bob | ผลลัพธ์ |
|---|
# Gemfile: gem "bcrypt" class User < ApplicationRecord has_secure_password # bcrypt hash in password_digest validates :password, length: { minimum: 12 }, allow_nil: true # you add this end User.create!(email_address: "ana@x.test", password: "correct horse battery staple") user.authenticate("typed password") # => user, or false
// npm install bcrypt (or argon2) const bcrypt = require("bcrypt"); // sign-up: store only the hash (salt is embedded in it) const hash = await bcrypt.hash(password, 12); // 12 = cost factor await db.users.insert({ email, password_hash: hash }); // login const ok = await bcrypt.compare(typedPassword, user.password_hash);
# pip install argon2-cffi (Django: make_password / check_password do this for you) from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError ph = PasswordHasher() # sign-up: store only the hash (salt is embedded in it) user.password_hash = ph.hash(password) # login try: ph.verify(user.password_hash, typed_password) except VerifyMismatchError: deny()
กฎรหัสผ่านที่ไลบรารีไม่ได้เพิ่มให้
ระวัง: bcrypt ดูแค่ 72 ไบต์แรกของรหัสผ่าน และไม่มีอะไรบังคับความยาวขั้นต่ำ ให้เน้นความยาวมากกว่าความซับซ้อน: อย่างน้อย 12 ตัว (NIST แนะนำ 15 ตัวถ้าใช้รหัสผ่านอย่างเดียวโดยไม่มี MFA) รับ passphrase ยาวๆ ได้ และบล็อกรหัสที่เคยหลุดในเหตุข้อมูลรั่ว
has_secure_password ให้ bcrypt และปฏิเสธรหัสผ่านที่ยาวเกิน 72 ไบต์ ส่วนที่เราต้องเพิ่ม: validates :password, length: { minimum: 12 } และอาจใช้ gem pwned5.2 ล็อกอินโดยไม่หลุดข้อมูล
อย่าบอกว่าผิดที่ ตรงไหน "ไม่พบอีเมลนี้" เท่ากับบอกผู้โจมตีว่าใครเป็นผู้ใช้ของเรา ใช้ข้อความเดียวกับทุกกรณีที่ล้มเหลว
เวลาก็รั่วได้: ถ้าอีเมลที่ไม่มีตอบทันที แต่รหัสผิดใช้ 200 ms นาฬิกาก็บอกความลับได้เหมือนกัน
ลองใช้รายชื่ออีเมลของผู้โจมตีกับทั้งสองฟอร์ม แบบไหนเผยความลับ?
wrong-guessล็อกอินที่รั่ว
ล็อกอินแบบข้อความกลาง
class SessionsController < ApplicationController rate_limit to: 10, within: 3.minutes, only: :create # Rails 7.2+ def create # authenticate_by hashes even when the email is unknown (timing-safe) if user = User.authenticate_by(params.permit(:email_address, :password)) start_new_session_for user # generator helper: fresh Session record redirect_to after_authentication_url else redirect_to new_session_path, alert: "Try another email address or password." end end end
app.post("/login", async (req, res) => {
const user = await db.users.findOne({ email: req.body.email });
// compare against a dummy hash when the user doesn't exist,
// so both paths take the same time
const hash = user ? user.password_hash : DUMMY_BCRYPT_HASH;
const ok = await bcrypt.compare(req.body.password, hash);
if (!user || !ok) return res.status(401).render("login",
{ error: "Invalid email or password." }); // one message
await startNewSession(req, user); // see section 06
res.redirect("/");
});
@app.post("/login")
def login():
user = User.query.filter_by(email=request.form["email"]).first()
# verify against a dummy hash when the user doesn't exist,
# so both paths take the same time
stored = user.password_hash if user else DUMMY_HASH
ok = verify(stored, request.form["password"])
if not user or not ok:
return render_template("login.html",
error="Invalid email or password."), 401 # one message
start_new_session(user) # see section 06
return redirect("/")
User.authenticate_by คำนวณ hash แม้ไม่มีอีเมลนั้น เวลาจึงเท่ากัน ถ้าเขียนค้นหาเอง จะเสียตรงนี้ไป5.3 การรีเซ็ตรหัสผ่าน
"ลืมรหัสผ่าน" คือทางล็อกอินทางที่สอง จึงต้องแข็งแรงเท่าทางแรก ขั้นตอนรีเซ็ตที่ปลอดภัย:
- แสดงข้อความเดียวกันไม่ว่าอีเมลจะมีอยู่หรือไม่: "ถ้าอีเมลนี้มีบัญชี เราได้ส่งลิงก์ไปให้แล้ว"
- ใช้ token สุ่มยาวๆ เก็บแค่ hash ของมัน หรือลงนามไว้
- อายุสั้น (15 นาที) ใช้ได้ครั้งเดียว เปลี่ยนรหัสแล้วใช้ไม่ได้อีก
- จำกัดอัตราการเรียก endpoint นี้ (ดูหัวข้อ 08)
5.4 การยืนยันตัวตนหลายปัจจัย (MFA)
รหัสผ่านโดนหลอก โดนเดา และถูกใช้ซ้ำ ปัจจัยที่สองทำให้รหัสที่ถูกขโมยอย่างเดียวไม่พอ เรียงคร่าวๆ จากอ่อนไปแข็ง:
- รหัสทาง SMS: ดีกว่าไม่มี แต่เสี่ยงต่อการโจมตีแบบ SIM swap และ phishing
- รหัสจากแอป authenticator: ดี แต่เว็บปลอมส่งต่อรหัสได้ภายใน 30 วินาที
- กดอนุมัติแบบ push: ผู้โจมตีส่งรัวๆ จนมีคนเผลอกดอนุมัติ ให้ใช้แบบจับคู่ตัวเลข
- Passkey: ผูกกับโดเมนจริง เว็บปลอมจึงไม่ได้อะไร เป็นตัวเลือกที่ดีที่สุด
ให้ recovery code บังคับ MFA สำหรับพนักงาน และถือว่า "ปิด MFA" เป็นเรื่องละเอียดอ่อน
rotp (แอป authenticator) หรือ webauthn (passkey) หรือ devise-two-factor ถ้าใช้ Devise5.5 หลังล็อกอิน: session
หลังล็อกอิน เบราว์เซอร์จะถือคุกกี้ที่บอกว่า "ยังเป็น Alice อยู่" หัวข้อถัดไปคือการกันไม่ให้คุกกี้นี้ตกไปอยู่ในมือคนอื่น