guest@websec: ~/websec/05-authentication/th — zsh — 100×40 เขียนโดย SaKKo ENTH โค้ด:

guest@websec ~ % man 05-authentication

05 · การยืนยันตัวตน (Authentication)

Authentication ถามเรื่องเดียว: คุณคือคนนั้นจริงไหม ถ้าทำถูก ฐานข้อมูลที่ถูกขโมยก็ไร้ค่า หน้าล็อกอินไม่หลุดอะไร และการรีเซ็ตรหัสไม่กลายเป็นประตูหลัง

bin/rails generate authentication (Rails 8) ให้รหัสผ่านที่ hash แล้ว, session ในฐานข้อมูล, ล็อกอินที่จำกัดอัตรา และลิงก์รีเซ็ตที่ปลอดภัย ที่ต้องเพิ่มเอง: ความยาวขั้นต่ำ, ตรวจรหัสที่เคยรั่ว, MFA

5.1 ห้ามเก็บรหัสผ่านตรงๆ

ฐานข้อมูลรั่วได้ และถ้ารหัสผ่านเก็บแบบตรงๆ ก็รั่วตาม คนชอบใช้รหัสซ้ำด้วย เราจึงเก็บ hash คือการแปลงทางเดียว ตอนล็อกอินก็ hash ที่พิมพ์มาแล้วเทียบ

แต่ไม่ใช่ hash อะไรก็ได้ MD5 กับ SHA-256 เร็ว เกินไป GPU เดาได้เป็นพันล้านครั้งต่อวินาที password hash ต้อง ช้า และ มี salt:

  • ช้า: bcrypt หรือ Argon2 ทำให้การเดาแต่ละครั้งใช้เวลาเป็นมิลลิวินาที
  • มี salt: รหัสเดียวกัน แต่ hash ต่างกันในแต่ละคน ตารางที่คำนวณไว้ล่วงหน้าจึงใช้ไม่ได้
sim: สิ่งที่โจรขโมยฐานข้อมูลเห็นคำนวณ hash จริงในเบราว์เซอร์ของคุณ

สองคนใช้รหัสเดียวกัน ลองดูว่าแต่ละวิธีเก็บ โจรจะเห็นอะไร

วิธีalicebobผลลัพธ์
# 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 ยาวๆ ได้ และบล็อกรหัสที่เคยหลุดในเหตุข้อมูลรั่ว

sim: ตัวตรวจนโยบายรหัสผ่านพิมพ์รหัสผ่าน
has_secure_password ให้ bcrypt และปฏิเสธรหัสผ่านที่ยาวเกิน 72 ไบต์ ส่วนที่เราต้องเพิ่ม: validates :password, length: { minimum: 12 } และอาจใช้ gem pwned

5.2 ล็อกอินโดยไม่หลุดข้อมูล

อย่าบอกว่าผิดที่ ตรงไหน "ไม่พบอีเมลนี้" เท่ากับบอกผู้โจมตีว่าใครเป็นผู้ใช้ของเรา ใช้ข้อความเดียวกับทุกกรณีที่ล้มเหลว

เวลาก็รั่วได้: ถ้าอีเมลที่ไม่มีตอบทันที แต่รหัสผิดใช้ 200 ms นาฬิกาก็บอกความลับได้เหมือนกัน

sim: user enumerationคุณคือผู้โจมตี

ลองใช้รายชื่ออีเมลของผู้โจมตีกับทั้งสองฟอร์ม แบบไหนเผยความลับ?

password: wrong-guess

ล็อกอินที่รั่ว

ล็อกอินแบบข้อความกลาง

attacker's notes: (empty)
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 การรีเซ็ตรหัสผ่าน

"ลืมรหัสผ่าน" คือทางล็อกอินทางที่สอง จึงต้องแข็งแรงเท่าทางแรก ขั้นตอนรีเซ็ตที่ปลอดภัย:

  1. แสดงข้อความเดียวกันไม่ว่าอีเมลจะมีอยู่หรือไม่: "ถ้าอีเมลนี้มีบัญชี เราได้ส่งลิงก์ไปให้แล้ว"
  2. ใช้ token สุ่มยาวๆ เก็บแค่ hash ของมัน หรือลงนามไว้
  3. อายุสั้น (15 นาที) ใช้ได้ครั้งเดียว เปลี่ยนรหัสแล้วใช้ไม่ได้อีก
  4. จำกัดอัตราการเรียก endpoint นี้ (ดูหัวข้อ 08)
sim: วงจรชีวิตของ reset tokentoken 15 นาที ย่อเหลือ 15 วินาที
No token yet.
reset token ของ Rails 8 ลงนามแล้ว หมดอายุใน 15 นาที และใช้ไม่ได้ทันทีเมื่อเปลี่ยนรหัส

5.4 การยืนยันตัวตนหลายปัจจัย (MFA)

รหัสผ่านโดนหลอก โดนเดา และถูกใช้ซ้ำ ปัจจัยที่สองทำให้รหัสที่ถูกขโมยอย่างเดียวไม่พอ เรียงคร่าวๆ จากอ่อนไปแข็ง:

  • รหัสทาง SMS: ดีกว่าไม่มี แต่เสี่ยงต่อการโจมตีแบบ SIM swap และ phishing
  • รหัสจากแอป authenticator: ดี แต่เว็บปลอมส่งต่อรหัสได้ภายใน 30 วินาที
  • กดอนุมัติแบบ push: ผู้โจมตีส่งรัวๆ จนมีคนเผลอกดอนุมัติ ให้ใช้แบบจับคู่ตัวเลข
  • Passkey: ผูกกับโดเมนจริง เว็บปลอมจึงไม่ได้อะไร เป็นตัวเลือกที่ดีที่สุด

ให้ recovery code บังคับ MFA สำหรับพนักงาน และถือว่า "ปิด MFA" เป็นเรื่องละเอียดอ่อน

ไม่มี MFA ในตัว ใช้ rotp (แอป authenticator) หรือ webauthn (passkey) หรือ devise-two-factor ถ้าใช้ Devise

5.5 หลังล็อกอิน: session

หลังล็อกอิน เบราว์เซอร์จะถือคุกกี้ที่บอกว่า "ยังเป็น Alice อยู่" หัวข้อถัดไปคือการกันไม่ให้คุกกี้นี้ตกไปอยู่ในมือคนอื่น