Rust 常見的雜湊 crate 有 argon2 與 bcrypt ,接下來我們先分析,再來決定採用哪個。
若無相容性限制,優先選 Argon2(argon2id)。
把下列加入 Cargo.toml dependencies:
argon2 = "0.5" # 或最新穩定版本
password-hash = "0.5" # argon2 會用到,用於 decode/verify
這個模組包含: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?
}
說明:
我們現有的 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秒
POST /users/login
{
"username_or_email": "user1",
"password": "pw"
}
POST /users/login
{
"username_or_email": "user1@a.com",
"password": "pw"
}
POST /users/login
{
"username_or_email": "user1",
"password": "1' OR '1'='1"
}
範例(判斷是否需要 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
}
}
重點