种子用户

在我们的测试套件中,一切看起来都很棒。

我们还没有对最新的功能进行任何探索性测试——我们停止了在浏览器中的胡乱操作, 几乎是在开始研究快乐路径的同时。这并非 巧合——我们目前无法进行快乐路径测试!

数据库中没有用户,也没有管理员注册流程——我们隐含的期望是 应用程序所有者会以某种方式成为新闻通讯的首位管理员! 现在是时候实现这一点了。

我们将创建一个种子用户——即添加一个迁移文件,在应用程序首次部署时将用户创建到数据库中。

种子用户将拥有一个预先确定的用户名和密码;

然后他们将能够在首次登录后更改密码。

数据库迁移

让我们使用 sqlx 创建一个新的迁移

sqlx migrate add seed_user

我们需要在用户表中插入一行新数据。我们需要:

  • 用户 ID (UUID)
  • 用户名
  • PHC 字符串

选择您喜欢的 UUID 生成器来获取有效的用户 ID。我们将使用 admin 作为用户名。

获取 PHC 字符串稍微麻烦一些——我们将使用 everythinghastostartsomewhere 作为密码,

但如何生成相应的 PHC 字符串呢?

我们可以利用我们在测试套件中编写的代码来“作弊”:

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

impl TestUser {
    pub fn generate() -> Self {
        Self {
            // [...]
            // password: Uuid::new_v4().to_string(),
            password: "everythinghastostartsomewhere".into(),
        }
    }

    async fn store(&self, pool: &PgPool) {
        // [...]
        let password_hash = /* */;
        dbg!(&password_hash);
        // [...]
    }
}

这只是一个临时的修改——之后只需运行 cargo test -- --nocapture 即可为我们的迁移脚本获取格式正确的 PHC 字符串。获取后请还原更改。

迁移脚本如下所示:

INSERT INTO users (user_id, username, password_hash)
VALUES (
  'ddf8994f-d522-4659-8d02-c1d479057be6',
  'admin',
  '$argon2id$v=19$m=15000,t=2,p=1$fA5tDKcNuhzfD6UD1Hmlsw$TN5KrFnqlxJBY7LUFpsV9OZZ/u0wKklR/KrRrzIras0'
);
sqlx migrate run

运行迁移,然后使用 cargo run 启动你的应用程序 - 你最终应该能够成功登录!

如果一切正常, /admin/dashboard 上应该会出现一条 "Welcome admin" 的消息。

恭喜!

重置密码

让我们从另一个角度来审视当前的情况——我们刚刚为一个高权限用户配置了已知的用户名/密码组合。 这很危险。

我们需要赋予种子用户更改密码的权限。这将是管理面板上的第一个功能!

构建此功能不需要任何新概念——请利用本节作为机会, 复习并确保您已经牢牢掌握了我们目前为止所讲的所有内容!

表单框架

让我们先来搭建所需的框架。这是一个基于表单的流程, 就像登录流程一样——我们需要一个 GET 端点来返回 HTML 表单,以及一个 POST 端点来处理提交的信息:

//! src/routes/admin.rs
// [...]
mod password;

pub use password::*;
//! src/routes/admin/password.rs
mod get;
mod post;

pub use get::change_password_form;
pub use post::change_password;
//! src/routes/admin/password/get.rs
use actix_web::{HttpResponse, http::header::ContentType};

pub async fn change_password_form() -> Result<HttpResponse, actix_web::Error> {
    Ok(HttpResponse::Ok().content_type(ContentType::html()).body(
        r#"<!DOCTYPE html>
<html lang="en">
<head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8">
    <title>Change Password</title>
</head>
<body>
    <form action="/admin/password" method="post">
    <label>Current password
    <input
        type="password"
        placeholder="Enter current password"
        name="current_password"
    >
    </label>
    <br>
    <label>New password
        <input
            type="password"
            placeholder="Enter new password"
            name="new_password"
        >
    </label>
    <br>
    <label>Confirm new password
        <input
            type="password"
            placeholder="Type the new password again"
            name="new_password_check"
        >
    </label>
    <br>
    <button type="submit">Change password</button>
    </form>
    <p><a href="/admin/dashboard">&lt;- Back</a></p>
</body>
</html>"#,
    ))
}
//! src/routes/admin/password/post.rs
use actix_web::{HttpResponse, web};
use secrecy::SecretString;

#[derive(serde::Deserialize)]
pub struct FormData {
    current_password: SecretString,
    new_password: SecretString,
    new_password_check: SecretString,
}

pub async fn change_password(form: web::Form<FormData>) -> Result<HttpResponse, actix_web::Error> {
    todo!()
}
//! src/startup.rs
// [...]

async fn run(
    // [...]
) -> Result<Server, anyhow::Error> {
    // [...]
    let server = HttpServer::new(move || {
        App::new()
            // [...]
            .route("/admin/password", web::get().to(change_password_form))
            .route("/admin/password", web::post().to(change_password))
            // [...]
    })
    // [...]
}

就像管理面板本身一样,我们不想向未登录的用户显示更改密码的表单。

让我们添加两个集成测试:

//! tests/api/main.rs
mod change_password;
// [...]
//! tests/api/helpers.rs
// [...]

impl TestApp {
    pub async fn get_change_password(&self) -> reqwest::Response {
        self.api_client
            .get(format!("{}/admin/password", &self.address))
            .send()
            .await
            .expect("Failed t execute request.")
    }
    
    pub async fn post_change_password<Body>(&self, body: &Body) -> reqwest::Response
    where
        Body: serde::Serialize,
    {
        self.api_client
            .post(format!("{}/admin/password", &self.address))
            .form(body)
            .send()
            .await
            .expect("Failed to execute request")
    }
    // [...]
}
//! tests/api/change_password.rs
use uuid::Uuid;

use crate::helpers::{assert_is_redirect_to, spawn_app};

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

    // Act
    let response = app.get_change_password().await;

    // Assert
    assert_is_redirect_to(&response, "/login");
}

#[tokio::test]
async fn you_must_br_logged_in_to_change_your_password() {
    // Arrange
    let app = spawn_app().await;
    let new_password = Uuid::new_v4().to_string();

    // Act
    let response = app
        .post_change_password(&serde_json::json!({
            "current_password": Uuid::new_v4().to_string(),
            "new_password": &new_password,
            "new_password_check": &new_password,
        }))
        .await;

    // Assert
    assert_is_redirect_to(&response, "/login");
}

然后我们可以通过在请求处理程序中添加检查来满足要求:

//! src/routes/admin/password/get.rs
use crate::session_state::TypedSession;
use crate::utils::{e500, see_other};
// [...]

use actix_web::{HttpResponse, http::header::ContentType};

use crate::session_state::TypedSession;
use crate::utils::{e500, see_other};

pub async fn change_password_form(session: TypedSession) -> Result<HttpResponse, actix_web::Error> {
    if session.get_user_id().map_err(e500)?.is_none() {
        return Ok(see_other("/login"));
    }

    Ok(/* */)
}
//! src/routes/admin/password/post.rs
use crate::session_state::TypedSession;
use crate::utils::{e500, see_other};
// [...]

pub async fn change_password(
    form: web::Form<FormData>,
    session: TypedSession,
) -> Result<HttpResponse, actix_web::Error> {
    if session.get_user_id().map_err(e500)?.is_none() {
        return Ok(see_other("/login"));
    }
    todo!()
}
//! src/utils.rs
use actix_web::{HttpResponse, http::header::LOCATION};

// Return an opaque 500 while preserving the error's root cause for logging.
pub fn e500<T>(e: T) -> actix_web::Error
where
    T: std::fmt::Debug + std::fmt::Display + 'static,
{
    actix_web::error::ErrorInternalServerError(e)
}

pub fn see_other(location: &str) -> HttpResponse {
    HttpResponse::SeeOther()
        .insert_header((LOCATION, location))
        .finish()
}
//! src/lib.rs
// [...]
pub mod utils;
//! src/routes/admin/dashboard.rs
// The definitation of e500 has been moved to sec/utils.rs
use crate::utils::e500;
// [...]

我们也不希望密码修改表单变成一个孤立页面——让我们在管理面板中添加一个可用操作列表 以及指向新页面的链接:

//! src/routes/admin/dashboard.rs
// [...]

pub async fn admin_dashboard(/* */) -> Result</* */> {
    // [...]

    Ok(HttpResponse::Ok()
        .content_type(ContentType::html())
        .body(format!(
            r#"<!DOCTYPE html>
<html lang="en">
<head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8">
    <title>Admin dashboard</title>
</head>
<body>
    <p>Welcome {username}!</p>
    <p>Available actions:</p>
    <ol>
        <li><a href="/admin/password">Change password</a></li>
    </ol>
</body>
</html>"#
        )))
}

不愉快路径: 新密码不匹配

我们已经完成了所有准备工作,现在是时候开始开发核心功能了。

让我们先从一个不太理想的情况开始——我们要求用户输入两次新密码,但两次输入的密码不一致。我们希望用户能够重定向回表单,并显示相应的错误消息。

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


#[tokio::test]
async fn new_password_fields_must_match() {
    // Arrange
    let app = spawn_app().await;
    let new_password = Uuid::new_v4().to_string();
    let another_new_password = Uuid::new_v4().to_string();

    // Act - Part 1 - Login
    app.post_login(&serde_json::json!({
        "username": &app.test_user.username,
        "password": &app.test_user.password,
    }))
    .await;

    // Act - Part2 - Try to change password
    let response = app
        .post_change_password(&serde_json::json!({
            "current_password" : &app.test_user.password,
            "new_password": &new_password,
            "new_password_check": &another_new_password,
        }))
        .await;

    assert_is_redirect_to(&response, "/admin/password");

    // Act - Part 3 - Follow the redirect
    let html_page = app.get_change_password_html().await;
    assert!(html_page.contains(
        "<p><i>You entered two different new passwords - \
        the field values must match.</i></p>"
    ));
}
//! tests/api/helpers.rs
// [...]

impl TestApp {
    // [...]

    pub async fn get_change_password_html(&self) -> String {
        self.get_change_password().await.text().await.unwrap()
    }
}

测试失败了,因为请求处理程序崩溃了。让我们修复它:

//! src/routes/admin/password/post.rs
use secrecy::ExposeSecret;
// [...]

pub async fn change_password(/* */) -> Result</* */> {
    // [...]
    // `SecretString` does not implement `Eq`
    // therefore we need to compare the underlying `String`
    if form.new_password.expose_secret() != form.new_password_check.expose_secret() {
        return Ok(see_other("/admin/password"));
    }
    todo!()
}

这处理了重定向(测试的第一部分),但它不处理错误消息:

thread 'change_password::new_password_fields_must_match' panicked at tests/api/change_password.rs:63:5:
assertion failed: html_page.contains("...")

我们之前已经通过登录表单经历过这个过程 - 我们可以再次使用 flash message!

//! src/routes/admin/password/post.rs
// [...]
use actix_web_flash_messages::FlashMessage;

pub async fn change_password(/* */) -> Result</* */> {
    // [...]
    if form.new_password.expose_secret() != form.new_password_check.expose_secret() {
        FlashMessage::error(
            "You entered two different new passwords - the field values must match.",
        )
        .send();
        // [...]
    }

    todo!()
}
//! src/routes/admin/password/get.rs
// [...]
use actix_web_flash_messages::IncomingFlashMessages;
use std::fmt::Write;

pub async fn change_password_form(
    session: TypedSession,
    flash_messages: IncomingFlashMessages,
) -> Result<HttpResponse, actix_web::Error> {
    // [...]
    let mut msg_html = String::new();
    for m in flash_messages.iter() {
        writeln!(msg_html, "<p><i>{}</i></p>", m.content()).unwrap();
    }
    Ok(HttpResponse::Ok()
        .content_type(ContentType::html())
        .body(format!(
            r#"<!DOCTYPE html>
        <html lang="en">
        <!-- [...] -->
        <body>
            {msg_html}
            <!-- [...] -->
        </body>
        </html>"#
        )))
}

现在测试应该通过了

不愉快路径: 当前密码无效

您可能已经注意到,我们要求用户在表单中提供其当前密码。这是为了防止攻击者 成功获取有效会话令牌后锁定合法用户的帐户。

让我们添加一个集成测试,以指定当提供的当前密码无效时我们期望看到的内容:

//! tests/api/change_password.rs
// [...]
#[tokio::test]
async fn current_password_must_be_valid() {
    // Arrange
    let app = spawn_app().await;
    let new_password = Uuid::new_v4().to_string();
    let wrong_password = Uuid::new_v4().to_string();

    // Act - Part 1 - Login
    app.post_login(&serde_json::json!({
        "username": &app.test_user.username,
        "password": &app.test_user.password
    }))
    .await;

    // Act - Part 2 - Try to change password
    let response = app
        .post_change_password(&serde_json::json!({
            "current_password": &wrong_password,
            "new_password": &new_password,
            "new_password_check": &new_password
        }))
        .await;

    // Assert
    assert_is_redirect_to(&response, "/admin/password");

    // Act - Part 3 - Follow the redirect
    let html_page = app.get_change_password_html().await;
    assert!(html_page.contains("<p><i>The current password is incorrect.</i></p>"));
}

为了验证 current_password 传递的值,我们需要检索用户名,然后调用 validate_credentials 例程,该例程负责我们的登录表单。

让我们从用户名开始:

//! src/routes/admin/password/post.rs
use crate::routes::admin::dashboard::get_username;
use sqlx::PgPool;
// [...]

pub async fn change_password(
    // [...]
    pool: web::Data<PgPool>,
    session: TypedSession,
) -> Result<HttpResponse, actix_web::Error> {
    let Some(user_id) = session.get_user_id().map_err(e500)? else {
        return Ok(see_other("/login"));
    };

    if form.new_password.expose_secret() != form.new_password_check.expose_secret() {
        // [...]
    }

    let username = get_username(user_id, &pool).await.map_err(e500)?;

    todo!()
}
//! src/routes/admin/dashboard.rs
// [...]

#[tracing::instrument(/* */)]
// Marked as `pub`!
pub async fn get_username(/* */) -> Result</* */> {
    // [...]
}

现在我们可以将用户名和密码组合传递给 validate_credentials - 如果验证失败,我们需要根据返回的错误采取不同的操作:

//! src/routes/admin/password/post.rs
// [...]
use crate::authentication::{validate_credentials, AuthError, Credentials};

pub async fn change_password(/* */) -> Result</* */> {
    // [...]

    let username = get_username(user_id, &pool).await.map_err(e500)?;
    let credentials = Credentials {
        username,
        password: form.0.current_password,
    };
    if let Err(e) = validate_credentials(credentials, &pool).await {
        return match e {
            AuthError::InvalidCredentials(_) => {
                FlashMessage::error("The current password is incorrect.").send();
                Ok(see_other("/admin/password"))
            }
            AuthError::UnexpectedError(_) => Err(e500(e).into()),
        };
    }

    todo!()
}

测试应该通过了

不愉快路径: 新密码太短

我们不希望用户选择强度较低的密码——这会将他们的账户暴露给攻击者。

OWASP 对密码强度提出了POST /admin/password——密码长度应大于 12 个字符,小于 128 个字符。

请将这些验证检查添加到我们的 POST /admin/password 端点,作为练习!

登出

现在终于到了看看完美路径的时候了——用户成功更改了密码。

我们将使用以下场景来检查一切是否按预期运行:

  • 登录
  • 通过提交密码更改表单来更改密码
  • 注销
  • 使用新密码重新登录

只剩下一个障碍——我们还没有注销端点!

在继续下一步之前,让我们先努力弥补这个功能上的差距。

首先,让我们在测试中编写我们的需求:

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

// TODO: wip