Danh sách bài viết

Bài 70: Users Register + Profile — Argon2 + Email Verification

Bài 70 của series Rust RESTful API — bài CODE thực tế security-critical nối tiếp B69 (5 endpoint cart DB-backed + checkout flow convert cart → order + migration 12 carts + cart_items + UPSERT pattern + cart consolidation guest → user preview B112): triển khai 4 endpoint users (POST /api/v1/users/register tạo account mới với password hash argon2id + sinh email verification token, GET /api/v1/users/me lấy profile current user, PATCH /api/v1/users/me update profile với double-Option PATCH B42 cho phone + avatar_url nullable, POST /api/v1/users/verify-email/{token} xác thực email qua token TTL 24h single-use); so sánh argon2 vs bcrypt vs scrypt — bcrypt legacy 1999 max 72 char tunable cost factor đơn lẻ + scrypt 2009 memory-hard chống ASIC ít documented + Argon2 2015 winner OWASP 2024 recommendation 3 variant (i, d, id) memory + time + parallelism tunable cân bằng side-channel resistance; lock decision Shop API Argon2id với m_cost = 65536 (64 MiB), t_cost = 3 iterations, p_cost = 4 parallel threads, output 32-byte hash — OWASP 2024 cheat sheet recommendation; module mới crates/shop-common/src/password.rs với 3 helper argon2_instance() config single instance + hash_password(plain) -> String sinh PHC string với salt random qua SaltString::generate(OsRng) + verify_password(plain, hash_str) -> bool parse PHC string + constant-time compare argon2 internal chống timing attack; migration 13 mới 20260616120000_create_email_verification.sql ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ nullable + CREATE TABLE email_verification_tokens 6 cột (token PRIMARY KEY format <uuid_v4>_<32_byte_hex_nonce> random + user_id FK CASCADE references users + email snapshot tránh thay đổi giữa lúc + created_at + expires_at DEFAULT (NOW() + INTERVAL '24 hours') TTL + used_at NULLABLE single-use marking + CHECK used_at IS NULL OR used_at >= created_at) + 2 index user lookup + partial expires WHERE used_at IS NULL; DTO mới RegisterDto 4 field (email validate format + password validate length 8-128 + custom validate_password_strength MANDATORY upper + lower + digit + display_name length 2-100 + phone Option<Phone> B46 newtype) + UpdateProfileDto 3 field (display_name Option + phone double-Option Option<Option<Phone>> + avatar_url double-Option lock B42 continued); module mới crates/shop-db/src/users.rs với UserRow 12 field (B65 11 + email_verified_at) + 4 hàm DB register_user 3 step transaction (INSERT users + INSERT email_verification_tokens + audit::log_action) + find_user_by_id filter deleted_at IS NULL + update_profile COALESCE + CASE WHEN double-Option pattern lock B53 + verify_email 4 step transaction (find token + validate expires_at + single-use + mark verified + mark token used); module mới crates/shop-api/src/routes/users.rs với 4 handler register + get_me + update_me + verify_email + TODO G16 send email queue placeholder log tracing::info! + TODO B112 placeholder user_id = 1 chưa enforce auth (B112 inject từ JWT claims); Entity/DTO security pattern B45 lock continued User entity KHÔNG impl Serialize accident-proof password_hash leak, conversion qua impl From<UserRow> for User + impl From<User> for UserResponseDto 2 step explicit; 3 workspace dep mới argon2 = "0.5" + uuid = { version = "1", features = ["v4"] } + rand = "0.8" + hex = "0.4"; foundation cho B71 (Stripe payment integration reuse pattern), B112 (JWT login + auth middleware extract user_id thay placeholder), G11 (auth deep), G16 (email worker queue async send verification mail).

16/06/2026
14 phút đọc
2 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Implement POST /api/v1/users/register với password hash argon2id (OWASP 2024 recommendation, params m_cost = 64 MiB + t_cost = 3 + p_cost = 4).
  • Implement GET /api/v1/users/me lấy profile current user + PATCH /api/v1/users/me update 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_password argon2 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 qua tracing::info! log (G16 implement queue thật).
  • RegisterDto validate email format + password strength (upper + lower + digit + length 8-128) + display_name 2-100 + UpdateProfileDto double-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).
2

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 = 65536 KiB (64 MiB) — đủ chống commodity GPU farm.
  • Time cost t_cost = 3 iterations — target hash time ~100ms trên server commodity (Intel Xeon Skylake).
  • Parallelism p_cost = 4 threads — 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.

3

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 compareargon2 crate verify_password internal 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::Password trả 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.
4

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 ~28 char đủ 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 predictable a → @, o → 0 khô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.
5

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 constraint used_at IS NULL OR used_at >= created_at defensive 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_at cho email B không đúng. Snapshot email cho phép verify chỉ khi users.email == token.email hoặ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.
6

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.

7

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;.

8

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 leak password_hash ra 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.
9

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.
10

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_password helper module shop-common::password — single instance config qua argon2_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.
  • RegisterDto validate: email format + password length 8-128 + custom validate_password_strength MANDATORY upper + lower + digit + display_name 2-100 + phone Option B46.
  • UpdateProfileDto PATCH double-Option Option<Option<T>> lock B42 continued cho phone + avatar_url nullable field.
  • Migration 13 create_email_verification: ALTER users ADD email_verified_at TIMESTAMPTZ + CREATE TABLE email_verification_tokens 6 cột + 2 index.
  • Token format <uuid_v4>_<32_byte_hex_nonce> random 378 bit entropy + TTL 24h DEFAULT + single-use used_at marking.
  • Email snapshot trong token row — verify email không bị thay đổi giữa lúc tạo và lúc click link.
  • register_user 3 step transaction: INSERT users + INSERT email_verification_tokens + audit::log_action — atomic guarantee không user mồ côi.
  • verify_email 4 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 Serialize accident-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 + NEW crates/shop-db/src/users.rs + NEW crates/shop-api/src/routes/users.rs + NEW crates/shop-db/migrations/20260616120000_create_email_verification.sql + UPDATED shop-common/src/dto/user.rs + UPDATED shop-common/src/lib.rs + UPDATED shop-db/src/lib.rs + UPDATED shop-api/src/routes/mod.rs + UPDATED shop-api/src/router.rs.
  • 4 workspace dep mới: argon2 = "0.5" + uuid = { version = "1", features = ["v4"] } + rand = "0.8" + hex = "0.4".
11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. 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?
  2. Constant-time compare — timing attack pattern là gì? Cho ví dụ scenario attacker tận dụng == string compare để leak hash từng byte.
  3. Token email verify single-useused_at field check + transaction. Cho ví dụ scenario double-spend nếu KHÔNG check single-use.
  4. 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.
  5. 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
  1. 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ố cost 10-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/s cost 10 → brute force common password list trong giờ; (iii) sửa lỗi $2a vs $2b vs $2y nhiề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).
  2. 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àm fn 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ửi baaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa đ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ộng 16 × 32 = 512 request recover 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 request như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 đủ n iteration 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ùng token_db == token_input hoặc hash_db == hash_input trực tiếp. Crate Rust subtle standard implementation constant-time primitives — subtle::ConstantTimeEq trait. Real-world incident: 2013 Ruby on Rails CVE-2013-1854 SHA1 token compare dùng == short-circuit → attacker recover CSRF token; fix dùng ActiveSupport::SecurityUtils.secure_compare constant-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).
  3. Single-use token + scenario double-spend KHÔNG check: Pattern lock B70: email_verification_tokens.used_at TIMESTAMPTZ NULL default NULL, sau khi verify thành công UPDATE SET used_at = NOW(); verify_email Step 2 check if 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 UPDATE email_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/T1 log 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 code SAVE10 giả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ồ. Pattern used_at field 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 index email_verification_expires_idx WHERE used_at IS NULL tối ưu query này.
  4. 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ồm password_hash + deleted_at + internal flags; struct này KHÔNG impl Serialize (chỉ Debug + Clone). UserResponseDto (cùng module) impl Serialize chỉ 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 qua impl 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ết Json(user_entity) trong handler thì cargo build FAIL ngay với error the 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ùng render json: @user mặc định serialize all attribute → response chứa encrypted_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 query SELECT * FROM users trong 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 @RestController return 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 @JsonIgnore per-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 detect password_hash string 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.
  5. Double-Option scenario phone null vs missing + lý do single Option không đủ: Lock B42 continued B70: UpdateProfileDto.phone: Option<Option<Phone>> + helper deserialize_optional_field phâ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áo phone: 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 deserializer deserialize_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ó value null → 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: GraphQL nullable: 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.
12

Bài Tiếp Theo

— 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).