种子用户
在我们的测试套件中,一切看起来都很棒。
我们还没有对最新的功能进行任何探索性测试——我们停止了在浏览器中的胡乱操作, 几乎是在开始研究快乐路径的同时。这并非 巧合——我们目前无法进行快乐路径测试!
数据库中没有用户,也没有管理员注册流程——我们隐含的期望是 应用程序所有者会以某种方式成为新闻通讯的首位管理员! 现在是时候实现这一点了。
我们将创建一个种子用户——即添加一个迁移文件,在应用程序首次部署时将用户创建到数据库中。
种子用户将拥有一个预先确定的用户名和密码;
然后他们将能够在首次登录后更改密码。
数据库迁移
让我们使用 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"><- 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