iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0
Rust

Rust 後端入門系列 第 20

Day 20 Axum與密碼安全

  • 分享至 

  • xImage
  •  

Rust 常見的雜湊 crate 有 argon2 與 bcrypt ,接下來我們先分析,再來決定採用哪個。

argon2 與 bcrypt 的比較

  • bcrypt
    • 歷史久、跨語言支援廣,適合大部分場景。
    • 參數主要是 cost(work factor),較易設定但在大型記憶體抗性上不如 Argon2。
  • Argon2(推薦 argon2id)
    • 現代化、設計上抵抗 GPU 破解與時間/記憶體平衡攻擊較佳。支援多個參數:memory_cost (KB)、time_cost (iterations)、parallelism(threads)。
    • 建議使用 argon2id(兼顧 preimage 與 side-channel 抵抗)。

若無相容性限制,優先選 Argon2(argon2id)。

salt 與存放雜湊

  • Salt 功用:避免相同密碼對應相同雜湊,阻礙 rainbow table 攻擊。
  • 不要自行手動管理 salt:使用雜湊函式庫會自動產生隨機 salt,並把 salt 與參數與雜湊結果一起 encode 到同一字串。
  • DB 只存該 encoded string 到 password_hash 欄位。

參數選擇(Argon2 )

  • 常用參數(範例,需依你主機性能調整):
    • memory_cost (KB):65536 (代表 64 MB)
    • time_cost (iterations):3
    • parallelism:1 或 2(依 CPU core 與併發考量)
  • 設定原則:以安全為主,並在目標硬體上測試雜湊時間。目標是雜湊操作在單次執行耗時在 100ms ~ 500ms 範圍內(太慢會影響使用者體驗,太快則降低攻擊阻力)。
  • bcrypt 的 cost 常見為 10 ~ 12(需視硬體與延遲需求調整)。

必要依賴

把下列加入 Cargo.toml dependencies:

argon2 = "0.5"          # 或最新穩定版本
password-hash = "0.5"   # argon2 會用到,用於 decode/verify

程式碼

src/password.rs

這個模組包含:hash_password()(在 blocking thread 做雜湊)、verify_password()(驗證),以及一個 helper 用於同步呼叫(皆用 spawn_blocking)。

請在專案新增 src/password.rs,內容如下:

use argon2::{Argon2, PasswordHasher, PasswordVerifier, Params, password_hash::{SaltString, rand_core::OsRng, PasswordHash, rand_core}};
use tokio::task;
use anyhow::Result;

/// Argon2 參數(可依環境調整)
fn default_argon2_params() -> Params {
    // 這裡的 memory_size 單位是 KB(例如 65536 KB = 64 MB)
    // time_cost = iterations
    // lanes = parallelism
    Params::new(65536, 3, 1, None).expect("invalid argon2 params")
}

/// 非同步呼叫:將明文密碼雜湊成 encoded string(blocking work)
pub async fn hash_password(password: String) -> Result<String> {
    // spawn_blocking 避免阻塞
    task::spawn_blocking(move || {
        // 使用 Argon2id
        let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, default_argon2_params());
        // 自動產生 salt
        let salt = SaltString::generate(&mut OsRng);
        // hash
        let password_hash = argon2.hash_password(password.as_bytes(), &salt)
            .map_err(|e| anyhow::anyhow!("argon2 hash error: {}", e))?
            .to_string();
        Ok(password_hash)
    }).await?
}

/// 非同步呼叫:驗證明文密碼是否與已儲存的 hash 相符
pub async fn verify_password(password: String, password_hash: String) -> Result<bool> {
    task::spawn_blocking(move || {
        let parsed_hash = PasswordHash::new(&password_hash)
            .map_err(|e| anyhow::anyhow!("invalid password hash format: {}", e))?;
        let argon2 = Argon2::default();
        match argon2.verify_password(password.as_bytes(), &parsed_hash) {
            Ok(_) => Ok(true),
            Err(argon2::password_hash::Error::Password) => Ok(false),
            Err(e) => Err(anyhow::anyhow!("argon2 verify error: {}", e)),
        }
    }).await?
}

說明:

  • 我們用 Argon2id 與自定義 Params(memory=64MB,time=3,parallelism=1)為範例。你可以根據實際硬體調整。
  • hash_password 與 verify_password 都用 tokio::task::spawn_blocking,把 CPU-heavy 工作放到 blocking thread pool。spawn_blocking 會回傳 JoinHandle 的結果,並以 anyhow::Result 包裝錯誤以便 handler 處理。
  • 最終 password_hash 存的是 Argon2 的 encoded string(包含參數與 salt)。

把雜湊整合到 handlers(create_user / update_user)

我們現有的 create_user handler 現在直接把 payload.password 存入 DB。現在將改成:先呼叫 hash_password(password).await,拿到 hash 後存入 DB,再把返回的 DTO(不包含 password_hash)回傳並在寫入 cache。

use crate::password::{hash_password, verify_password};

pub async fn create_user(
    Extension(pool): Extension<PgPool>,
    Extension(mut redis): Extension<MultiplexedConnection>,
    Json(payload): Json<CreateUser>,
) -> Result<impl IntoResponse, AppError> {
 // ...
    let password_hash = hash_password(payload.password.clone())
        .await
        .map_err(|e| internal_err(e))?;
    let rec = //...;
}

update_user(如果 payload.password 有值,則 hash 再更新):

let new_password_hash = match payload.password {
        Some(p) => hash_password(p).await.map_err(|e| internal_err(e))?, 
        None => existing.password_hash,
    };

重要: update_user 與 create_user 會把 password_hash 存進 DB,請確保返回給 client 的 UserResponse 不含 password_hash)。

驗證密碼(登入流程範例)

新增登入路由(/login),用 verify_password 比對。

下面是簡單示範登入 handler:

use crate::models::LoginRequest;

// POST /users/login -> Login
// LoginRequest { username_or_email, password }
pub async fn login(
    Extension(pool): Extension<PgPool>,
    Json(payload): Json<LoginRequest>,
) -> Result<impl IntoResponse, AppError> {
    // 1. 先查 DB 取得 user row by username or email
    let user = sqlx::query_as::<_, User>(
        "SELECT id, username, email, password_hash, created_at, updated_at FROM users WHERE username = $1 OR email = $1"
    )
    .bind(&payload.username_or_email)
    .fetch_optional(&pool)
    .await
    .map_err(|e| internal_err(e))?;

    let user = match user {
        Some(u) => u,
        None => return Err((StatusCode::UNAUTHORIZED, "invalid credentials".to_string())),
    };

    // 2. 驗證密碼
    let ok = verify_password(payload.password.clone(), user.password_hash.clone())
        .await
        .map_err(|e| internal_err(e))?;

    if !ok {
        return Err((StatusCode::UNAUTHORIZED, "invalid credentials".to_string()));
    }

    // 3. 產生 session / jwt 等(此處略)
    Ok((StatusCode::OK, Json(json!({"message":"login ok"}))))
}

models.rs

// 用於登入
#[derive(Serialize, Deserialize, Clone)]
pub struct LoginRequest {
	pub username_or_email: String,
	pub password: String,
}

main.rs

mod password;

#[tokio::main]
async fn main() {
		//...
	let app = Router::new()
	    .route("/users", post(handlers::create_user).get(handlers::list_users))
	    .route(
	        "/users/{id}",
	        get(handlers::get_user)
	            .put(handlers::update_user)
	            .delete(handlers::delete_user),
	    )
		.route("/users/login", post(handlers::login))
	    .layer(Extension(pool))
		.layer(Extension(redis_conn));
		//...
}

安全考量:對失敗的驗證回應不要透露是 user 不存在還是密碼錯誤(避免 user enumeration),可統一回 401 並訊息簡短。

測試

驗證密碼可能需要1-2秒

  1. 使用 username 登入(正常)
POST /users/login

{

"username_or_email": "user1",
"password": "pw"

}
  1. 使用 email 登入(正常)
POST /users/login

{

"username_or_email": "user1@a.com",
"password": "pw"

}
  1. 嘗試SQL注入(登入失敗,401)
POST /users/login

{

"username_or_email": "user1",
"password": "1' OR '1'='1"

}

參數變更與密碼遷移

  • 若日後想調整 Argon2 參數(例如提高 memory 或 iterations),需要考慮已存在使用者的舊雜湊。常見做法為:
    • 在驗證成功時檢查雜湊參數是否低於當前標準(parse encoded hash 的參數),若不是最新則在使用者成功登入後重新以新參數雜湊並更新 DB(稱為「延伸式遷移」)。
  • Argon2 的 PasswordHash 可以解析參數,你可以在 verify 時檢查是否需要 rehash。

範例(判斷是否需要 rehash):

use argon2::password_hash::SaltString;
use argon2::password_hash::PasswordHash;

fn needs_rehash(stored: &str) -> bool {
    if let Ok(ph) = PasswordHash::new(stored) {
        // 解析 ph.params 來判斷是否低於你期待的參數
        // 實作上你可以比對 memory_cost / time_cost / parallelism
        true
    } else {
        false
    }
}

重點

  • 儲存密碼只存雜湊結果(不可存明文、禁止使用可逆編碼)。建議使用 Argon2 做雜湊;若要兼顧相容性也可選 bcrypt,但 Argon2 為更現代且在競賽中評價更好。
  • 不要手動產生或儲存 salt:使用成熟的雜湊函式庫(argon2 / bcrypt crate)會自動處理 salt 並把 salt 與參數一起編碼到最終雜湊字串(存為 single string)。
  • 雜湊應在 blocking thread 執行(spawn_blocking)。
  • 在 create/update 時同步完成 DB 寫入與雜湊(或先雜湊再寫 DB),若要降低延遲可用背景任務,但那會增加同步一致性與錯誤處理複雜度。
  • 儲存在 DB 的欄位(password_hash)放雜湊後的 string(例如 Argon2 encoded form),回傳 API 時絕對不可包含 password_hash。

上一篇
Day 19 Axum 加入快取(Redis)與資料一致性策略
下一篇
Day 21 Axum 專案整合 CORS/Tracing
系列文
Rust 後端入門25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言