Mục lục
- Mục Tiêu Bài Học
- Argon2 Vs Bcrypt Vs Scrypt
- Cài Argon2 + Helper Module
- RegisterDto + UpdateProfileDto
- Email Verification Token Pattern
- register_user Function Trong shop-db
- Endpoint Handler — Register + Get Me + Update Me + Verify Email
- Implement From<UserRow> For User
- Verify End-To-End + Security Checks
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Implement
POST /api/v1/users/registervới password hash argon2id (OWASP 2024 recommendation, paramsm_cost = 64 MiB+t_cost = 3+p_cost = 4). - Implement
GET /api/v1/users/melấy profile current user +PATCH /api/v1/users/meupdate profile với double-Option PATCH lock B42. - Hiểu sự khác biệt argon2 vs bcrypt vs scrypt — chọn cho password hashing production, lý do OWASP 2024 chọn Argon2id.
- Hiểu timing attack vs constant-time compare — tại sao
verify_passwordargon2 internal MANDATORY dùng constant-time. - Implement email verification flow: sinh token UUID v4 + nonce hex 32 byte + bảng
email_verification_tokens+ send mail preview quatracing::info!log (G16 implement queue thật). RegisterDtovalidate email format + password strength (upper + lower + digit + length 8-128) + display_name 2-100 +UpdateProfileDtodouble-Option cho phone + avatar_url.- Pattern 3 step transaction register_user (insert user + insert verify token + audit log) — atomic guarantee không có user mồ côi không có token verify.
- Foundation cho G11 auth deep (B112 JWT login + auth middleware extract user_id thay placeholder; G16 email worker queue async).
Argon2 Vs Bcrypt Vs Scrypt
Password hashing function (KDF — key derivation function) khác hash function thông thường (MD5, SHA-256) ở chỗ cố tình chậm. SHA-256 1 lần chạy ~1µs trên CPU hiện đại — attacker GPU có thể brute-force 10^10 hash/giây cho danh sách rockyou.txt 14 triệu password trong vài phút. KDF như argon2/bcrypt/scrypt tăng cost lên ~100ms mỗi lần hash → giảm GPU throughput xuống ~10 hash/giây/core → attacker brute-force cùng list mất vài năm thay vì vài phút.
bcrypt (Niels Provos + David Mazières, 1999) — old standard. Pros: battle-tested 25+ năm, wide language support (Node.js, Python, Ruby, PHP đều có lib mature), Rust crate bcrypt stable. Cons: (i) max 72 char password — Blowfish key schedule giới hạn 72 byte, password dài hơn bị truncate âm thầm tại byte 72 (vd "passphrase_dài_80_chars_..." 2 password khác nhau cùng 72 byte đầu sẽ hash giống nhau → vulnerable); (ii) tunable cost đơn lẻ chỉ 1 tham số cost (10-12 default) → không tune được memory cost chống GPU; (iii) chỉ chống CPU brute-force, GPU attacker với hashcat vẫn nhanh hơn 100-1000× CPU.
scrypt (Colin Percival, 2009) — memory-hard. Pros: memory-hard chống ASIC + GPU bằng cách yêu cầu lượng RAM lớn (vd 64 MiB) mỗi lần hash → attacker không thể parallelize hiệu quả trên GPU/ASIC (RAM đắt + chậm hơn compute). Cons: (i) ít documented hơn argon2/bcrypt; (ii) params N/r/p khó tune đúng (N memory cost log2, r block size, p parallelism); (iii) timing side-channel attack có khả năng (data-dependent memory access).
Argon2 (Alex Biryukov + Daniel Dinu + Dmitry Khovratovich, 2015) — winner Password Hashing Competition 2013-2015 + OWASP 2024 recommendation. Pros: (a) 3 variant tách rõ trade-off: Argon2i data-independent memory access chống side-channel (vd cache-timing attack), Argon2d data-dependent chống GPU mạnh nhất nhưng vulnerable side-channel, Argon2id hybrid 2 pass đầu i + remaining d cân bằng tối ưu cho password hashing (nửa đầu chống side-channel, nửa sau chống GPU); (b) 3 tunable param memory cost (m_cost KiB) + time cost (t_cost iterations) + parallelism (p_cost threads) cho phép adapt với hardware tăng theo thời gian; (c) OWASP top recommendation 2024 cho mọi new project; (d) Rust crate argon2 0.5 mature + audit. Cons: (i) memory cost 64 MiB × parallelism có thể tốn RAM nếu server traffic cao đồng thời (giải pháp: spawn_blocking tách thread pool); (ii) ít legacy support hơn bcrypt (nhưng đủ trong Rust + Node + Python ecosystem).
Lock decision Shop API B70 — Argon2id MANDATORY:
- Variant Argon2id — cân bằng side-channel resistance + GPU resistance, default choice OWASP 2024.
- Memory cost
m_cost = 65536KiB (64 MiB) — đủ chống commodity GPU farm. - Time cost
t_cost = 3iterations — target hash time~100mstrên server commodity (Intel Xeon Skylake). - Parallelism
p_cost = 4threads — match số core average server. - Output length 32 bytes (default) — đủ collision resistance 256 bit.
- Theo OWASP Password Storage Cheat Sheet 2024 recommendation cho Argon2id.
Nếu server traffic cao đồng thời (vd 100 register/giây) thì 100 × 64 MiB = 6.4 GiB RAM peak — chấp nhận được trên VM 16 GiB; nếu tăng lên 1000 register/giây thì cần spawn_blocking + thread pool cap hoặc dùng p_cost = 1 trade off ngắn. Shop API hiện chưa cần tối ưu — B70 dùng default lock.
Cài Argon2 + Helper Module
Thêm 4 workspace dep mới (argon2 cho password, uuid + rand + hex cho email verification token):
# File: Cargo.toml (workspace root)
[workspace.dependencies]
# ... các dep cũ B69 giữ nguyên
argon2 = "0.5"
uuid = { version = "1", features = ["v4"] }
rand = "0.8"
hex = "0.4"
Thêm vào crates/shop-common/Cargo.toml:
# File: crates/shop-common/Cargo.toml
[dependencies]
# ... cũ
argon2 = { workspace = true }
Tạo file mới helper module:
// File: crates/shop-common/src/password.rs
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2, Algorithm, Version, Params,
};
use crate::error::AppError;
/// Argon2 instance configured theo lock Shop API B70:
/// - Algorithm: Argon2id (hybrid variant)
/// - Version: V0x13 (latest, RFC 9106)
/// - Params: m_cost=64MiB, t_cost=3, p_cost=4, output 32 bytes
fn argon2_instance() -> Argon2<'static> {
let params = Params::new(
65536, // m_cost: 64 MiB memory cost
3, // t_cost: 3 iterations time cost
4, // p_cost: 4 parallel threads
None, // output length (None = default 32 bytes)
)
.expect("invalid Argon2 params");
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
}
/// Hash plain password thành PHC string format:
/// `$argon2id$v=19$m=65536,t=3,p=4$<base64_salt>$<base64_hash>`
pub fn hash_password(plain: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let hash = argon2_instance()
.hash_password(plain.as_bytes(), &salt)
.map_err(|e| AppError::Internal(format!("password hash failed: {}", e)))?;
Ok(hash.to_string())
}
/// Verify plain password với PHC string đã lưu trong DB.
/// Constant-time compare argon2 internal — chống timing attack.
pub fn verify_password(plain: &str, hash_str: &str) -> Result<bool, AppError> {
let parsed = PasswordHash::new(hash_str)
.map_err(|e| AppError::Internal(format!("invalid hash format: {}", e)))?;
match argon2_instance().verify_password(plain.as_bytes(), &parsed) {
Ok(_) => Ok(true),
Err(argon2::password_hash::Error::Password) => Ok(false), // sai password, không lỗi
Err(e) => Err(AppError::Internal(format!("verify failed: {}", e))),
}
}
Re-export module top-level shop-common:
// File: crates/shop-common/src/lib.rs
pub mod password;
pub use password::{hash_password, verify_password};
Unit test verify pattern hash + verify roundtrip:
// File: crates/shop-common/src/password.rs (cuối file)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_verify_roundtrip() {
let plain = "user_password_123";
let hash = hash_password(plain).unwrap();
// PHC string starts with `$argon2id$v=19$m=65536,t=3,p=4$...`
assert!(hash.starts_with("$argon2id$v=19$m=65536,t=3,p=4$"));
// Đúng password
assert!(verify_password(plain, &hash).unwrap());
// Sai password
assert!(!verify_password("wrong_password", &hash).unwrap());
}
}
3 lock decision module password:
SaltString::generate(OsRng)— salt random 16 byte từ OS entropy mỗi lần hash mới; cùng password 2 user khác nhau sẽ ra 2 PHC string khác (chống rainbow table attack); salt embedded trong PHC string tự động không cần lưu riêng.- Constant-time compare —
argon2 crate verify_passwordinternal so sánh hash byte-by-byte với XOR + OR tích lũy, time invariant theo input → attacker không thể đo thời gian response để guess từng byte. KHÔNG dùng==string compare (short-circuit, leak length match qua thời gian). - Error::Password vs Error::* — match riêng
password_hash::Error::PasswordtrảOk(false)(password sai, không phải lỗi system); mọi error khác (corrupt hash format, RNG fail) trảAppError::Internal→ 500 trên client log + alert ops team.
RegisterDto + UpdateProfileDto
Cập nhật crates/shop-common/src/dto/user.rs (đã có User entity + UserResponseDto từ B45) thêm 2 DTO mới + 1 custom validator:
// File: crates/shop-common/src/dto/user.rs (extend B45)
use serde::Deserialize;
use validator::Validate;
use super::Phone;
#[derive(Debug, Clone, Deserialize, Validate)]
pub struct RegisterDto {
#[validate(email(message = "email không hợp lệ"))]
pub email: String,
#[validate(length(min = 8, max = 128, message = "mật khẩu phải từ 8 đến 128 ký tự"))]
#[validate(custom(function = "validate_password_strength"))]
pub password: String,
#[validate(length(min = 2, max = 100, message = "tên hiển thị phải từ 2 đến 100 ký tự"))]
pub display_name: String,
#[serde(default)]
pub phone: Option<Phone>, // B46 Phone newtype
}
#[derive(Debug, Clone, Deserialize, Validate)]
pub struct UpdateProfileDto {
#[validate(length(min = 2, max = 100))]
pub display_name: Option<String>,
// double-Option B42: phân biệt missing vs null
#[serde(default, deserialize_with = "crate::dto::deserialize_optional_field")]
pub phone: Option<Option<Phone>>,
#[serde(default, deserialize_with = "crate::dto::deserialize_optional_field")]
pub avatar_url: Option<Option<String>>,
}
/// Custom validator: password phải có ít nhất 1 upper + 1 lower + 1 digit
/// OWASP entry-level password policy
fn validate_password_strength(password: &str) -> Result<(), validator::ValidationError> {
let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
let has_digit = password.chars().any(|c| c.is_ascii_digit());
if !(has_upper && has_lower && has_digit) {
return Err(validator::ValidationError::new("weak_password"));
}
Ok(())
}
Re-export tại crates/shop-common/src/dto/mod.rs:
// File: crates/shop-common/src/dto/mod.rs (extend)
pub use user::{RegisterDto, UpdateProfileDto, User, UserResponseDto};
4 lock decision DTO B70:
- Password length 8-128 — min 8 (NIST 800-63B recommendation 2024); max 128 cap chống DoS attack attacker submit password 10 MB → argon2 hash 10 MB tốn vài giây CPU + 64 MiB RAM × p_cost = 256 MiB → 100 request đồng thời cạn RAM server. Max 128 không ảnh hưởng UX (passphrase 4 từ Diceware
~28char đủ entropy). - Password strength MANDATORY upper + lower + digit — entry-level OWASP policy, chặn
"password"+"12345678"+"qwertyui"top 100 weak password. Không yêu cầu special char (NIST 2024 update không recommend nữa, user xu hướng substitute predictablea → @, o → 0không tăng entropy thật). - display_name length 2-100 — min 2 chặn empty + 1 char nonsense; max 100 đủ tên đầy đủ + suffix (
"Nguyễn Văn A (CEO Acme Corp Vietnam)"36 char vẫn vừa). - UpdateProfileDto double-Option Option<Option<T>> lock B42 continued — phân biệt 3 trạng thái:
{}không gửi phone → giữ DB cũ;{"phone": null}client muốn clear phone → set NULL;{"phone": "+84912..."}set giá trị mới. Single Option<T> không phân biệt được missing vs null → mất 1 use case.
Email Verification Token Pattern
Tạo migration 13 (sau B69 create_carts = 12) thêm cột email_verified_at vào bảng users + bảng mới email_verification_tokens:
cargo sqlx migrate add --source crates/shop-db/migrations create_email_verification
# → tạo file crates/shop-db/migrations/20260616120000_create_email_verification.sql
Nội dung file migration:
-- File: crates/shop-db/migrations/20260616120000_create_email_verification.sql
ALTER TABLE users
ADD COLUMN email_verified_at TIMESTAMPTZ;
CREATE TABLE email_verification_tokens (
token TEXT PRIMARY KEY, -- format: <uuid_v4>_<32_byte_nonce_hex>
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email TEXT NOT NULL, -- snapshot email lúc tạo token
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '24 hours'),
used_at TIMESTAMPTZ, -- NULL = unused, NOT NULL = đã verify
CHECK (used_at IS NULL OR used_at >= created_at)
);
-- Lookup tokens theo user
CREATE INDEX email_verification_user_idx
ON email_verification_tokens(user_id);
-- Cleanup cron job G18 expired unused tokens
CREATE INDEX email_verification_expires_idx
ON email_verification_tokens(expires_at) WHERE used_at IS NULL;
5 lock decision schema migration 13:
- Token format
<uuid_v4>_<32_byte_hex_nonce>— UUID v4 (122 bit random) đảm bảo unique cross-DB; nonce 32 byte hex (256 bit random) tăng entropy chống brute-force token trên URL/users/verify-email/{token}; tổng entropy~378 bit> đủ chống brute-force trong vũ trụ lifespan. - TTL 24h — DEFAULT
(NOW() + INTERVAL '24 hours'); balance giữa UX (user kiểm mail muộn vẫn verify được) + security (token leak qua log không vĩnh viễn). Industry standard 24h (Gmail, GitHub, Stripe). - Single-use marking
used_at TIMESTAMPTZ NULL— NULL = chưa dùng, NOT NULL = đã verify lúc đó; chống replay attack attacker dùng lại token đã verify thành công. CHECK constraintused_at IS NULL OR used_at >= created_atdefensive ngăn DBA edit lệch thời gian. - Email snapshot trong bảng token — verify email không bị thay đổi giữa lúc tạo token và lúc user click link. Scenario: user register email A, sau đó update profile sang email B, token cũ vẫn refer email A → verify thành công ghi
email_verified_atcho email B không đúng. Snapshot email cho phép verify chỉ khiusers.email == token.emailhoặc skip update nếu mismatch (B70 chọn skip handle case mismatch). - FK CASCADE
user_id REFERENCES users(id) ON DELETE CASCADE— user xóa thì token tự xóa, không có token mồ côi.
register_user Function Trong shop-db
Tạo file mới crates/shop-db/src/users.rs với 4 hàm DB:
// File: crates/shop-db/src/users.rs
use sqlx::PgPool;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use rand::RngCore;
use crate::audit;
pub struct UserRow {
pub id: i64,
pub email: String,
pub password_hash: String,
pub display_name: String,
pub phone: Option<String>,
pub avatar_url: Option<String>,
pub status: String,
pub email_verified_at: Option<DateTime<Utc>>,
pub deleted_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
}
/// Register user + sinh email verification token + audit log atomic.
/// Trả `(UserRow, verify_token_string)` — caller send mail với token.
pub async fn register_user(
pool: &PgPool,
email: &str,
password_hash: &str,
display_name: &str,
phone: Option<&str>,
request_id: Option<&str>,
) -> Result<(UserRow, String), sqlx::Error> {
let mut tx = pool.begin().await?;
// 1. INSERT user mới với status 'active' default
let user = sqlx::query_as!(
UserRow,
r#"
INSERT INTO users (email, password_hash, display_name, phone, status)
VALUES ($1, $2, $3, $4, 'active')
RETURNING id, email, password_hash, display_name, phone, avatar_url, status,
email_verified_at, deleted_at, created_at, updated_at, last_login_at
"#,
email, password_hash, display_name, phone
)
.fetch_one(&mut *tx).await?;
// 2. Sinh verification token: <uuid_v4>_<32_byte_hex_nonce>
let mut nonce = [0u8; 32];
rand::thread_rng().fill_bytes(&mut nonce);
let token = format!("{}_{}", Uuid::new_v4(), hex::encode(nonce));
sqlx::query!(
"INSERT INTO email_verification_tokens (token, user_id, email) VALUES ($1, $2, $3)",
token, user.id, email
)
.execute(&mut *tx).await?;
// 3. Audit log register action (B62 audit helper)
audit::log_action(
&mut tx,
"users",
user.id,
"register",
None, // self-registration, không có actor
Some(serde_json::json!({
"email": email,
"via": "self_register",
})),
request_id,
).await?;
tx.commit().await?;
Ok((user, token))
}
pub async fn find_user_by_id(
pool: &PgPool,
user_id: i64,
) -> Result<Option<UserRow>, sqlx::Error> {
sqlx::query_as!(
UserRow,
r#"
SELECT id, email, password_hash, display_name, phone, avatar_url, status,
email_verified_at, deleted_at, created_at, updated_at, last_login_at
FROM users
WHERE id = $1 AND deleted_at IS NULL
"#,
user_id
)
.fetch_optional(pool)
.await
}
Hàm update_profile dùng pattern COALESCE + CASE WHEN double-Option lock B53 cho phép phân biệt 3 trạng thái (missing / null / value):
// File: crates/shop-db/src/users.rs (tiếp)
pub async fn update_profile(
pool: &PgPool,
user_id: i64,
display_name: Option<&str>,
phone: Option<Option<&str>>, // double-Option B42
avatar_url: Option<Option<&str>>,
request_id: Option<&str>,
) -> Result<UserRow, sqlx::Error> {
let user = sqlx::query_as!(
UserRow,
r#"
UPDATE users SET
display_name = COALESCE($2, display_name),
phone = CASE
WHEN $3::boolean THEN $4
ELSE phone
END,
avatar_url = CASE
WHEN $5::boolean THEN $6
ELSE avatar_url
END
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, email, password_hash, display_name, phone, avatar_url, status,
email_verified_at, deleted_at, created_at, updated_at, last_login_at
"#,
user_id,
display_name,
phone.is_some(), // outer Some = client gửi field
phone.flatten(), // inner Some/None = value hoặc clear
avatar_url.is_some(),
avatar_url.flatten(),
)
.fetch_one(pool)
.await?;
// Audit (đơn giản single-statement, không wrap transaction)
let _ = sqlx::query!(
r#"
INSERT INTO audit_logs (table_name, row_id, action, actor_user_id, changes, request_id)
VALUES ('users', $1, 'update_profile', $1, $2, $3)
"#,
user_id,
serde_json::json!({
"display_name": display_name,
"phone": phone,
"avatar_url": avatar_url,
}),
request_id,
)
.execute(pool).await;
Ok(user)
}
Hàm verify_email dùng transaction 4 step:
// File: crates/shop-db/src/users.rs (tiếp)
pub async fn verify_email(
pool: &PgPool,
token: &str,
) -> Result<UserRow, sqlx::Error> {
let mut tx = pool.begin().await?;
// 1. Find token row
let token_row = sqlx::query!(
r#"
SELECT user_id, email, expires_at, used_at
FROM email_verification_tokens WHERE token = $1
"#,
token
)
.fetch_optional(&mut *tx).await?
.ok_or(sqlx::Error::RowNotFound)?;
// 2. Validate single-use + expires_at
if token_row.used_at.is_some() {
tx.rollback().await?;
return Err(sqlx::Error::Configuration("token already used".into()));
}
if token_row.expires_at < Utc::now() {
tx.rollback().await?;
return Err(sqlx::Error::Configuration("token expired".into()));
}
// 3. Mark email verified
let user = sqlx::query_as!(
UserRow,
r#"
UPDATE users SET email_verified_at = NOW()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, email, password_hash, display_name, phone, avatar_url, status,
email_verified_at, deleted_at, created_at, updated_at, last_login_at
"#,
token_row.user_id
)
.fetch_one(&mut *tx).await?;
// 4. Mark token used (single-use)
sqlx::query!(
"UPDATE email_verification_tokens SET used_at = NOW() WHERE token = $1",
token
)
.execute(&mut *tx).await?;
tx.commit().await?;
Ok(user)
}
Thêm pub mod users; vào crates/shop-db/src/lib.rs.
Endpoint Handler — Register + Get Me + Update Me + Verify Email
Tạo file mới crates/shop-api/src/routes/users.rs với 4 handler:
// File: crates/shop-api/src/routes/users.rs
use axum::extract::{State, Extension, Path};
use axum::{Json, Router};
use axum::routing::{get, post};
use shop_common::dto::{RegisterDto, UpdateProfileDto, UserResponseDto, User};
use shop_common::error::AppError;
use shop_common::password::hash_password;
use shop_db::users as db;
use crate::state::AppState;
use crate::extractors::ValidatedJson;
use crate::responses::Created;
use crate::middleware::request_id::RequestId;
pub async fn register(
State(state): State<AppState>,
Extension(req_id): Extension<RequestId>,
ValidatedJson(dto): ValidatedJson<RegisterDto>,
) -> Result<Created<UserResponseDto>, AppError> {
// 1. Hash password (CPU-bound ~100ms — spawn_blocking nếu high throughput)
let password_hash = hash_password(&dto.password)?;
// 2. Insert user + generate verify token + audit log atomic
let phone_str = dto.phone.as_ref().map(|p| p.as_str().to_string());
let (user_row, verify_token) = db::register_user(
&state.db,
&dto.email,
&password_hash,
&dto.display_name,
phone_str.as_deref(),
Some(&req_id.0),
)
.await?;
// 3. TODO G16: enqueue send-verification-email job (worker async)
tracing::info!(
email = %dto.email,
token = %verify_token,
"TODO G16: send verification email to user"
);
// 4. Convert UserRow → User entity → UserResponseDto (B45 security pattern)
let user = User::from(user_row);
let response = UserResponseDto::from(user);
Ok(Created {
location: format!("/api/v1/users/{}", response.id.0),
data: response,
})
}
pub async fn get_me(
State(state): State<AppState>,
// TODO B112: extract user_id từ Extension<CurrentUser> (JWT middleware)
) -> Result<Json<UserResponseDto>, AppError> {
let user_id = 1i64; // PLACEHOLDER — B112 inject từ JWT claims
let user_row = db::find_user_by_id(&state.db, user_id).await?
.ok_or_else(|| AppError::NotFound("user not found".into()))?;
let user = User::from(user_row);
Ok(Json(UserResponseDto::from(user)))
}
pub async fn update_me(
State(state): State<AppState>,
Extension(req_id): Extension<RequestId>,
ValidatedJson(dto): ValidatedJson<UpdateProfileDto>,
) -> Result<Json<UserResponseDto>, AppError> {
let user_id = 1i64; // PLACEHOLDER B112
// double-Option Option<Option<Phone>> → Option<Option<&str>> cho DB layer
let phone_str: Option<Option<String>> = dto.phone.as_ref().map(|p| {
p.as_ref().map(|ph| ph.as_str().to_string())
});
let avatar_str: Option<Option<String>> = dto.avatar_url.as_ref().map(|a| a.as_ref().cloned());
let user_row = db::update_profile(
&state.db,
user_id,
dto.display_name.as_deref(),
phone_str.as_ref().map(|p| p.as_deref()),
avatar_str.as_ref().map(|a| a.as_deref()),
Some(&req_id.0),
)
.await?;
let user = User::from(user_row);
Ok(Json(UserResponseDto::from(user)))
}
pub async fn verify_email(
State(state): State<AppState>,
Path(token): Path<String>,
) -> Result<Json<UserResponseDto>, AppError> {
let user_row = db::verify_email(&state.db, &token).await?;
let user = User::from(user_row);
Ok(Json(UserResponseDto::from(user)))
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/users/register", post(register))
.route("/users/me", get(get_me).patch(update_me))
.route("/users/verify-email/{token}", post(verify_email))
}
Wire route trong crates/shop-api/src/router.rs:
// File: crates/shop-api/src/router.rs (extend)
let api_v1 = Router::new()
// ... routes cũ B60-B69
.merge(routes::users::routes()); // B70
Update crates/shop-api/src/routes/mod.rs thêm pub mod users;.
Implement From<UserRow> For User
B45 đã lock pattern User entity KHÔNG impl Serialize (accident-proof password_hash leak) + conversion impl From<User> for UserResponseDto explicit. B70 thêm bridge impl From<UserRow> for User cho phép convert DB row → entity:
// File: crates/shop-common/src/dto/user.rs (extend B45)
// Bridge UserRow (shop-db) → User entity (shop-common)
// Lưu ý: cần thêm dep `shop-db = { path = "../shop-db" }` vào shop-common
// HOẶC đảo chiều — define UserRow trong shop-common reuse từ shop-db
// B70 chọn cách 2 để giữ shop-common → shop-db dependency direction (clean)
impl From<shop_db::users::UserRow> for User {
fn from(row: shop_db::users::UserRow) -> Self {
User {
id: UserId(row.id as u64),
email: row.email,
password_hash: row.password_hash,
display_name: row.display_name,
avatar_url: row.avatar_url,
phone: row.phone,
created_at: row.created_at,
last_login_at: row.last_login_at,
deleted_at: row.deleted_at,
}
}
}
Note dependency direction: graph workspace lock shop-api → shop-common + shop-api → shop-db + shop-db → shop-common (shop-db dùng AppError + UserId từ shop-common). Nếu thêm shop-common → shop-db sẽ circular dep → cargo build fail. Giải pháp B70 chọn: chuyển impl From<UserRow> for User sang crates/shop-api/src/conversions.rs (file mới, dùng cả 2 crate) hoặc đặt cùng file với handler register (dùng inline). Snippet trên minh họa intent; thực tế file sẽ nằm trong shop-api.
2 lock decision security pattern B45 continued:
- User entity KHÔNG impl Serialize — nếu future developer accidentally viết
Json(user)trong handler thì compile error ngay lập tức, không silent leakpassword_hashra response. Compile-time guard mạnh hơn runtime audit. - UserResponseDto explicit conversion — chỉ field safe được expose (id, email, display_name, avatar_url, phone, created_at, last_login_at), exclude
password_hash+deleted_at(admin internal) +login_count(analytics internal nếu có). Mọi response user public PHẢI đi qua DTO này, không có shortcut.
Verify End-To-End + Security Checks
Bootstrap migration + server:
cargo sqlx migrate run --source crates/shop-db/migrations
# Applied 20260616120000/migrate create email verification (migration 13)
AUTO_MIGRATE=true cargo run -p shop-api
# shop-api listening on 0.0.0.0:3000
Test 1 — Register success:
curl -X POST http://localhost:3000/api/v1/users/register \
-H 'Content-Type: application/json' \
-d '{
"email": "[email protected]",
"password": "SecurePass123",
"display_name": "Canh Nguyen",
"phone": "+84912345678"
}'
# 201 Created
# Location: /api/v1/users/1
# {
# "id": 1,
# "email": "[email protected]",
# "display_name": "Canh Nguyen",
# "phone": "+84912345678",
# "created_at": "2026-06-16T...",
# ...
# }
# (KHÔNG có password_hash — B45 lock security)
# Server log:
# INFO shop_api::routes::users: TODO G16: send verification email to user
# [email protected] token=550e8400-e29b-...d6c_a3f8b2e1...
Test 2 — Weak password (thiếu uppercase):
curl -X POST http://localhost:3000/api/v1/users/register \
-H 'Content-Type: application/json' \
-d '{
"email": "[email protected]",
"password": "weakpw99",
"display_name": "X"
}'
# 422 Unprocessable Entity
# {
# "error": "validation failed",
# "code": "VALIDATION_FAILED",
# "details": { "password": ["weak_password"] }
# }
Test 3 — Duplicate email → 409 Conflict:
curl -X POST http://localhost:3000/api/v1/users/register \
-H 'Content-Type: application/json' \
-d '{
"email": "[email protected]",
"password": "OtherPass456",
"display_name": "Other"
}'
# 409 Conflict (SQLSTATE 23505 unique violation, B55 mapping)
# {
# "error": "duplicate entry",
# "code": "CONFLICT",
# "detail": { "constraint": "users_email_key" }
# }
Test 4 — Get me (placeholder user_id = 1):
curl http://localhost:3000/api/v1/users/me
# 200 OK
# {
# "id": 1, "email": "[email protected]", "display_name": "Canh Nguyen",
# "phone": "+84912345678", ...
# }
Test 5 — PATCH profile (double-Option semantics):
# Update display_name, giữ nguyên phone
curl -X PATCH http://localhost:3000/api/v1/users/me \
-H 'Content-Type: application/json' \
-d '{"display_name": "Canh Nguyen Updated"}'
# 200 OK, phone vẫn "+84912345678"
# Clear phone (set NULL)
curl -X PATCH http://localhost:3000/api/v1/users/me \
-H 'Content-Type: application/json' \
-d '{"phone": null}'
# 200 OK, phone = null trong DB
Test 6 — Verify email:
# Lấy token từ DB hoặc từ server log Test 1
TOKEN=$(docker compose exec -T postgres psql -U shop -d shop_dev -t -c \
"SELECT token FROM email_verification_tokens WHERE user_id = 1 AND used_at IS NULL LIMIT 1;" \
| tr -d ' ')
curl -X POST "http://localhost:3000/api/v1/users/verify-email/$TOKEN"
# 200 OK
# email_verified_at giờ có giá trị NOW()
# Verify lại lần 2 → 422/400 token already used
curl -X POST "http://localhost:3000/api/v1/users/verify-email/$TOKEN"
# 400 Bad Request (single-use guard)
Test 7 — Verify password hashed correctly trong DB:
docker compose exec postgres psql -U shop -d shop_dev -c \
"SELECT email, LEFT(password_hash, 35) AS hash_prefix FROM users LIMIT 1;"
# email | hash_prefix
# ------------------+-----------------------------------
# [email protected] | $argon2id$v=19$m=65536,t=3,p=4$Yk
#
# OK: KHÔNG plain text "SecurePass123"
# OK: Đúng Argon2id PHC string format
# OK: Params đúng lock B70: m=65536, t=3, p=4
Security verify checklist:
- Password KHÔNG plain text trong DB — PHC string Argon2id.
- Response KHÔNG include password_hash — B45 Entity/DTO pattern lock compile-time guarantee.
- Constant-time compare — argon2 internal verify_password.
- Email verification token random 378 bit entropy + TTL 24h + single-use.
- Audit log register action — trace via request_id (B62 lock continued).
- Transaction atomic register_user — không có user mồ côi không có verify token.
Tổng Kết
- Argon2id lock pattern Shop API:
m_cost = 65536(64 MiB),t_cost = 3,p_cost = 4, output 32 bytes (default). - OWASP 2024 recommendation Argon2id thay bcrypt (legacy, max 72 char) + scrypt (ít documented).
hash_password+verify_passwordhelper moduleshop-common::password— single instance config quaargon2_instance().- Constant-time compare argon2 internal — chống timing attack, KHÔNG dùng
==string compare. - SaltString::generate(OsRng) salt random 16 byte mỗi lần hash — chống rainbow table attack.
RegisterDtovalidate: email format + password length 8-128 + customvalidate_password_strengthMANDATORY upper + lower + digit + display_name 2-100 + phone Option B46.UpdateProfileDtoPATCH double-OptionOption<Option<T>>lock B42 continued cho phone + avatar_url nullable field.- Migration 13
create_email_verification: ALTER users ADDemail_verified_at TIMESTAMPTZ+ CREATE TABLEemail_verification_tokens6 cột + 2 index. - Token format
<uuid_v4>_<32_byte_hex_nonce>random 378 bit entropy + TTL 24h DEFAULT + single-useused_atmarking. - Email snapshot trong token row — verify email không bị thay đổi giữa lúc tạo và lúc click link.
register_user3 step transaction: INSERT users + INSERT email_verification_tokens + audit::log_action — atomic guarantee không user mồ côi.verify_email4 step transaction: find token + validate (expires_at + single-use) + mark verified + mark token used.- TODO G16 send email queue — placeholder log
tracing::info!, implement worker async sau (apalis-redis Project Spec). - Entity/DTO security pattern (B45 lock continued): User entity KHÔNG impl
Serializeaccident-proof password_hash leak compile-time guard. - 4 endpoint Shop API:
POST /users/register,GET /users/me,PATCH /users/me,POST /users/verify-email/{token}. - TODO B112 placeholder user_id = 1 — auth chưa wire, B112 inject từ JWT
Extension<CurrentUser>. - File path lock B70: NEW
crates/shop-common/src/password.rs+ NEWcrates/shop-db/src/users.rs+ NEWcrates/shop-api/src/routes/users.rs+ NEWcrates/shop-db/migrations/20260616120000_create_email_verification.sql+ UPDATEDshop-common/src/dto/user.rs+ UPDATEDshop-common/src/lib.rs+ UPDATEDshop-db/src/lib.rs+ UPDATEDshop-api/src/routes/mod.rs+ UPDATEDshop-api/src/router.rs. - 4 workspace dep mới:
argon2 = "0.5"+uuid = { version = "1", features = ["v4"] }+rand = "0.8"+hex = "0.4".
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Argon2 vs bcrypt — pros/cons mỗi cách? Tại sao OWASP 2024 chọn Argon2id thay vì bcrypt vốn được dùng phổ biến từ 1999?
- Constant-time compare — timing attack pattern là gì? Cho ví dụ scenario attacker tận dụng
==string compare để leak hash từng byte. - Token email verify single-use —
used_atfield check + transaction. Cho ví dụ scenario double-spend nếu KHÔNG check single-use. - Entity vs Response DTO security pattern — tại sao User KHÔNG impl
Serialize? Cho ví dụ anti-pattern accident leak password_hash trong codebase real-world. - PATCH update profile dùng double-Option
Option<Option<T>>— cho ví dụ scenario PATCH{"phone": null}vs PATCH{}khác nhau ra sao trong DB và lý do single Option<T> không phân biệt được.
Đáp án
- Argon2 vs bcrypt + lý do OWASP 2024 chọn Argon2id: bcrypt (Niels Provos + David Mazières 1999) Blowfish-based KDF. Pros: (a) battle-tested 25+ năm production scale từ Twitter, GitHub, Stripe; (b) wide language support — mọi backend ecosystem (Node, Python, PHP, Ruby, Go, Rust) đều có lib mature audit; (c) đơn giản — chỉ 1 tham số
cost10-12 dễ tune cho beginner; (d) PHC string format$2b$12$...compact 60 char. Cons: (i) max 72 byte password Blowfish key schedule giới hạn — password dài hơn bị truncate âm thầm tại byte 72, hash 2 password khác nhau cùng 72 byte đầu sẽ giống nhau → vulnerable user dùng passphrase dài; (ii) chỉ tunable CPU cost không tune được memory → GPU/ASIC attacker scale linearly với hash rate, hashcat trên RTX 4090 đạt~100k bcrypt/scost 10 → brute force common password list trong giờ; (iii) sửa lỗi$2avs$2bvs$2ynhiều variant gây confusion legacy migration. scrypt (Colin Percival 2009): memory-hard chống ASIC nhưng ít documented + timing side-channel data-dependent access vulnerable. Argon2 (PHC winner 2015): Pros: (a) 3 variant tách rõ trade-off — Argon2i data-independent chống side-channel cache-timing, Argon2d data-dependent chống GPU mạnh nhất nhưng vulnerable side-channel, Argon2id hybrid 2 pass đầu i + remaining d cân bằng tối ưu cho password hashing (recommendation default); (b) 3 tunable param memory + time + parallelism cho phép adapt với hardware evolve theo thời gian (tăng m_cost mỗi 2 năm theo Moore's law); (c) memory-hard 64 MiB × parallelism RAM cost vô hiệu hóa GPU attack (RAM bandwidth bottleneck thay vì compute); (d) RFC 9106 chuẩn IETF 2021 + audit độc lập NIST. OWASP 2024 chọn Argon2id MANDATORY new project — bcrypt vẫn OK legacy không bắt buộc migrate (cost migration cao + risk regression auth flow), nhưng project mới phải dùng Argon2id; lý do core: (i) chống brute-force tốt hơn 100-1000× GPU/ASIC nhờ memory cost; (ii) future-proof tunable param adapt hardware tương lai; (iii) no truncate issue (max input length unbounded thực tế 1 GiB); (iv) standard chuẩn RFC 9106 không có ambiguity variant. Shop API B70 lock Argon2id theo OWASP 2024 cheat sheet params m_cost=65536 + t_cost=3 + p_cost=4 cân bằng UX (hash time ~100ms server-side OK) + security (GPU farm $10k giải password 8 char với strength upper+lower+digit cần ~50 năm). - Timing attack pattern + scenario leak hash từng byte: Timing attack tận dụng side-channel thời gian response của server để suy luận thông tin private. Scenario cụ thể với
==string compare: server có hàmfn verify_token(input: &str, expected: &str) -> bool { input == expected };==implementation trong hầu hết ngôn ngữ (C strcmp, Rust String::eq, Python str.__eq__) short-circuit: trả false ngay khi gặp byte khác đầu tiên thay so sánh toàn bộ. Attack workflow: (1) attacker biết expected token format 32 char hex, gửi request POST/verify-email/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaađo thời gian response = T0 (vd 50µs); (2) gửibaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaađo T1; ... liệt kê 16 ký tự hex 0-9a-f tại vị trí 1 đo 16 timing; (3) ký tự nào có timing LỚN nhất (vd 55µs) chính là ký tự đúng position 1 — vì==phải so qua byte 1 mới fail tại byte 2, ngược lại ký tự sai fail ngay byte 1; (4) lock byte 1, lặp lại với byte 2 — 16 request đo timing chọn max; (5) tổng cộng16 × 32 = 512 requestrecover toàn bộ 32-char token. Thực tế phức tạp hơn: timing noise network → cần repeat 100-1000 lần mỗi byte averaging, tổng~500k requestnhưng vẫn feasible trong giờ với rate limit weak. Constant-time compare giải pháp:fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } let mut diff = 0u8; for i in 0..a.len() { diff |= a[i] ^ b[i]; } diff == 0 }— vòng lặp luôn chạy đủniteration không short-circuit; XOR cộng dồn vào diff; cuối cùng compare diff với 0. Thời gian response = constant theo length input, không phụ thuộc nội dung → attacker không có information leak. argon2 crate verify_password internal MANDATORY dùng constant-time compare hash byte-by-byte; KHÔNG bao giờ dùngtoken_db == token_inputhoặchash_db == hash_inputtrực tiếp. Crate Rustsubtlestandard implementation constant-time primitives —subtle::ConstantTimeEqtrait. Real-world incident: 2013 Ruby on Rails CVE-2013-1854 SHA1 token compare dùng==short-circuit → attacker recover CSRF token; fix dùngActiveSupport::SecurityUtils.secure_compareconstant-time. Generalize: mọi secret compare API key, JWT signature, HMAC tag, password hash, session ID PHẢI dùng constant-time; rule MANDATORY mọi security-critical code Shop API tương lai (B71 Stripe webhook HMAC verify, B112 JWT signature verify, G11 OAuth state token compare). - Single-use token + scenario double-spend KHÔNG check: Pattern lock B70:
email_verification_tokens.used_at TIMESTAMPTZ NULLdefault NULL, sau khi verify thành công UPDATE SET used_at = NOW();verify_emailStep 2 checkif token_row.used_at.is_some() { return error }trước khi mark verified. Scenario double-spend nếu KHÔNG check: Case 1 — replay attack token leak: (T0) user A register, token T1 sinh ra; (T0+10s) user A click link verify thành công →users.email_verified_at = NOW(); (T0+1h) attacker B chiếm được email A (vd email account A bị hack qua phishing) tìm trong inbox link verify cũ/verify-email/T1→ click lại; (T0+1h+1ms) nếu KHÔNG check used_at → server lại UPDATEemail_verified_at = NOW()+ return 200 OK → attacker biết user A đã register account → enumeration; nguy hiểm hơn nếu endpoint verify_email kèm action mạnh (vd tạo session cho user A, grant role admin, reset password trigger) thì attacker hijack account hoàn toàn. Case 2 — token leak qua log: token URL/verify-email/T1log vào nginx access log + browser history + proxy log; nếu KHÔNG single-use thì bất cứ ai đọc log đều dùng được. Case 3 — refund/coupon system (generalize pattern beyond email verify): coupon codeSAVE10giảm 10% mỗi đơn; nếu KHÔNG single-use thì user A dùng code rồi share Facebook cho 1000 bạn cùng dùng → site mất doanh thu khổng lồ. Patternused_atfield check MANDATORY cho mọi token có business value (verify email B70, password reset token B108, refund token G21, coupon code G14, OAuth authorization code G11). Transaction wrap MANDATORY Step 3 + Step 4 atomic — nếu Step 3 UPDATE users succeed nhưng Step 4 UPDATE token used_at fail (network glitch giữa 2 statement) thì token vẫn dùng được nhiều lần, vẫn vulnerable replay. Transaction commit at-once đảm bảo both succeed hoặc both rollback. Alternative pattern: thay UPDATE used_at = DELETE token row → tận dụng PostgreSQL row-level lock + đơn giản query; trade-off: mất audit trail "token T1 đã verify lúc X". Shop API B70 lock UPDATE used_at giữ audit trail. Generalize idempotency vs single-use: idempotency key B66 cho phép retry không tạo duplicate (response cached) — KHÁC single-use (chỉ effective 1 lần, retry fail). Mỗi pattern serve mục đích khác. Cron G18 cleanup DELETE WHERE expires_at < NOW() AND used_at IS NULL — partial indexemail_verification_expires_idx WHERE used_at IS NULLtối ưu query này. - Entity vs Response DTO security pattern + anti-pattern accident leak real-world: Pattern B45 lock continued B70: User entity (in
shop-common::dto::user::User) chứa toàn bộ field DB row bao gồmpassword_hash+deleted_at+internal flags; struct này KHÔNG implSerialize(chỉDebug + Clone). UserResponseDto (cùng module) implSerializechỉ chứa field public-safe (id, email, display_name, avatar_url, phone, created_at, last_login_at — exclude password_hash + deleted_at + login_count). Conversion explicit quaimpl From<User> for UserResponseDto. Tại sao User KHÔNG impl Serialize: compile-time guard mạnh hơn runtime audit. Nếu future developer (junior, sau 6 tháng quên context) viếtJson(user_entity)trong handler thì cargo build FAIL ngay với errorthe trait Serialize is not implemented for User→ developer buộc phải dừng + đọc comment + dùng UserResponseDto. Ngược lại nếu impl Serialize thì code compile OK,Json(user)chạy được, password_hash leak ra response — chỉ phát hiện qua code review hoặc bug bounty report (đã muộn). Anti-pattern accident leak real-world: (1) GitHub 2019 cve Rails app dùngrender json: @usermặc định serialize all attribute → response chứaencrypted_password+reset_token; phát hiện qua bug bounty $7500 trả thưởng. (2) Twitter 2022 OAuth endpoint accidentally return refresh_token trong response error path → user thấy refresh_token của họ trong console browser, có thể bị stolen qua XSS. (3) Linear startup 2023 Postgres querySELECT * FROM userstrong API GraphQL resolver — schema GraphQL không khai báo password_hash field nhưng debug log dump full struct → log file chứa password_hash leak qua centralized log Datadog. (4) Spring Boot common default@RestControllerreturn Entity Hibernate trực tiếp serialize qua Jackson — Jackson serialize toàn bộ getter bao gồm getPassword(). Best practice Java migration: dùng@JsonIgnoreper-field hoặc tách DTO layer hoàn toàn. Lock pattern Shop API B70: 2 layer enforce — (a) compile-time Entity không impl Serialize (B45 + B70 continued); (b) runtime middleware audit log nếu detectpassword_hashstring trong response body (defense in depth, deploy G19+). Generalize: pattern Entity (internal) vs DTO (boundary) áp dụng cho mọi sensitive entity Shop API: User B45/B70 + Payment B71 (exclude payment_payload chứa Stripe full card detail) + Order B65 (exclude internal_notes admin) + Cart B69 (exclude session_id raw). Rule MANDATORY: nếu entity có ít nhất 1 field sensitive thì KHÔNG impl Serialize trên entity, tách DTO Response explicit conversion. - Double-Option scenario phone null vs missing + lý do single Option không đủ: Lock B42 continued B70:
UpdateProfileDto.phone: Option<Option<Phone>>+ helperdeserialize_optional_fieldphân biệt 3 trạng thái JSON. Scenario JSON request: Case A —{}empty body: outer Option = None (field missing trong JSON); semantic: "tôi không muốn change phone, giữ nguyên giá trị DB cũ"; DB SQL:UPDATE users SET phone = phone WHERE id = $1(no-op CASE WHEN false). Case B —{"phone": null}: outer Some(inner None) (field tồn tại với value null); semantic: "tôi muốn clear phone hiện tại, set thành NULL"; DB SQL:UPDATE users SET phone = NULL WHERE id = $1(CASE WHEN true THEN $4=NULL). Case C —{"phone": "+84912345678"}: outer Some(inner Some("+84...")); semantic: "set phone thành giá trị mới"; DB SQL:UPDATE users SET phone = '+84912...' WHERE id = $1. Tại sao single Option<T> KHÔNG đủ: nếu khai báophone: Option<Phone>single layer thì serde behavior mặc định Case A và Case B trở thành cùng kết quảNone(vì#[serde(default)]Option fill None cho cả 2 case missing + null). Backend không phân biệt được "user không gửi field" vs "user muốn clear field" → 2 use case khác nhau bị merge → mất tính năng. Solution Rust idiomatic:Option<Option<T>>+ custom deserializerdeserialize_optional_field(đã define B42): hàm này read JSON value; nếu field hoàn toàn missing → outer None; nếu field có valuenull→ outer Some(None); nếu field có value cụ thể → outer Some(Some(T)). 3 case mapping 1-1 với JSON shape input. DB SQL trick CASE WHEN flag boolean:UPDATE users SET phone = CASE WHEN $3::boolean THEN $4 ELSE phone END— $3 =phone.is_some()(outer Some?), $4 =phone.flatten()(inner value or NULL). Khi outer None ($3 = false) → giữ nguyên DB; khi outer Some ($3 = true) → set $4 (có thể là NULL cho clear hoặc value mới). 1 statement handle cả 3 case atomic. Generalize: pattern double-Option lock Shop API cho mọi PATCH endpoint có field nullable: B70 UpdateProfileDto phone + avatar_url, B66 UpdateOrderDto note (single Option nếu chỉ 2 case missing vs set, không cần clear), B53 UpdateProductDto description + tags (double-Option vì có case clear). Decision rule: nếu field DB cho phép NULL VÀ client có use case set NULL explicit thì PHẢI dùng double-Option; nếu chỉ có 2 use case (missing = keep, value = set, không bao giờ clear) thì single Option đủ. Pattern industry parallel: GraphQLnullable: true+ JSON Merge Patch RFC 7396 ({"phone": null}= remove) + JSON Patch RFC 6902 ({"op": "remove", "path": "/phone"}explicit op). Shop API B42 chọn JSON Merge Patch semantic implicit qua double-Option Rust + serde.
Bài Tiếp Theo
Bài 71: Payment Stripe Integration Preview — chi tiết Stripe API integration: create PaymentIntent client_secret cho frontend Elements + confirm payment server-side + webhook signature verify (B37 lock continued HMAC SHA-256 constant-time compare) + payment_payload JSONB query @> pattern (B60 lock continued); áp Shop API checkout flow B69 với Stripe payment method (thay Cash on Delivery) + idempotency key Stripe-side (Stripe-native idem key) integrate với Idempotency middleware B66; lock decision Stripe vs PayPal vs Adyen cho Shop API; foundation cho B73 (refund flow), G16 (webhook job queue), G21 (3D Secure SCA challenge).
