基于密码的鉴权

让我们从理论到实践:如何实现身份验证?

在我们提到的三种方法中,密码验证看起来是最简单的。

我们应该如何将用户名和密码传递给 API?

基本鉴权

我们可以使用“基本”身份验证方案,这是互联网工程任务组 (IETF) 在 RFC 2617 中定义的标准,后来由 RFC 7617 更新。

API 必须在传入请求中查找授权标头,其结构如下: Authorization: Basic <encoded credentials> 其中 <encoded credentials>{username}:{password} 的 base64 编码。

根据规范,我们需要将 API 划分为多个保护空间或域 - 同一域内的资源使用相同的身份验证方案和凭证集进行保护。 我们只需要保护一个端点 - POST /newsletters。因此,我们将使用一个名为 publish 的域。 API 必须拒绝所有缺少标头或使用无效凭证的请求 - 响应必须使用 401 Unauthorized 状态码,并包含一个包含质询的特殊标头 WWW-Authenticate

挑战是一个字符串,用于向 API 调用者解释我们期望在相关领域看到哪种类型的身份验证方案。

在我们的例子中,使用基本身份验证,它应该是:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="publish"

让我们来实现它!

提取凭证

从传入请求中提取用户名和密码 将是我们的第一个里程碑。

让我们先从一个不太愉快的情况开始——没有 Authorization 标头的传入请求被拒绝。

//! tests/api/newsletter.rs
// [...]

#[tokio::test]
async fn requests_missing_authorization_are_rejected() {
    // Arrange
    let app = spawn_app().await;

    let response = reqwest::Client::new()
        .post(&format!("{}/newsletters", &app.address))
        .json(&serde_json::json!({
            "title": "Newsletter title",
            "content": {
                "text": "Newsletter body as plain text",
                "html": "<p>Newsletter body as HTML</p>",
            }
        }))
        .send()
            .await
            .expect("Failed to execute request.");

    // Assert
    assert_eq!(401, response.status().as_u16());
    assert_eq!(r#"Basic realm="publish""#, response.headers()["WWW-Authenticate"]);
}

它在第一个断言时就失败了:

thread 'newsletter::requests_missing_authorization_are_rejected' panicked at tes
ts/api/newsletter.rs:158:5:
assertion `left == right` failed
  left: 401
 right: 200

我们必须更新处理程序以满足新的要求。

我们可以使用 HttpRequest 提取器来获取与传入请求关联的标头:

//! src/routes/newsletters.rs
// [...]
pub async fn publish_newsletter(
    body: web::Json<BodyData>,
    pool: web::Data<PgPool>,
    email_client: web::Data<EmailClient>,
    request: HttpRequest,
) -> Result<HttpResponse, PublishError> {
    let _credentials = basic_authentication(request.headers());
    // [...]
}

struct Credentials {
    username: String,
    password: SecretString,
}

fn basic_authentication(headers: &HeaderMap) -> Result<Credentials, anyhow::Error> {
    todo!()
}

要提取凭证,我们需要处理 base64 编码。 让我们将 base64 crate 添加为依赖项:

cargo add base64

现在我们可以写下 basic_authentication 的主体:

//! src/routes/newsletters.rs
// [...]

fn basic_authentication(headers: &HeaderMap) -> Result<Credentials, anyhow::Error> {
    // The header value, if present, must be a valid UTF8 string
    let header_value = headers
        .get("Authorization")
        .context("The 'Authorization' header was missing")?
        .to_str()
        .context("The 'Authorization' header was not a valid UTF8 string.")?;
    let base64_encoded_segment = header_value
        .strip_prefix("Basic ")
        .context("The authorization scheme was not 'Basic'.")?;
    let decoded_bytes = base64::engine::general_purpose::STANDARD
        .decode(base64_encoded_segment)
        .context("Failed to base64-decode 'Basic' credentials.")?;
    let decoded_credentials = String::from_utf8(decoded_bytes)
        .context("The decoded credential string is not valid UTF8.")?;

    // APlit into two segments, using ':' as delimitator
    let mut credentials = decoded_credentials.splitn(2, ':');
    let username = credentials
        .next()
        .ok_or_else(|| anyhow::anyhow!("A useranme must be provided in 'Basic' auth."))?
        .to_string();
    let password = credentials
        .next()
        .ok_or_else(|| anyhow::anyhow!("A password must be provided in 'Basic' auth."))?
        .to_string();

    Ok(Credentials {
        username,
        password: SecretString::from(password),
    })
}

花点时间逐行检查代码,彻底理解发生了什么。很多操作都可能出错!

把 RFC 和书放在一起打开,会很有帮助!

我们还没完——我们的测试仍然失败。

我们需要根据 basic_authentication 返回的错误采取行动:

//! src/routes/newsletters.rs
// [...]
#[derive(thiserror::Error)]
pub enum PublishError {
    // New error variant!
    #[error("Authentication failed.")]
    AuthError(#[source] anyhow::Error),
    #[error(transparent)]
    UnexpectedError(#[from] anyhow::Error),
}


impl ResponseError for PublishError {
    fn status_code(&self) -> actix_web::http::StatusCode {
        match self {
            PublishError::AuthError(_) => StatusCode::UNAUTHORIZED,
            // Return a 401 for auth errors
            PublishError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

pub async fn publish_newsletter(
    // [...]
) -> Result<HttpResponse, PublishError> {
    let _credentials = basic_authentication(request.headers())
        // Bubble up the error, performing the necessary conversion
        .map_err(PublishError::AuthError)?;
    // [...]
}

我们的状态代码断言现在很满意,但标头断言还不满意:

thread 'newsletter::requests_missing_authorization_are_rejected' panicked at tes
ts/api/newsletter.rs:159:62:
no entry found for key "WWW-Authenticate"

到目前为止,指定每个错误返回的状态码已经足够了——现在我们需要更多的东西,一个报头。

我们需要将重点从 ResponseError::status_code 转移到 ResponseError::error_response:

//! src/routes/newsletters.rs
// [...]
impl ResponseError for PublishError {
    fn error_response(&self) -> HttpResponse<actix_web::body::BoxBody> {
        match self {
            PublishError::UnexpectedError(_) => {
                HttpResponse::new(StatusCode::INTERNAL_SERVER_ERROR)
            }
            PublishError::AuthError(_) => {
                let mut response = HttpResponse::new(StatusCode::UNAUTHORIZED);
                let header_value = HeaderValue::from_str(r#"Basic realm="publish""#)
                    .unwrap();
                response
                    .headers_mut()
                    // actix_web::http::header provides a collection of constants
                    // for the names of several well-known/standard HTTP headers
                    .insert(header::WWW_AUTHENTICATE, header_value);
                response
            },
        }   
    }
    // `status_code` is invoked by the default `error_response`
    // implementation. We are providing a bespoke `error_response` implementation
    // therefore there is no need to maintain a `status_code` implementation anymore.
}

我们的身份验证测试通过了!

不过,一些旧测试还是有问题:

thread 'newsletter::newsletters_are_not_delivered_to_unconfirmed_subscribers' pa
nicked at tests/api/newsletter.rs:34:5:
assertion `left == right` failed
  left: 401
 right: 200

thread 'newsletter::newsletters_are_delivered_to_confirmed_subscribers' panicked at tests/api/newsl
etter.rs:102:5:
assertion `left == right` failed
  left: 401
 right: 200

POST /newsletters 现在会拒绝所有未经身份验证的请求,包括我们在快乐路径黑盒测试中发出的请求。

我们可以通过提供随机的用户名和密码组合来阻止这种情况:

//! tests/api/helpers.rs
// [...]
impl TestApp {
    pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response {
        reqwest::Client::new()
            .post(&format!("{}/newsletters", &self.address))
            // Random credentials!
            // `reqwest` does all the encoding/formatting heavy-lefting for us.
            .basic_auth(Uuid::new_v4().to_string(), Some(Uuid::new_v4().to_string()))
            .json(&body)
            .send()
            .await
            .expect("Failed to execute request.")
    }

    // [...]
}

测试套件应该再次通过。

密码验证 - 简单方法

接受随机凭证的身份验证层...并不理想。

我们需要开始验证从授权标头中提取的凭证——它们 应该与已知用户列表进行比较。

我们将创建一个新的用户 Postgres 表来存储此列表:

sqlx migrate add create_users_table

该模式的初稿可能如下所示:

-- migrations/<date>_create_users_table.sql
CREATE TABLE users(
  user_id uuid PRIMARY KEY,
  username TEXT NOT NULL UNIQUE,
  password TEXT NOT NULL
);

然后我们可以更新我们的处理程序以便在每次执行身份验证时查询它:

//! src/routes/newsletters.rs
use secrecy::ExposeSecret;
// [...]

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let user_id: Option<_> = sqlx::query!(
        r#"
        SELECT user_id
        FROM users
        WHERE username = $1 AND password = $2
        "#,
        credentials.username,
        credentials.password.expose_secret()
    )
        .fetch_optional(pool)
        .await
        .context("Failed to perform a query to validate auth credentials.")
        .map_err(PublishError::UnexpectedError)?;

    user_id
        .map(|row| row.user_id)
        .ok_or_else(|| anyhow::anyhow!("Invalid username or password."))
        .map_err(PublishError::AuthError)
}

pub async fn publish_newsletter(
    body: web::Json<BodyData>,
    pool: web::Data<PgPool>,
    email_client: web::Data<EmailClient>,
    request: HttpRequest,
) -> Result<HttpResponse, PublishError> {
    let credentials = basic_authentication(request.headers())
        // Bubble up the error, performing the necessary conversion
        .map_err(PublishError::AuthError)?;
    let user_id = validate_credentials(credentials, &pool).await?;
    // [...]
}

记录谁调用了 POST /newsletters 是个好主意——让我们在处理程序周围添加一个 tracing span:

//! src/routes/newsletters.rs
// [...]

#[tracing::instrument(
    name = "Publish a newsletter issue",
    skip(body, pool, email_client, request),
    fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
pub async fn publish_newsletter(
    body: web::Json<BodyData>,
    pool: web::Data<PgPool>,
    email_client: web::Data<EmailClient>,
    request: HttpRequest,
) -> Result<HttpResponse, PublishError> {
    let credentials = basic_authentication(request.headers())
        // Bubble up the error, performing the necessary conversion
        .map_err(PublishError::AuthError)?;
    tracing::Span::current().record(
        "username", 
        &tracing::field::display(&credentials.username)
    );
    let user_id = validate_credentials(credentials, &pool)
        .await?;
    tracing::Span::current().record("user_id", &tracing::field::display(user_id));
    // [...]
}

现在,我们需要更新我们的快乐路径测试,以指定一个能被 validate_credentials 接受的用户名-密码对。

我们将为测试应用的每个实例生成一个测试用户。我们尚未实现新闻通讯编辑者的注册流程,因此我们无法采用完全黑盒的方法——目前,我们将把测试用户的详细信息直接注入数据库:

//! tests/api/helpers.rs
// [...]

pub async fn spawn_app() -> TestApp {
    // [...]

    let test_app = TestApp {
        // [...]
    };
    add_test_user(&test_app.db_pool).await;
    test_app
}

async fn add_test_user(pool: &PgPool) {
    sqlx::query!(
        "INSERT INTO users (user_id, username, password)
        VALUES ($1, $2, $3)",
        Uuid::new_v4(),
        Uuid::new_v4().to_string(),
        Uuid::new_v4().to_string(),
    )
    .execute(pool)
    .await
    .expect("Failed to create test users.");
}

TestApp 将提供一个辅助方法来检索其用户名和密码

//! tests/api/helpers.rs
// [...]

impl TestApp {
    // [...]
    pub async fn test_user(&self) -> (String, String) {
        let row = sqlx::query!("SELECT username, password FROM users LIMIT 1",)
            .fetch_one(&self.db_pool)
            .await
            .expect("Failed to create test users.");
        (row.username, row.password)
    }
}

然后我们将从您的 post_newsletters 方法中调用它,而不是使用随机凭据:

//! tests/api/helpers.rs
// [...]

impl TestApp {
    // [...]
    pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response {
        let (username, password) = self.test_user().await;
        reqwest::Client::new()
            .post(&format!("{}/newsletters", &self.address))
            .basic_auth(username, Some(password))
            .json(&body)
            .send()
            .await
            .expect("Failed to execute request.")
    }
}

现在所有测试应该都可以通过了

存储密码

将原始用户密码存储在数据库中并非明智之举。

攻击者可以访问您存储的数据,立即开始冒充您的用户——用户名和密码都已准备就绪。

他们甚至无需入侵您的实时数据库——只需一个未加密的备份即可。

不需要存储原始密码

我们为什么要存储密码呢?

我们需要执行相等性检查——每次用户尝试身份验证时,我们都会验证他们提供的密码是否与我们预期的密码匹配。 如果我们只关心相等性,就可以开始设计更复杂的策略。

例如,我们可以在比较密码之前应用一个函数来转换它们。

所有确定性函数在给定相同输入的情况下都会返回相同的输出。

设 f 是我们的确定性函数: psw_candidate == expected_psw 意味着 f(psw_candidate) == f(expected_psw)

但这还不够——如果 f 对每个可能的输入字符串都返回 hello 呢?无论输入是什么,密码验证都会成功。 我们需要反过来:

如果 f(psw_candidate) == f(expected_psw)psw_candidate == expected_psw

假设我们的函数 f 具有一个附加属性,那么这是可能的:它必须是单射函数——如果 x != y, 则 f(x) != f(y)

如果我们有这样一个函数 f,我们就可以完全避免存储原始密码:当用户注册时,我们计算 f(password) 并将其存储在数据库中。密码会被丢弃。

当同一个用户尝试登录时,我们计算 f(psw_candidate) 并检查它是否与我们在注册时存储的 f(password) 值匹配。原始密码永远不会被持久化。

这真的能改善我们的安全状况吗?

这取决于 f!

定义一个单射函数并不难——逆函数 f("hello") = "olleh"

就满足我们的标准。同样容易猜测如何反转转换以恢复原始密码——这不会妨碍攻击者。

我们可以让变换更加复杂——复杂到足以让攻击者难以找到逆变换。

即使这样也可能不够。通常情况下,攻击者能够从输出中恢复输入的某些属性(例如长度),从而发起例如有针对性的暴力破解攻击就足够了。

我们需要更强大的算法——两个输入 x 和 y 的相似度与相应的输出 f(x) 和 f(y) 的相似度之间不应该存在任何关系。

我们需要一个加密哈希函数

哈希函数将输入空间中的字符串映射到固定长度的输出。

形容词“加密”指的是我们刚才讨论的一致性属性,也称为雪崩效应: 输入的微小差异会导致输出差异如此之大,以至于看起来不相关。

需要注意的是: 哈希函数不是单射的,存在微小的碰撞风险——如果 f(x) == f(y), 则有很大概率(不是 100%!)x == y

使用加密哈希

理论讲得够多了——让我们更新一下实现,在存储密码之前先进行哈希处理。

市面上有几种加密哈希函数——MD5SHA-1SHA-2SHA-3KangarooTwelve 等等。

我们不会深入探讨每种算法的优缺点——对于密码来说,这毫无意义,原因稍后会解释清楚。

为了本节的方便,我们先来讨论一下安全哈希算法家族的最新成员 SHA-3。

除了该算法之外,我们还需要选择输出大小——例如,SHA3-224 使用 SHA-3 算法生成 224 位的固定大小输出。 输出大小选项包括 224、256、384 和 512。输出越长,发生碰撞的可能性就越小。另一方面,使用更长的哈希值会需要更多存储空间并消耗更多带宽。

SHA3-256 应该足以满足我们的用例。

Rust Crypto 组织提供了 SHA-3 的实现,即 sha3 crate。让我们将它添加到我们的依赖项中:

cargo add sha3

为了清楚起见,我们将 password column 重命名为 password_hash:

sqlx migrate add rename_password_column
-- migrations/<timestamp>_rename_password_column.sql
ALTER TABLE users RENAME password TO password_hash;

我们的项目应该停止编译:

error: error returned from database: column "password" does not exist
   --> src/routes/newsletters.rs:182:30
    |
182 |       let user_id: Option<_> = sqlx::query!(
    |  ______________________________^
183 | |         r#"
184 | |         SELECT user_id
185 | |         FROM users
...   |
189 | |         credentials.password.expose_secret()
190 | |     )
    | |_____^
    |
    = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more in
fo)

sqlx::query! 发现我们的一个查询使用了当前模式中不再存在的列。

SQL 查询的编译时验证非常简洁,不是吗?

我们的 validate_credentials 函数如下所示:

//! src/routes/newsletters.rs
// [...]

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let user_id: Option<_> = sqlx::query!(
        r#"
        SELECT user_id
        FROM users
        WHERE username = $1 AND password = $2
        "#,
        credentials.username,
        credentials.password.expose_secret()
    )
    // [...]
}

让我们更新它以使用散列密码:

//! src/routes/newsletters.rs
// [...]
use sha3::Digest;

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let password_hash = sha3::Sha3_256::digest(credentials.password.expose_secret().as_bytes());
    let user_id: Option<_> = sqlx::query!(
        r#"
        SELECT user_id
        FROM users
        WHERE username = $1 AND password_hash = $2
        "#,
        credentials.username,
        password_hash
    )
    // [...]
}

不幸的是,它不会立即编译:

error[E0308]: mismatched types
   --> src/routes/newsletters.rs:191:9
    |
191 |         password_hash
    |         ^^^^^^^^^^^^^
    |         |
    |         expected `&str`, found `GenericArray<u8, UInt<..., ...>>`
    |         expected due to the type of this binding
    |
    = note: expected reference `&str`
                  found struct `GenericArray<u8, UInt<UInt<UInt<UInt<UInt<UInt<UTerm, B1>, B0>, B0>, B0>, B0>, B0>>`

Digest::digest 返回一个固定长度的字节数组, 而我们的 password_hash 列是 TEXT 类型,即字符串。

我们可以更改用户表的模式,将 password_hash 存储为二进制。或者,我们可以使用十六进制格式将 Digest::digest 返回的字节编码为字符串。

为了避免再次迁移,我们可以使用第二种方案:

//! src/routes/newsletters.rs
// [...]

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let password_hash = sha3::Sha3_256::digest(credentials.password.expose_secret().as_bytes());
    // Lowercase hexadecimal encoding.
    let password_hash = format!("{password_hash:x}");
    // [...]
}

应用程序代码现在应该可以编译了。测试套件则需要更多工作。

test_user 辅助方法之前是通过查询用户表来恢复一组有效凭证的, ——现在我们存储的是哈希值而不是原始密码,所以这种方法不再可行!

//! tests/api/helpers.rs
// [...]

impl TestApp {
    pub async fn test_user(&self) -> (String, String) {
        let row = sqlx::query!("SELECT username, password FROM users LIMIT 1",)
            .fetch_one(&self.db_pool)
            .await
            .expect("Failed to create test users.");
        (row.username, row.password)
    }
}

async fn add_test_user(pool: &PgPool) {
    sqlx::query!(
        "INSERT INTO users (user_id, username, password)
        VALUES ($1, $2, $3)",
        Uuid::new_v4(),
        Uuid::new_v4().to_string(),
        Uuid::new_v4().to_string(),
    )
    .execute(pool)
    .await
    .expect("Failed to create test users.");
}

// [...]

我们需要 Test`App 来存储随机生成的密码,以便我们在辅助方法中访问它。

我们先创建一个新的辅助结构体 TestUser:

//! tests/api/helpers.rs
// [...]

pub struct TestUser {
    pub user_id: Uuid,
    pub username: String,
    pub password: String,
}

impl TestUser {
    pub fn generate() -> Self {
        Self {
            user_id: Uuid::new_v4(),
            username: Uuid::new_v4().to_string(),
            password: Uuid::new_v4().to_string(),
        }
    }

    async fn store(&self, pool: &PgPool) {
        let password_hash = sha3::Sha3_256::digest(self.password.as_bytes());
        let password_hash = format!("{password_hash:x}");
        sqlx::query!(
            "INSERT INTO users (user_id, username, password_hash)
        VALUES ($1, $2, $3)",
            self.user_id,
            self.username,
            password_hash,
        )
        .execute(pool)
        .await
        .expect("Failed to store test user.");
    }
}

我们可以在 TestApp 中存储一个 TestUser 的实例, 作为新的字段:

//! tests/api/helpers.rs
// [...]

pub struct TestApp {
    // [...]
    test_user: TestUser,
}

pub async fn spawn_app() -> TestApp {
    // [...]

    let test_app = TestApp {
        // [...]
        test_user: TestUser::generate(),
    };
    test_app.test_user.store(&test_app.db_pool).await;
    test_app
}

最后, 我们移除 add_test_user, TestApp::test_user 和更新 TestApp::post_newsletters:

//! tests/api/helpers.rs
// [...]
impl TestApp {
    // [...]
    pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response {
        reqwest::Client::new()
            .post(format!("{}/newsletters", &self.address))
            .basic_auth(&self.test_user.username, Some(&self.test_user.password))
            .json(&body)
            .send()
            .await
            .expect("Failed to execute request.")
    }
}

现在测试套件应该可以通过了.

原像攻击

如果攻击者获取了我们的用户表,SHA3-256 是否足以保护用户的密码?

假设攻击者想要破解我们数据库中特定的密码哈希值。

攻击者甚至不需要检索原始密码。为了成功验证身份,他们

只需要找到一个输入字符串 s,其 SHA3-256 哈希值与他们试图破解的密码匹配——换句话说,就是碰撞。

这被称为原像攻击

它有多难?

数学计算起来有点棘手,但暴力攻击的时间复杂度是指数级的——2^n,其中n 是哈希长度(以位为单位)。

如果 n > 128,则认为无法计算

除非 SHA-3 中存在漏洞,否则我们无需担心针对 SHA3-256 的原像攻击。

朴素字典攻击

不过,我们并不是对任意输入进行哈希处理——我们可以通过对原始密码进行一些假设来减少搜索空间:它有多长?使用了哪些符号?

假设我们正在寻找一个长度少于 17 个字符的字母数字密码。

我们可以计算候选密码的数量:

// (26 letters + 10 number symbols) ^ Password Length
// for all allowed password lengths
36^1 +
36^2 +
... +
36^16

总计大约有 8 * 10^24 种可能性。

我找不到关于 SHA3-256 的具体数据,但研究人员使用图形处理单元 (GPU) 每秒计算出约 9 亿个 SHA3-512 哈希值。

假设哈希率约为每秒 10^9, 则我们需要约 10^15 秒来哈希所有候选密码。宇宙的年龄约为 4 * 10^17 秒。

字典攻击

让我们回顾一下本章开头讨论的内容——一个人不可能记住数百个在线服务的唯一密码。

他们要么依赖密码管理器,要么在多个账户中重复使用一个或多个密码。

此外,大多数密码即使重复使用也远非随机——常用词、全名、日期、热门运动队名称等等。

攻击者可以轻松设计一个简单的算法来生成数千个看似合理的密码——但他们不必这样做。他们可以查看过去十年中众多安全漏洞中的一个密码数据集,找出最常见的密码。

只需几分钟,他们就可以预先计算出最常用的一千万个密码的 SHA3-256 哈希值。然后,他们开始扫描我们的数据库,寻找匹配的密码。

这被称为字典攻击——而且非常有效。

我们到目前为止提到的所有加密哈希函数都旨在提高速度

速度足够快,任何人都可以发起字典攻击,而无需使用专门的硬件。

我们需要一种速度慢得多,但又具有与加密哈希函数相同的数学特性的算法。

Argon2

开放式 Web 应用程序安全项目 (OWASP)75 提供了有关安全密码存储的有用指导 - 其中有一整节介绍如何选择正确的散列算法:

  • 使用 Argon2id,最低配置为 15 MiB 内存、迭代次数为 2 且并行度为 1。
  • 如果 Argon2id 不可用,请使用 bcrypt,工作因子为 10 或更高,密码长度限制为 72 字节。
  • 对于使用 scrypt 的旧系统,请使用最低 CPU/内存成本参数 (2^16)、最小块大小为 8(1024 字节)和并行化参数为 1。
  • 如果需要符合 FIPS-140 标准,请使用 PBKDF2,工作因子为 310,000 或更高,并使用 HMAC-SHA-256 内部哈希函数。
  • 考虑使用胡椒粉来提供额外的纵深防御(尽管单独使用时,它不会提供额外的安全特性)。

所有这些选项——Argon2、bcrypt、scrypt、PBKDF2——都被设计为计算要求高

它们还公开了一些配置参数(例如,bcrypt 的工作因子),以进一步降低哈希计算速度:应用程序开发者可以调整一些参数以跟上硬件加速的步伐——无需每隔几年就迁移到更新的算法。

让我们按照 OWASP 的建议,用 Argon2id 替换 SHA-3。

Rust Crypto 组织再次为我们提供了帮助——他们提供了一个纯 Rust 实现, argon2

让我们将它添加到我们的依赖项中:

cargo add argon2 --features=std

要对密码进行哈希处理,我们需要创建一个 Argon2 结构体实例。

new 方法的签名如下:

//! argon2/lib.rs
// [...]

impl<'key> Argon2<'key> {
    /// Create a new Argon2 context.
    pub fn new(algorithm: Algorithm, version: Version, params: Params) -> Self {
        // [...]
    }
    // [...]
}

Algorithm 是一个枚举: 它允许我们选择要使用的 Argon2 变体 - Argon2dArgon2iArgon2id。为了符合 OWASP 的建议,我们将使用 Algorithm::Argon2id

Version 的作用也类似 - 我们将使用最新版本, Version::V0x13

那么 Params 呢?

Params::new 指定了构建一个 Argon2 所需的所有必需参数:

//! argon2/params.rs
// [...]

/// Create new parameters.
pub fn new(
    m_cost: u32,
    t_cost: u32,
    p_cost: u32,
    output_len: Option<usize>
) -> Result<Self> {
    // [...]
}

m_cost、t_cost 和 p_cost 对应于 OWASP 的要求:

  • m_cost 是内存大小,以千字节为单位
  • t_cost 是迭代次数
  • p_cost 是并行度

output_len 决定了返回哈希值的长度——如果省略,则默认为 32字节。这相当于 256 位,与我们通过 SHA3-256 获得的哈希值长度相同。

目前,我们已经掌握了足够的信息来构建一个:

//! src/routes/newsletters.rs
// [...]
async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let hasher = Argon2::new(
        argon2::Algorithm::Argon2id,
        argon2::Version::V0x13,
        Params::new(15000, 2, 1, None)
            .context("Failed to build Argon2 parameters")
            .map_err(PublishError::UnexpectedError)?,
    );
    let password_hash = sha3::Sha3_256::digest(credentials.password.expose_secret().as_bytes());
    // [...]
}

Argon2 实现了 PasswordHasher trait:

//! password_hash/traits.rs
pub trait PasswordHasher {
    // [...]
    fn hash_password<'a, S>(
        &self,
        password: &[u8],
        salt: &'a S
    ) -> Result<PasswordHash<'a>>
    where
      S: AsRef<str> + ?Sized;
}

它是 password-hash crate 的重新导出,后者是一个统一的接口,用于处理由多种算法(目前支持 Argon2PBKDF2scrypt)支持的密码哈希。

PasswordHasher::hash_passwordSha3_256::digest 略有不同——它要求在原始密码的基础上添加一个额外的参数, 即盐值。

加盐

Argon2 比 SHA-3 慢得多,但这不足以使字典攻击无法进行。虽然对最常见的 1000 万个密码进行哈希处理需要更长的时间,但也不会太长。

但是,如果攻击者必须为数据库中的每个用户重新哈希整个字典呢?

那就更具挑战性了!

这就是加盐算法的作用。对于每个用户,我们都会生成一个唯一的随机字符串——盐。

在生成哈希值之前,盐会被添加到用户密码的前面。PasswordHasher::hash_password 会为我们处理加盐的工作。

盐存储在数据库中,与密码哈希值相邻。

如果攻击者获取了数据库备份,他们就可以访问所有盐值。

但他们必须计算 dictionary_size * n_users 的哈希值,而不是 dictionary_size。此外,预先计算哈希值不再是一个选项——这为我们赢得了时间来检测违规行为并采取行动(例如,强制所有用户重置密码)。

让我们在 users 表中添加一个 password_salt 列:

sqlx migrate add add_salt_to_users
-- migrations/<timestamp>_add_salt_to_users.sql
ALTER TABLE users ADD COLUMN salt TEXT NOT NULL;

我们不能再在查询用户表之前计算哈希值了——我们需要先检索盐值。

让我们来改组一下操作:

//! src/routes/newsletters.rs
// [...]

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let hasher = Argon2::new(
        argon2::Algorithm::Argon2id,
        argon2::Version::V0x13,
        Params::new(15000, 2, 1, None)
            .context("Failed to build Argon2 parameters")
            .map_err(PublishError::UnexpectedError)?,
    );
    let row: Option<_> = sqlx::query!(
        r#"
        SELECT user_id, password_hash, salt
        FROM users
        WHERE username = $1
        "#,
        credentials.username,
    )
    .fetch_optional(pool)
    .await
    .context("Failed to perform a query to retrieve stored credentials.")
    .map_err(PublishError::UnexpectedError)?;

    let (expected_password_hash, user_id, salt) = match row {
        Some(row) => (row.password_hash, row.user_id, row.salt),
        None => {
            return Err(PublishError::AuthError(anyhow::anyhow!(
                "Unknown username.",
            )));
        }
    };

    let mut password_hash = hasher
        .hash_password(credentials.password.expose_secret().as_bytes(), &salt)
        .context("Failed to hash password")
        .map_err(PublishError::UnexpectedError)?;

    let password_hash = format!("{:x}", password_hash.hash.unwrap());

    if password_hash != expected_password_hash {
        Err(PublishError::AuthError(anyhow::anyhow!(
            "Invalid password."
        )))
    } else {
        Ok(user_id)
    }
}

很不幸的, 这不能编译

error[E0277]: the trait bound `Salt<'_>: std::convert::From<&std::string::String>` is not satisfied
   --> src/routes/newsletters.rs:214:73
    |
214 |         .hash_password(credentials.password.expose_secret().as_bytes(), &salt)
    |          ------------- required by a bound introduced by this call      ^^^^^ the trait `std::convert::
From<&std::string::String>` is not implemented for `Salt<'_>`
    |

Output 提供了其他方法来获取字符串表示,例如 Output::b64_encode

只要我们愿意更改数据库中存储的哈希值的假定编码,它就可以工作。

如果有必要进行更改,我们可以尝试比 base64 编码更好的方法。

PHC 字符串格式

为了验证用户身份,我们需要可重复性: 每次都必须运行完全相同的哈希算法。

盐值和密码只是 Argon2id 输入的一部分。所有其他加载参数(t_cost、m_cost、p_cost)对于在给定相同盐值和密码的情况下获得相同哈希值都同样重要。

如果我们存储哈希值的 base64 编码表示,则我们做出了一个强有力的隐含假设: password_hash 列中存储的所有值都是使用相同的加载参数计算的。

正如我们前几节所讨论的,硬件功能会随着时间推移而发展: 应用程序开发人员需要通过使用更高的加载参数来增加哈希值的计算成本,从而跟上时代的步伐。

当您必须将存储的密码迁移到更新的哈希配置时会发生什么? 为了继续验证旧用户的身份,我们必须在每个哈希值旁边存储用于计算哈希值的精确加载参数集。

这允许在两种不同的加载配置之间无缝迁移: 当旧用户进行身份验证时,我们使用存储的加载参数验证密码有效性;然后,我们使用新的加载参数重新计算密码哈希值,并相应地更新存储的信息。

我们可以采用简单的方法——在用户表中添加三个新列: t_costm_costp_cost

只要算法仍然是 Argon2id,这种方法就有效。

如果在 Argon2id 中发现漏洞,我们被迫迁移到其他版本,会发生什么情况?

我们可能需要添加一个算法列,以及一些新列来存储 Argon2id 替代品的加载参数。

这可以做到,但很繁琐。

幸运的是,有一个更好的解决方案:PHC 字符串格式。PHC 字符串格式为密码哈希值提供了标准表示:它包含哈希值本身、盐值、算法以及所有相关参数。

使用 PHC 字符串格式,Argon2id 密码哈希如下所示:

# ${algorithm}${algorithm version}${$-separated algorithm parameters}${hash}${salt}
$argon2id$v=19$m=65536,t=2,p=1$gZiV/M1gPc22ElAH/Jh1Hw$CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno

argon2 crate 公开了 PasswordHash, 这是 PHC 格式的 Rust 实现:

//! argon2/lib.rs
// [...]
pub struct PasswordHash<'a> {
    pub algorithm: Ident<'a>,
    pub version: Option<Decimal>,
    pub params: ParamsString,
    pub salt: Option<Salt<'a>>,
    pub hash: Option<Output>,
}

将密码哈希值存储为 PHC 字符串格式,可以避免我们不得不使用显式参数初始化 Argon2 结构体。

我们可以依赖 Argon2PasswordVerifier 特性实现:

pub trait PasswordVerifier {
    fn verify_password(
        &self,
        password: &[u8],
        hash: &PasswordHash<'_>
    ) -> Result<()>;
}

通过 PasswordHash 传递预期的哈希值, Argon2 可以自动推断出应该使用哪些加载参数和盐来验证密码候选是否匹配。

让我们更新我们的实现:

//! src/routes/newsletters.rs
use argon2::{Argon2, PasswordHash, PasswordVerifier};
// [...]

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let row: Option<_> = sqlx::query!(
        r#"
        SELECT user_id, password_hash
        FROM users
        WHERE username = $1
        "#,
        credentials.username,
    )
    .fetch_optional(pool)
    .await
    .context("Failed to perform a query to retrieve stored credentials.")
    .map_err(PublishError::UnexpectedError)?;

    let (expected_password_hash, user_id) = match row {
        Some(row) => (row.password_hash, row.user_id),
        None => {
            return Err(PublishError::AuthError(anyhow::anyhow!(
                "Unknown username.",
            )));
        }
    };

    let expected_password_hash = PasswordHash::new(&expected_password_hash)
        .context("Failed to parse hash in PHC string format.")
        .map_err(PublishError::UnexpectedError)?;

    Argon2::default()
        .verify_password(
            credentials.password.expose_secret().as_bytes(),
            &expected_password_hash,
        )
        .context("Invalid password")
        .map_err(PublishError::AuthError)?;

    Ok(user_id)
}

编译成功。

你可能还注意到,我们不再直接处理盐值了——PHC 字符串格式会隐式地帮我们处理。

我们可以完全去掉盐值列:

sqlx migrate add remove_salt_from_users
-- migrations/<timestamp>_remove_salt_from_users.sql
ALTER TABLE users DROP COLUMN salt;

我们的测试怎么样?

其中两个测试失败了:

---- newsletter::newsletters_are_not_delivered_to_unconfirmed_subscribers stdout ----

thread 'newsletter::newsletters_are_not_delivered_to_unconfirmed_subscribers' panicked at tests/api
/newsletter.rs:34:5:
assertion `left == right` failed
  left: 500
 right: 200
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

---- newsletter::newsletters_are_delivered_to_confirmed_subscribers stdout ----

thread 'newsletter::newsletters_are_delivered_to_confirmed_subscribers' panicked at tests/api/newsl
etter.rs:102:5:
assertion `left == right` failed
  left: 500
 right: 200


failures:
    newsletter::newsletters_are_delivered_to_confirmed_subscribers
    newsletter::newsletters_are_not_delivered_to_unconfirmed_subscribers

我们可以查看日志来找出问题所在:

TEST_LOG=true cargo test newsletters_are_not_delivered | bunyan
Caused by:
    password hash string missing field

让我们看一下测试用户的密码生成代码:

//! tests/api/helpers.rs
// [...]

impl TestUser {
    // [...]

    async fn store(&self, pool: &PgPool) {
        let password_hash = sha3::Sha3_256::digest(self.password.as_bytes());
        let password_hash = format!("{password_hash:x}");
        // [...]
    }
}

我们还在使用 SHA-3!

让我们更新它:

//! tests/api/helpers.rs
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHasher};
// [...]

impl TestUser {
    // [...]

    async fn store(&self, pool: &PgPool) {
        let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
        // We don't care about the exact Argon2 parameters here
        // given that it's for testing purposes!
        let password_hash = Argon2::default()
            .hash_password(self.password.as_bytes(), &salt)
            .unwrap()
            .to_string();
        // [...]
    }
}

测试套件现在应该可以通过了。

我们已经从项目中删除了所有关于 sha3 的引用——现在可以将其从 Cargo.toml 的依赖项列表中删除了。

不要阻塞异步执行器

运行集成测试时,验证用户凭据需要多长时间?

我们目前没有关于密码哈希的跨度 - 让我们修复它:

//! src/routes/newsletters.rs
// [...]

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let (user_id, expected_password_hash) = get_stored_credentials(&credentials.username, pool)
        .await
        .map_err(PublishError::UnexpectedError)?
        .ok_or_else(|| PublishError::AuthError(anyhow::anyhow!("Unknown username.")))?;

    let expected_password_hash = PasswordHash::new(expected_password_hash.expose_secret())
        .context("Failed to parse hash in PHC string format.")
        .map_err(PublishError::UnexpectedError)?;

    tracing::info_span!("Verify password hash")
        .in_scope(|| {
            Argon2::default().verify_password(
                credentials.password.expose_secret().as_bytes(),
                &expected_password_hash,
            )
        })
        .context("Invalid password")
        .map_err(PublishError::AuthError)?;

    Ok(user_id)
}

// We extracted the db-querying logic in its own function with its own span.
#[tracing::instrument(name = "Get stored credentials", skip(username, pool))]
async fn get_stored_credentials(
    username: &str,
    pool: &PgPool,
) -> Result<Option<(uuid::Uuid, SecretString)>, anyhow::Error> {
    let row: Option<_> = sqlx::query!(
        r#"
        SELECT user_id, password_hash
        FROM users
        WHERE username = $1
        "#,
        username,
    )
    .fetch_optional(pool)
    .await
    .context("Failed to perform a query to retrieve stored credentials.")?
    .map(|row| (row.user_id, SecretString::from(row.password_hash)));

    Ok(row)
}

现在我们可以查看其中一个集成测试的日志:

TEST_LOG=true cargo test --quiet --release newsletters_are_delivered | grep "VERIFY PASSWORD" | bunyan
[VERIFY PASSWORD HASH - END] (elaps
ed_milliseconds=16

大约 10 毫秒。

这很可能在负载下引发问题——臭名昭著的阻塞问题。

Rust 中的 async/await 是基于一个称为协作调度的概念构建的。

它是如何工作的?

我们来看一个例子:

async fn my_fn() {
    a().await;
    b().await;
    c().await;
}

my_fn 返回一个 Future

当等待 Future 时,我们的异步运行时 (tokio) 就会介入: 它开始轮询 Future。

如何实现对 my_fn 返回的 Future 的轮询?

你可以将其视为一个状态机:

enum MyFnFuture {
    Initialized,
    CallingA,
    CallingB,
    CallingC,
    Complete
}

每次调用 poll 时,它都会尝试进入下一个状态来取得进展。例如,如果 a.await() 返回,我们就开始等待 b()。

在 MyFnFuture 中,异步函数体中的每个 .await 都会有不同的状态。

这就是为什么 .await 调用通常被称为 yield point(让渡点)——我们的 Future 从上一个 .await 进展到下一个 .await, 然后将控制权交还给执行器。

执行器可以选择再次 poll 同一个 Future,或者优先处理其他任务。这就是像 tokio 这样的异步运行时如何通过不断地暂停和恢复每个任务来同时处理多个任务的方法。

在某种程度上,你可以将异步运行时视为出色的杂耍演员。

其基本假设是,大多数异步任务都在执行某种输入输出 (IO) 工作——它们的大部分执行时间都花在等待其他事件发生(例如,操作系统通知我们套接字上有可供读取的数据),因此,相比为每个任务分配一个并行执行单元(例如,每个操作系统核心一个线程),我们可以有效地并发执行更多任务。

如果任务之间能够协作,并频繁地将控制权交还给执行器,那么这种模型就能很好地发挥作用。

让我们实现它!

//! src/routes/newsletters.rs
// [...]

#[tracing::instrument(name = "Validate credentials", skip(credentials, pool))]
async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let (user_id, expected_password_hash) = get_stored_credentials(&credentials.username, pool)
        .await
        .map_err(PublishError::UnexpectedError)?
        .ok_or_else(|| PublishError::AuthError(anyhow::anyhow!("Unknown username.")))?;

    let expected_password_hash = PasswordHash::new(expected_password_hash.expose_secret())
        .context("Failed to parse hash in PHC string format.")
        .map_err(PublishError::UnexpectedError)?;

    tokio::task::spawn_blocking(move || {
        tracing::info_span!("Verify password hash").in_scope(|| {
            Argon2::default().verify_password(
                credentials.password.expose_secret().as_bytes(),
                &expected_password_hash,
            )
        })
    })
    .await
    .context("Invalid password")
    .map_err(PublishError::AuthError)?;

    Ok(user_id)
}

borrow checker 并不是很满意

190 |       let expected_password_hash = PasswordHash::new(expected_password_hash.expose_secret())
    |                                                      ^^^^^^^^^^^^^^^^^^^^^^ borrowed value do
es not live long enough
...
194 | /     tokio::task::spawn_blocking(move || {
195 | |         tracing::info_span!("Verify password hash").in_scope(|| {
196 | |             Argon2::default().verify_password(
197 | |                 credentials.password.expose_secret().as_bytes(),
...   |
200 | |         })
201 | |     })
    | |______- argument requires that `expected_password_hash` is borrowed for `'static`

我们正在一个单独的线程上启动一个计算——该线程本身的生命周期可能比我们创建它的异步任务更长。为了避免这个问题, spawn_blocking 要求其参数具有 'static 生命周期——这阻止我们将当前函数上下文的引用传递给闭包。

你可能会争辩说: "我们正在使用 move || {},闭包应该获取 expected_pa​​ssword_hash 的所有权!"。

你说得对!但这还不够。

我们再看看 PasswordHash 是如何定义的:

pub struct PasswordHash<'a> {
    pub algorithm: Ident<'a>,
    pub salt: Option<Salt<'a>>,
    // [...]
}

它保存了对解析后的字符串的引用。

我们需要将原始字符串的所有权移到闭包中,并将解析逻辑也移到其中。

为了清晰起见,我们创建一个单独的函数 verify_password_hash:

//! src/routes/newsletters.rs
// [...]

#[tracing::instrument(name = "Validate credentials", skip(credentials, pool))]
async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let (user_id, expected_password_hash) = get_stored_credentials(&credentials.username, pool)
        .await
        .map_err(PublishError::UnexpectedError)?
        .ok_or_else(|| PublishError::AuthError(anyhow::anyhow!("Unknown username.")))?;

    tokio::task::spawn_blocking(move || {
        verify_password_hash(expected_password_hash, credentials.password)
    })
    .await
    .context("Invalid password")
    .map_err(PublishError::AuthError)??;

    Ok(user_id)
}

#[tracing::instrument(
    name = "Verify password hash",
    skip(expected_password_hash, password_candidate)
)]
fn verify_password_hash(
    expected_password_hash: SecretString,
    password_candidate: SecretString,
) -> Result<(), PublishError> {
    let expected_password_hash = PasswordHash::new(expected_password_hash.expose_secret())
        .context("Failed to parse hash in PHC string format.")
        .map_err(PublishError::UnexpectedError)?;

    Argon2::default()
        .verify_password(
            password_candidate.expose_secret().as_bytes(),
            &expected_password_hash,
        )
        .context("Invalid password.")
        .map_err(PublishError::AuthError)
}

编译通过!

Tracing 上下文是线程本地的

让我们再次查看 verify password hash span的日志:

TEST_LOG=true cargo test --quiet --release newsletters_are_delivered | grep "VERIFY PASSWORD" | bunyan
[2025-09-16T14:13:30.467Z]  INFO: test/124941 on qby-workspace: [VERIFY PASSWORD HASH - START] (file=...,line=...,target=...)
[2025-09-16T14:13:30.482Z]  INFO: test/124941 on qby-workspace: [VERIFY PASSWORD HASH - END] (elapsed_milliseconds=14,file=...,line=...,target=...)

我们缺少从相应请求的根 span 继承的所有属性,例如 request_idhttp.methodhttp.route 等。为什么?

让我们看看 tracing 的文档:

Span 构成树形结构——除非是根 Span,否则所有 Span 都有一个父级,并且可能有一个或多个子级。创建新 Span 时,当前 Span 将成为新 Span 的父级。

当前跨度是 tracing::Span::current() 返回的跨度 - 让我们查看其文档:

返回收集器认为是当前跨度的跨度句柄。如果收集器指示它不跟踪当前跨度,或者调用此函数的线程当前不在跨度内,则返回的跨度将被禁用。

“当前跨度”实际上是指“当前线程的活动跨度”。

这就是为什么我们没有继承任何属性:我们在一个单独的线程上生成计算,而 tracing::info_span! 在执行时找不到与其关联的任何活动跨度。

我们可以通过将当前跨度显式附加到新生成的线程来解决这个问题:

//! src/routes/newsletters.rs
// [...]

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    // [...]

    // This executes before spawning the new thread
    let current_span = tracing::Span::current();
    tokio::task::spawn_blocking(move || {
        // We then pass ownership to it into the closure
        // and explicitly executes all our computation
        // within its scope.
        current_span.in_scope(|| verify_password_hash(expected_password_hash, credentials.password))
    })
    // [...]
}

你可以验证它是否有效——我们现在获取了所有我们关心的属性。

不过这有点冗长——让我们编写一个辅助函数:

//! src/telemetry.rs
use tokio::task::JoinHandle;
// [...]
// Just copied trait bounds and signature from `spawn_blocking`
pub fn spawn_blocking_with_tracing<F, R>(f: F) -> JoinHandle<R>
where
    F: FnOnce() -> R + Send + 'static,
    R: Send + 'static,
{
    let current_span = tracing::Span::current();
    tokio::task::spawn_blocking(move || current_span.in_scope(f))
}
//! src/routes/newsletters.rs
use crate::telemetry::spawn_blocking_with_tracing;
// [...]

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    // [...]

    spawn_blocking_with_tracing(move || {
        verify_password_hash(expected_password_hash, credentials.password)
    })
    // [...]
}

现在,每当我们需要将一些 CPU 密集型计算卸载到专用线程池时,都可以轻松地使用它。

用户枚举

让我们增加一个新测试用例:

//! tests/api/newsletter.rs
use uuid::Uuid;
// [...]

#[tokio::test]
async fn invalid_password_is_rejected() {
    // Arrange
    let app = spawn_app().await;
    let username = &app.test_user.username;
    // Random password
    let password = Uuid::new_v4().to_string();
    assert_ne!(app.test_user.password, password);

    let response = reqwest::Client::new()
        .post(format!("{}/newsletters", &app.address))
        .basic_auth(username, Some(password))
        .json(&serde_json::json!({
            "title": "newsletter title",
            "content": {
                "text": "Newsletter body as plain text",
                "html": "<p>Newsletter body as HTML</p>",
            }
        }))
        .send()
        .await
        .expect("Failed to execute request.");

    // Assert
    assert_eq!(401, response.status().as_u16());
    assert_eq!(
        r#"Basic realm="publish""#,
        response.headers()["WWW-Authenticate"]
    )
}

这个应该也能通过。请求多久才会失败?

TEST_LOG=true cargo test --quiet --release invalid_password_is_rejected | grep "HTTP REQUEST" | bunyan
# [...] Omitting setup requests
[...] [HTTP REQUEST - END] (elapsed_millis
econds=19,..., http.route=/newsletters, ...)

大约 10 毫秒 - 小一个数量级!

我们可以利用这种差异来执行定时攻击,这是更广泛的旁道攻击之一。

如果攻击者知道至少一个有效用户名,他们就可以检查服务器响应时间81来确认是否存在其他用户名——我们正在研究一个潜在的用户枚举漏洞。

这会是个问题吗?

视情况而定。

如果您正在运行Gmail,还有很多其他方法可以查明@gmail.com电子邮件地址是否存在。

电子邮件地址的有效性并非秘密!

如果您正在运行SaaS产品,情况可能会更加复杂。

让我们假设一个场景:您的SaaS产品提供工资服务,并使用电子邮件地址作为用户名。产品有单独的员工和管理员登录页面。

我的目标是获取工资数据——我需要入侵具有特权访问权限的员工。

我们可以爬取LinkedIn数据,获取财务部门所有员工的姓名。

公司邮箱的结构是可预测的(姓名.姓氏@payrollaces.com),所以我们有一份候选人名单。

现在,我们可以对管理员登录页面进行定时攻击,将名单缩小到有权访问的用户。

即使在我们虚构的例子中,单靠用户枚举也不足以提升我们的权限。

但它可以作为跳板,缩小目标范围,以便进行更精准的攻击。

我们如何预防这种情况?

有两种策略:

  1. 消除因密码无效导致的身份验证失败和因用户名不存在导致的身份验证失败之间的时间差
  2. 限制给定 IP/用户名的身份验证失败次数。

第二种策略通常有助于防止暴力破解攻击,但它需要保存一些状态——我们稍后再讨论。

我们先来重点讨论第一种策略。

为了消除时间差异,我们需要在两种情况下执行相同的工作量。

目前,我们遵循以下方案:

  • 获取给定用户名的存储凭证
  • 如果不存在,则返回 401
  • 如果存在,则对候选密码进行哈希处理,并与存储的哈希值进行比较。

我们需要消除这种提前退出的情况——我们应该有一个备用的预期密码(包含盐值和加载参数),

以便与候选密码的哈希值进行比较。

//! src/routes/newsletters.rs
// [...]
async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let mut user_id = None;
    let mut expected_password_hash = SecretString::from(
        "$argon2id$v=19$m=15000,t=2,p=1$\
gZiV/M1gPc22ElAH/Jh1Hw$\
CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
            .to_string(),
    );
    if let Some((stored_user_id, stored_password_hash)) =
        get_stored_credentials(&credentials.username, pool)
            .await
            .map_err(PublishError::UnexpectedError)?
    {
        user_id = Some(stored_user_id);
        expected_password_hash = stored_password_hash
    }

    spawn_blocking_with_tracing(move || {
        verify_password_hash(expected_password_hash, credentials.password)
    })
    .await
    .context("Invalid password")
    .map_err(PublishError::AuthError)??;

    // This is only set to `Some` if we found credentials in the store
    // So, even if the default password ends up matching (somehow)
    // with the provided password,
    // we never authenticate a non-existing user.
    user_id.ok_or_else(|| PublishError::AuthError(anyhow::anyhow!("Unknown username.")))
}
//! tests/api/helpers.rs
use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version};
// [...]
impl TestUser {
    // [...]

    async fn store(&self, pool: &PgPool) {
        let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
        // We don't care about the exact Argon2 parameters here
        // given that it's for testing purposes!
        let password_hash = Argon2::new(
            Algorithm::Argon2id,
            Version::V0x13,
            Params::new(15000, 2, 1, None).unwrap(),
        )
        .hash_password(self.password.as_bytes(), &salt)
        .unwrap()
        .to_string();
        // [...]
    }
}

现在不应该存在任何统计上显著的时间差异。