登录
让我们开始处理登录表单。
我们需要连接一个端点占位符,就像之前对 GET / 所做的那样。我们将在 GET /login 中提供登录表单。
//! src/routes.rs
// [...]
// New module!
mod login;
pub use login::*;
//! src/routes/login.rs
mod get;
pub use get::login_form;
//! src/routes/login/get.rs
use actix_web::HttpResponse;
pub async fn login_form() -> HttpResponse {
HttpResponse::Ok().finish()
}
//! src/startup.rs
// [...]
pub fn run(
// [...]
) -> Result<Server, std::io::Error> {
// [...]
let server = HttpServer::new(move || {
App::new()
// [...]
.route("/login", web::get().to(login_form))
// [...]
})
// [...]
}
HTML 表单
这次 HTML 会更加复杂:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<title>Login</title>
</head>
<body>
<form>
<label>Username
<input type="text" placeholder="Enter Username" name="username">
</label>
<label>Password
<input type="password" placeholder="Enter password" name="password">
</label>
<button type="submit">Login</button>
</form>
</body>
</html>
//! src/routes/login/get.rs
use actix_web::{HttpResponse, http::header::ContentType};
pub async fn login_form() -> HttpResponse {
HttpResponse::Ok()
.content_type(ContentType::html())
.body(include_str!("login.html"))
}
form 是执行繁重工作的 HTML 元素。它的作用是收集一组数据字段,并将它们发送到后端服务器进行处理。
这些字段使用 input 元素定义——这里有两个: 用户名和密码。
输入元素被赋予 type 属性——它告诉浏览器如何显示它们。
text 和 password 都将呈现为单行自由文本字段,但有一个关键区别:
输入到密码字段的字符会被混淆。
每个输入元素都包裹在一个 label 元素中:
- 点击标签名称可切换输入字段
- 它提高了屏幕阅读器用户的可访问性(当用户将焦点放在元素上时,会大声读出)。
我们为每个输入元素设置了另外两个属性:
placeholder, 其值在用户开始填写表单之前在文本字段中显示为建议值;name, 我们必须在后端使用该键来识别已提交表单数据中的字段值。
表单末尾有一个按钮,它会触发将提供的输入提交到后端。
如果您输入随机的用户名和密码并尝试提交,会发生什么?
页面会刷新,输入字段会被重置——但 URL 已经改变了!
现在应该是 localhost:8000/login?username=myusername&password=mysecretpassword。
这是表单的默认行为—— form 使用 GET HTTP 动词将数据提交到它所服务的同一页面(即 /login)。这远非理想情况——正如您刚刚看到的,通过 GET 提交的表单会将所有输入数据以明文形式编码为查询参数。作为 URL 的一部分,它们最终会被存储为浏览器的导航历史记录。查询参数也会被捕获到日志中(例如,我们自己后端的 http.route 属性)。
我们真的不希望在那里存储密码或任何类型的敏感数据。
我们可以通过设置 form 上的 action 和 method 的值来改变这种行为:
<!-- src/routes/login/login.html -->
<!-- [...] -->
<form action="/login" method="post"></form>
<!-- [...] -->
从技术上讲,我们可以省略 action,但默认行为的文档记录并不详尽,因此明确定义它会更清晰。
由于 method="post", 输入数据将通过请求主体传递到后端,这是一个更安全的选择。
如果您尝试再次提交表单,您应该会在 POST /login 的 API 日志中看到 404 错误。让我们定义端点!
//! src/routes/login.rs
// [...]
mod post;
pub use post::login;
//! src/routes/login/post.rs
use actix_web::HttpResponse;
pub async fn login() -> HttpResponse {
HttpResponse::Ok().finish()
}
//! src/startup.rs
use crate::routes::login;
// [...]
pub fn run(
listener: TcpListener,
db_pool: PgPool,
email_client: EmailClient,
base_url: String,
) -> Result<Server, std::io::Error> {
// [...]
let server = HttpServer::new(move || {
App::new()
// [...]
.route("/login", web::post().to(login))
// [...]
})
// [...]
}
成功后重定向
尝试重新登录:表单将消失,您将看到一个空白页面。这不是 最好的反馈方式——理想情况下,显示一条确认用户已登录的消息才是理想的。此外,如果用户尝试刷新页面,浏览器会提示他们确认是否要再次提交表单。
我们可以通过使用重定向来改善这种情况——如果身份验证成功,我们会指示浏览器导航回我们的主页。
重定向响应需要两个元素:
- 重定向状态码;
- Location 标头,设置为我们要重定向到的 URL。
所有重定向状态码都在 3xx 范围内——我们需要根据 HTTP 动词和我们想要传达的语义(例如,临时重定向还是永久重定向)选择最合适的重定向。
您可以在 MDN Web 文档中找到完整的指南。303 See Other 最适合我们的用例(表单提交后的确认页面):
//! src/routes/login/post.rs
use actix_web::{HttpResponse, http::header::LOCATION};
pub async fn login() -> HttpResponse {
HttpResponse::SeeOther()
.insert_header((LOCATION, "/"))
.finish()
}
提交表单后,您现在应该会看到 "Welcome to our newsletter!"。
处理表单数据
说实话,我们并不是在成功时重定向——我们一直在重定向。
我们需要增强登录功能,以便真正验证传入的凭据。
正如我们在第 3 章中看到的,表单数据使用 application/x-www-form-urlencoded 内容类型提交到后端。
我们可以使用 actix-web 的表单提取器和一个实现 serde::Deserialize 的结构体从传入请求中解析出它:
//! src/routes/login/post.rs
// [...]
use actix_web::{HttpResponse, http::header::LOCATION, web};
use secrecy::SecretString;
#[derive(serde::Deserialize)]
pub struct FormData {
username: String,
password: SecretString,
}
pub async fn login(_form: web::Form<FormData>) -> HttpResponse {
// [...]
}
我们在本章前面部分构建了基于密码的身份验证的基础 - 让我们 再次看一下 POST /newsletters 处理程序中的授权码:
//! 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));
// [...]
}
basic_authentication 处理从 Authorization 标头中提取凭据当使用"Basic"身份验证方案时——我们不想在登录时重复使用它。
validation_credentials 才是我们想要的: 它以用户名和密码作为输入,返回相应的 user_id(如果身份验证成功)或错误(如果凭据无效)。
validation_credentials 的当前定义受到 publish_newsletters 关注点的影响:
//! 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)??;
user_id.ok_or_else(|| PublishError::AuthError(anyhow::anyhow!("Unknown username.")))
}
构建一个 authentication 模块
让我们重构 validate_credentials, 以便为提取做好准备——我们想要构建一个共享的身份验证模块,它将在
POST /login 和 POST /newsletters 中同时使用。
让我们定义一个新的错误枚举 AuthError:
//! src/lib.rs
pub mod authentication;
//! src/authentication.rs
#[derive(thiserror::Error, Debug)]
pub enum AuthError {
#[error("Invalid credentials.")]
InvalidCredentials(#[source] anyhow::Error),
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}
我们使用枚举,是因为就像我们在 POST /newsletters 中所做的那样, 我们希望能够让调用者根据错误类型做出不同的响应 - 例如,对于 UnexpectedError 返回 500,而对于 AuthErrors 则返回 401。
现在,让我们将 validate_credentials 的签名更改为返回 Result<uuid::Uuid, AuthError>:
//! src/routes/newsletters.rs
usae crate::authentication::AuthError;
// [...]
async fn validate_credentials(
credentials: Credentials,
pool: &PgPool,
) -> Result<uuid::Uuid, AuthError> {
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?
{
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")??;
user_id
.ok_or_else(|| anyhow::anyhow!("Unknown username."))
.map_err(AuthError::InvalidCredentials)
}
cargo check 返回了两个错误
error[E0277]: `?` couldn't convert the error to `PublishError`
--> src/routes/newsletters.rs:85:65
|
85 | let user_id = validate_credentials(credentials, &pool).await?;
| ----------------------------------------------^ the trait `std::convert::Fro
m<AuthError>` is not implemented for `PublishError`
| |
| this can't be annotated with `?` because it has type `Result<_, AuthError>`
|
error[E0277]: `?` couldn't convert the error to `AuthError`
--> src/routes/newsletters.rs:206:34
|
202 | / spawn_blocking_with_tracing(move || {
203 | | verify_password_hash(expected_password_hash, credentials.password)
204 | | })
205 | | .await
206 | | .context("Invalid password")??;
| | -^ the trait `std::convert::From<PublishError>` is not impl
emented for `AuthError`
| |_________________________________|
| this can't be annotated with `?` because it has type `Resul
t<_, PublishError>`
|
第一个错误来自 validate_credentials 本身——我们正在调用 verify_password_hash, 它仍然返回 PublishError。
//! src/routes/newsletters.rs
// [...]
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)
}
让我们修正它:
//! src/routes/newsletters.rs
// [...]
fn verify_password_hash(
expected_password_hash: SecretString,
password_candidate: SecretString,
) -> Result<(), AuthError> {
let expected_password_hash = PasswordHash::new(expected_password_hash.expose_secret())
.context("Failed to parse hash in PHC string format.")?;
Argon2::default()
.verify_password(
password_candidate.expose_secret().as_bytes(),
&expected_password_hash,
)
.context("Invalid password.")
.map_err(AuthError::InvalidCredentials)
}
让我们处理第二个错误:
--> src/routes/newsletters.rs:85:65
|
85 | let user_id = validate_credentials(credentials, &pool).await?;
| ----------------------------------------------^ the trait `std::convert::Fro
m<AuthError>` is not implemented for `PublishError`
| |
| this can't be annotated with `?` because it has type `Result<_, AuthError>`
|
这源于在请求处理程序 publish_newsletters 中对 verify_credentials 的调用。
AuthError 未实现到 PublishError 的转换,因此无法使用 ? 运算符。
我们将调用 map_err 来内联执行映射:
//! 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 user_id = validate_credentials(credentials, &pool)
.await
// We match on `AuthError`'s variants, bit we pass the **whole** error
// into the constructors for `PublishError` variants. This ensures that
// the context of the top-level wrapped is preserved when the error is
// logged by our middleware.
.map_err(|e| match e {
AuthError::InvalidCredentials(_) => PublishError::AuthError(e.into()),
AuthError::UnexpectedError(_) => PublishError::UnexpectedError(e.into()),
})?;
// [...]
}
现在代码应该可以编译通过了
让我们通过将 validate_credentials、Credentials、get_stored_credentials
和 verify_password_hash 移到 authentication 模块来完成提取:
//! src/authentication.rs
use anyhow::Context;
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use secrecy::{ExposeSecret, SecretString};
use sqlx::PgPool;
use crate::telemetry::spawn_blocking_with_tracing;
// [...]
pub struct Credentials {
// These two fields were not marked as `pub` before!
pub username: String,
pub password: SecretString,
}
#[tracing::instrument(/* */)]
pub async fn validate_credentials(/* */) -> Result<uuid::Uuid, AuthError> {
// [...]
}
#[tracing::instrument(/* */)]
fn verify_password_hash(/* */) -> Result<(), AuthError> {
// [...]
}
#[tracing::instrument(/* */)]
async fn get_stored_credentials(/* */) -> Result<Option<(uuid::Uuid, SecretString)>, anyhow::Error> {
// [...]
}
//! src/routes/newsletters.rs
// [...]
use crate::authentication::{validate_credentials, AuthError, Credentials};
// There will be warnings about unused imports, follow the compiler to fix them!
// [...]
拒绝无效凭证
提取的 authentication 模块现在可以在我们的 login 函数中使用了。
让我们将其插入:
//! src/routes/login/post.rs
use actix_web::{HttpResponse, http::header::LOCATION, web};
use secrecy::SecretString;
use sqlx::PgPool;
use crate::authentication::{Credentials, validate_credentials};
#[derive(serde::Deserialize)]
pub struct FormData {
username: String,
password: SecretString,
}
#[tracing::instrument(
skip(form, pool),
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
pub async fn login(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
let credentials = Credentials {
username: form.0.username,
password: form.0.password,
};
tracing::Span::current().record("username", tracing::field::display(&credentials.username));
match validate_credentials(credentials, &pool).await {
Ok(user_id) => {
tracing::Span::current().record("user_id", tracing::field::display(&user_id));
HttpResponse::SeeOther()
.insert_header((LOCATION, "/"))
.finish()
}
Err(_) => {
todo!()
}
}
}
使用随机凭证的登录尝试现在应该会失败:请求处理程序会因为 validation_credentials 返回错误而崩溃,进而导致 actix-web 断开连接。
这并非优雅的失败——浏览器可能会显示类似以下内容: 连接已重置。
我们应该尽可能避免请求处理程序中出现崩溃——所有错误都应该优雅地处理。
让我们引入一个 LoginError:
//! src/routes/login/post.rs
// [...]
use actix_web::{
HttpResponse, ResponseError,
http::{StatusCode, header::LOCATION},
web,
};
use crate::{
authentication::{Credentials, validate_credentials},
routes::error_chain_fmt,
};
#[derive(serde::Deserialize)]
pub struct FormData {
username: String,
password: SecretString,
}
#[tracing::instrument(
skip(form, pool),
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
pub async fn login(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, LoginError> {
// [...]
let user_id = validate_credentials(credentials, &pool)
.await
.map_err(|e| match e {
AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()),
})?;
tracing::Span::current().record("user_id", tracing::field::display(&user_id));
Ok(HttpResponse::SeeOther()
.insert_header((LOCATION, "/"))
.finish())
}
#[derive(thiserror::Error)]
pub enum LoginError {
#[error("Authentication failed")]
AuthError(#[source] anyhow::Error),
#[error("Something went wrong")]
UnexpectedError(#[from] anyhow::Error),
}
impl std::fmt::Debug for LoginError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}
impl ResponseError for LoginError {
fn status_code(&self) -> actix_web::http::StatusCode {
match self {
LoginError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
LoginError::AuthError(_) => StatusCode::UNAUTHORIZED,
}
}
}
这段代码与我们之前重构 POST /newsletters 时编写的代码非常相似。
这会对浏览器产生什么影响?
提交表单会触发页面加载,导致屏幕上显示"Authentication failed"。
比以前好多了,我们正在取得进展!
语境错误
错误信息已经足够清晰了——但用户接下来应该怎么做呢?
我们合理地假设他们想再次尝试输入凭证——他们可能拼错了用户名或密码。
我们需要将错误信息显示在登录表单的顶部——为用户提供信息,同时允许他们快速重试。
简单实现
最简单的方法是什么?
我们可以从 ResponseError 返回登录 HTML 页面,并注入一个额外的段落 (<p>
HTML 元素) 来向用户报告错误。
它看起来应该像这样:
//! src/routes/login/post.rs
// [...]
impl ResponseError for LoginError {
fn status_code(&self) -> actix_web::http::StatusCode {
// [...]
}
fn error_response(&self) -> HttpResponse<actix_web::body::BoxBody> {
HttpResponse::build(self.status_code())
.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>Login</title>
</head>
<body>
<p><i>{}</i></p>
<form action="/login" method="post">
<label>Username
<input
type="text"
placeholder="Enter Username"
name="username"
>
</label>
<label>Password
<input
type="password"
placeholder="Enter Password"
name="password"
>
</label>
<button type="submit">Login</button>
</form>
</body>
</html>
"#,
self
))
}
}
这种方法有几个缺点:
-
我们有两个略有不同但几乎完全相同的登录页面,定义在两个不同的地方。 如果我们决定修改登录表单,我们需要记住同时修改这两个页面
-
如果用户在登录失败后尝试刷新页面,系统会提示用户确认是否重新提交表单。
为了解决第二个问题,我们需要让用户登录到 GET 端点。
为了解决第一个问题,我们需要找到一种方法来重用我们在 GET /login 中编写的 HTML, 而不是复制它。
我们可以通过另一个重定向来实现这两个目标:如果身份验证失败,我们会将用户返回到 GET /login。
//! src/routes/login/post.rs
// [...]
impl ResponseError for LoginError {
fn error_response(&self) -> HttpResponse<actix_web::body::BoxBody> {
HttpResponse::build(self.status_code())
.insert_header((LOCATION, "/login"))
.finish()
}
fn status_code(&self) -> StatusCode {
StatusCode::SEE_OTHER
}
}
不幸的是,普通的重定向是不够的——浏览器会再次向用户显示登录表单,而且没有任何反馈来解释他们的登录尝试失败。
我们需要找到一种方法来指示 GET /login 显示错误消息。
让我们来探索一些方案。
查询参数
Location 标头的值决定了用户将被重定向到的 URL。
但这还不够——我们还可以指定查询参数!
让我们将身份验证错误消息编码到错误查询参数中。
查询参数是 URL 的一部分——因此我们需要对 LoginError 的显示表示进行 URL 编码。
#! Cargo.toml
# [...]
[dependencies]
urlencoding = "2"
impl ResponseError for LoginError {
fn error_response(&self) -> HttpResponse<actix_web::body::BoxBody> {
let encoded_error = urlencoding::Encoded::new(self.to_string());
HttpResponse::build(self.status_code())
.insert_header((LOCATION, format!("/login?error={encoded_error}")))
.finish()
}
// [...]
}
然后可以在 GET /login 的请求处理程序中提取错误查询参数。
//! src/routes/login/get.rs
use actix_web::{HttpResponse, http::header::ContentType, web};
#[derive(serde::Deserialize)]
pub struct QueryParams {
error: Option<String>,
}
pub async fn login_form(query: web::Query<QueryParams>) -> HttpResponse {
let _error = query.0.error;
HttpResponse::Ok()
.content_type(ContentType::html())
.body(include_str!("login.html"))
}
最后,我们可以根据其值定制返回的 HTML 页面:
//! src/routes/login/get.rs
// [...]
pub async fn login_form(query: web::Query<QueryParams>) -> HttpResponse {
let error_html = match query.0.error {
None => "".into(),
Some(error_message) => format!("<p><i>{error_message}</i><p>"),
};
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>Login</title>
</head>
<body>
{error_html}
<form action="/login" method="post">
<label>Username
<input
type="text"
placeholder="Enter Username"
name="username"
>
</label>
<label>Password
<input
type="password"
placeholder="Enter Password"
name="password"
>
</label>
<button type="submit">Login</button>
</form>
</body>
</html>
"#
))
}
有用!
跨站脚本(XSS)
查询参数并非私密信息 - 我们的后端服务器无法阻止用户修改 URL。
尤其无法阻止攻击者利用这些参数。
请尝试访问以下 URL:
http://localhost:8000/login?error=Your%20account%20has%20been%20locked%2C%20 please%20submit%20your%20details%20%3Ca%20href%3D%22https%3A%2F%2Fzero2prod.com %22%3Ehere%3C%2Fa%3E%20to%20resolve%20the%20issue
在登录表单顶部,您将看到
Your account has been locked, please submit your details here to resolve the issue.
这里是一个指向另一个网站的链接(在本例中为 zero2prod.com)。
在更现实的情况下,这里会链接到一个由攻击者控制的网站,诱使受害者泄露其登录凭据。
这被称为跨站脚本攻击 (XSS)。
攻击者利用来自不可信来源(例如用户输入、查询参数等)的动态内容,将 HTML 片段或 JavaScript 代码段注入受信任的网站。
从用户的角度来看,XSS 攻击尤其隐蔽——URL 与您想要访问的 URL 匹配, 因此您很可能会信任显示的内容。
OWASP 提供了一份关于如何预防 XSS 攻击的详尽备忘单——如果您正在开发 Web 应用程序,我强烈建议熟悉它。
让我们看一下针对我们问题的指南:我们希望在 HTML 元素中显示不受信任的数据(查询参数的值) (<p><i>此处显示不受信任的数据</i></p>)。
根据 OWASP 的指南,我们必须对不受信任的输入进行 HTML 实体编码,即:
- 将
&转换为& - 将
<转换为< - 将
>转换为> - 将
"转换为" - 将
'转换为' - 将
/转换为/
HTML 实体编码通过转义定义 HTML 元素所需的字符来阻止插入其他 HTML 元素。
让我们修改 login_form 处理程序:
#! Cargo.toml
# [...]
[dependencies]
htmlescape = "0.3"
//! src/routes/login/get.rs
// [...]
pub async fn login_form(query: web::Query<QueryParams>) -> HttpResponse {
let error_html = match query.0.error {
None => "".into(),
Some(error_message) => format!(
"<p><i>{}</i><p>",
htmlescape::encode_minimal(&error_message)
),
};
// [...]
}
再次加载受损的 URL - 您将看到不同的消息:
Your account has been locked, please submit your details
<a href=“https://zero2prod.com”>here</a>to resolve the issue.
HTML a 元素不再被浏览器渲染——用户现在有理由怀疑有什么不对劲。
这样就够了吗?
至少,与直接点击相比,用户不太可能复制粘贴并导航到链接。
尽管如此,攻击者并非天真——一旦他们注意到我们的网站正在执行 HTML 实体编码,他们就会立即修改注入的消息。
这可能很简单:
Your account has been locked, please call +CC3332288777 to resolve the issue.
这或许足以引诱几个受害者。我们需要比角色逃脱更强的手段。
消息认证码
我们需要一种机制来验证查询参数是否已由我们的 API 设置,并且未被第三方更改。
这被称为消息认证——它保证消息在传输过程中未被修改(完整性),并允许您验证发送者的身份(数据源认证)。
消息认证码 (MAC) 是一种常用的消息认证技术——在消息中添加一个标签,允许验证者检查其完整性和来源。
HMAC 是一个著名的 MAC 系列——基于哈希的消息认证码。
HMAC 围绕一个密钥和一个哈希函数构建。
密钥被添加到消息的前面,并将生成的字符串输入到哈希函数中。
然后将生成的哈希值与密钥连接起来,再次进行哈希运算——输出就是消息标签。
伪代码如下:
let hmac_tag = hash(
concat(
key,
hash(concat(key, message))
)
);
我们特意省略了有关键填充的一些细微差别——您可以在 RFC 2104 中找到所有详细信息。
添加 HMAC 标签来保护查询参数
让我们尝试使用 HMAC 来验证查询参数的完整性和来源。
Rust Crypto 组织提供了 HMAC 的实现,即 hmac crate。我们还需要一个哈希函数——我们选择 SHA-256。
#! Cargo.toml
# [...]
[dependencies]
hmac = { version = "0.12", features = ["std"] }
sha2 = "0.10"
让我们在 Location header 中添加另一个查询参数 tag,用于存储错误消息的 HMAC。
//! src/routes/login/post.rs
use hmac::{Hmac, Mac};
// [...]
impl ResponseError for LoginError {
fn error_response(&self) -> HttpResponse<actix_web::body::BoxBody> {
let query_string = format!("error={}", urlencoding::Encoded::new(self.to_string()));
// We need the secret there - how do we get it?
let secret: &[u8] = todo!();
let hmac_tag = {
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(secret).unwrap();
mac.finalize().into_bytes()
};
HttpResponse::build(self.status_code())
// Appending the hexadecimal representation of the HMAC tag to the
// query string as an additional query parameter.
.insert_header((LOCATION, format!("/login?{query_string}&tag={hmac_tag:x}")))
.finish()
}
// [...]
}
这段代码几乎完美了——我们只需要一种方法来获取密钥!
可惜的是,这在 ResponseError 内部无法实现——我们只能访问我们试图转换为 HTTP 响应的错误类型 (LoginError)。ResponseError 只是一个特化的 Into trait。
具体来说,我们无法访问应用程序状态(即我们无法使用 web::Data
提取器),而这正是我们存储密钥的地方。
让我们将代码移回请求处理程序:
//! src/routes/login/post.rs
use secret::ExposeSecret;
// [...]
pub async fn login(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
// Injecting the secret as a secret string for the time being.
secret: web::Data<SecretString>,
// No longer returning a `Result<HttpResponse, LoginError>`~
) -> HttpResponse {
// [...]
match validate_credentials(credentials, &pool).await {
Ok(user_id) => {
tracing::Span::current().record("user_id", &tracing::field::display(&user_id));
HttpResponse::SeeOther()
.insert_header((LOCATION, "/"))
.finish()
}
Err(e) => {
let e = match e {
AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()),
};
let query_string = format!("error={}", urlencoding::Encoded::new(e.to_string()));
let hmac_tag = {
let mut mac =
Hmac::<sha2::Sha256>::new_from_slice(secret.expose_secret().as_bytes())
.unwrap();
mac.update(query_string.as_bytes());
mac.finalize().into_bytes()
};
HttpResponse::SeeOther()
.insert_header((
LOCATION,
format!("/login?{}&tag={:x}", query_string, hmac_tag),
))
.finish()
}
}
}
// The `ResponseError` implementation for `LoginError` has been deleted.
这是一种可行的方法,而且可以编译通过。
但它有一个缺点——我们不再将错误上下文传播到上游中间件链。
这在处理 LoginError::UnexpectedError 时会令人担忧——我们的日志应该
真正捕获出错的地方。
幸运的是,有一种方法可以鱼与熊掌兼得: actix_web::error::InternalError。
InternalError 可以由 HttpResponse 和错误构建。它可以作为错误从请求处理程序(它实现了 ResponseError 接口)返回,并将你传递给其构造函数的 HttpResponse 返回给调用者——这正是我们所需要的!
让我们再次修改登录方法以使用它:
//! src/routes/login/post.rs
// [...]
// Returning a `Result` again!
pub async fn login(
// [...]
) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(credentials, &pool).await {
Ok(user_id) => {
// [...]
// We need to Ok-wrap again
Ok(/* */)
}
Err(e) => {
// [...]
let response = HttpResponse::SeeOther()
.insert_header((
LOCATION,
format!("/login?{}&tag={:x}", query_string, hmac_tag),
))
.finish();
Err(InternalError::from_response(e, response))
}
}
}
错误报告已保存。
我们还剩最后一个任务: 将 HMAC 使用的密钥注入应用程序状态。
//! src/configuration.rs
// [...]
#[derive(serde::Deserialize, Clone)]
pub struct ApplicationSettings {
// [...]
pub hmac_secret: SecretString,
}
//! src/startup.rs
use secrecy:::SecretString;
// [...]
impl Application {
pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> {
// [...]
let server = run(
listener,
connection_pool,
email_client,
configuration.application.base_url,
configuration.application.hmac_secret,
)?;
}
// [...]
}
pub fn run(
// [...]
hmac_secret: SecretString,
) -> Result<Server, std::io::Error> {
// [...]
let server = HttpServer::new(move || {
App::new()
// [...]
.app_data(web::Data::new(hmac_secret.clone()))
})
// [...]
}
#! configuration/base.yml
application:
# [...]
# You need to set the `APP_APPLICATION__HMAC_SECRET` environment variable
# on Digital Ocean as well for production!
hmac_secret: "super-long-and-secret-random-key-needed-to-verify-message-integrity"
# [...]
使用 SecretString 作为注入应用状态的类型远非理想。String 是一种原始类型,存在很大的冲突风险——例如,另一个中间件或服务会注册另一个 SecretString 到应用状态,从而覆盖我们的 HMAC 密钥(反之亦然)。
让我们创建一个包装器类型来规避这个问题:
//! src/startup.rs
// [...]
#[derive(Clone)]
pub struct HmacSecret(pub Secret<String>);
pub fn run(
hmac_secret: SecretString,
) -> Result<Server, std::io::Error> {
// [...]
let server = HttpServer::new(move || {
App::new()
// [...]
.app_data(web::Data::new(HmacSecret(hmac_secret.clone())))
})
// [...]
}
//! src/routes/login/post.rs
use crate::startup::HmacSecret;
// [...]
#[tracing::instrument(
skip(/* */, secret),
fields(/* */)
)]
pub async fn login(
// [...]
// Injec the wrapper type!
secret: web::Data<HmacSecret>,
) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(credentials, &pool).await {
Ok(/* */) => { /* */ }
Err(e) => {
// [...]
let hmac_tag = {
let mut mac =
Hmac::<sha2::Sha256>::new_from_slice(secret.0.expose_secret().as_bytes())
.unwrap();
// [...]
};
// [...]
}
}
}
验证 HMAC 标签
是时候在 GET /login 中验证该标签了!
让我们从提取标签查询参数开始。
我们目前正在使用查询提取器将传入的查询参数解析为 QueryParams 结构体,该结构体包含一个可选的错误字段。
展望未来,我们预计会出现两种情况:
- 没有错误(例如,您刚刚进入登录页面),因此我们不需要任何查询参数;
- 需要报告错误,因此我们预计会同时看到错误和标签查询 参数。
将 QueryParams 从
#[derive(serde::Deserialize)]
pub struct QueryParams {
error: Option<String>,
}
修改为
#[derive(serde::Deserialize)]
pub struct QueryParams {
error: Option<String>,
tag: Option<String>,
}
无法准确捕捉新的需求——它会允许调用者传递标签参数,而忽略错误参数,反之亦然。我们需要在请求处理程序中进行额外的验证,以确保不会出现这种情况。
我们可以完全避免这个问题,方法是将 QueryParams 中的所有字段设为必填字段,而 QueryParams 本身则变为可选字段:
//! src/routes/login/get.rs
// [...]
#[derive(serde::Deserialize)]
pub struct QueryParams {
error: String,
tag: String,
}
pub async fn login_form(query: Option<web::Query<QueryParams>>) -> HttpResponse {
let error_html = match query {
None => "".into(),
Some(query) => format!("<p><i>{}</i><p>", htmlescape::encode_minimal(&query.error)),
};
// [...]
}
温馨提示: 非法状态无法用类型表示!
为了验证标签,我们需要访问 HMAC 共享密钥——让我们注入它:
//! src/routes/login/get.rs
use crate::startup::HmacSecret;
// [...]
pub async fn login_form(
query: Option<web::Query<QueryParams>>,
secret: web::Data<HmacSecret>,
) -> HttpResponse {
// [...]
}
tag 是一个编码为十六进制字符串的字节切片。我们需要十六进制 crate 在 GET /login 中将其解码回字节。
让我们将其添加为依赖项:
#! Cargo.toml
[dependencies]
hex = "0.4"
现在我们可以在 QueryParams 本身上定义一个 verify 方法: 如果消息验证码符合我们的预期,它将返回错误字符串,否则返回错误。
//! src/routes/login/get.rs
use hmac::{Hmac, Mac};
use secrecy::ExposeSecret;
// [...]
impl QueryParams {
fn verify(self, secret: &HmacSecret) -> Result<String, anyhow::Error> {
let tag = hex::decode(self.tag)?;
let query_string = format!("error={}", urlencoding::Encoded::new(&self.error));
let mut mac =
Hmac::<sha2::Sha256>::new_from_slice(secret.0.expose_secret().as_bytes()).unwrap();
mac.update(query_string.as_bytes());
mac.verify_slice(&tag)?;
Ok(self.error)
}
}
现在我们需要修改请求处理程序来调用它,这就引出了一个问题:如果验证失败,我们该怎么做?
一种方法是返回 400 错误码,使整个请求失败。或者,我们可以将验证失败记录为警告,并在渲染 HTML 时跳过错误消息。
我们选择后者——用户被一些不可靠的查询参数重定向后,会看到我们的登录页面,这是一个可以接受的场景。
//! src/routes/login/get.rs
// [...]
pub async fn login_form(
query: Option<web::Query<QueryParams>>,
secret: web::Data<HmacSecret>,
) -> HttpResponse {
let error_html = match query {
None => "".into(),
Some(query) => match query.0.verify(&secret) {
Ok(error) => format!("<p><i>{}</i><p>", htmlescape::encode_minimal(&error)),
Err(e) => {
tracing::warn!(
error.message = %e,
error.cause_chain = ?e,
"Failed to verify query parameters using the HMAC tag"
);
"".into()
}
},
};
// [...]
}
您可以再次尝试加载我们的诈骗网址:
http://localhost:8000/login?error=Your%20account%20has%20been%20locked%2C%20please%20submit%20your%20details%20%3Ca%20href%3D%22https%3A%2F%2Fzero2prod.com%22%3Ehere%3C%2Fa%3E%20to%20resolve%20the%20issue.
浏览器不应该呈现任何错误消息!
错误消息必须是短暂的
从实现角度来看,我们很满意:错误信息按预期呈现,而且由于 HMAC 标签的存在,没有人能够篡改我们的消息。我们应该部署它吗?
我们选择使用查询参数来传递错误消息,因为查询参数是 URL 的一部分——在失败时重定向回登录表单时,很容易将它们传递到 Location 标头的值中。这既是它们的优点,也是它们的缺点: URL 存储在浏览器历史记录中,而浏览器历史记录会在您在地址栏中输入 URL 时提供自动完成建议。
您可以自己尝试一下:尝试在地址栏中输入 localhost:8000, 会得到什么建议?
由于我们目前为止进行的所有实验,大多数 URL 都会包含错误查询参数。
如果您选择一个带有有效标签的 URL,登录表单就会显示身份验证失败的错误消息……
即使距离您上次登录尝试已经过去了一段时间。这是
我们不希望看到的。
我们希望错误消息是短暂的。
它会在登录尝试失败后立即显示,但不会存储在您的浏览器历史记录中。唯一 再次触发错误消息的方法应该是……再次登录失败。
我们确定查询参数不符合我们的要求。我们还有其他选择吗?
是的,Cookie!
这是一个很棒的休息时刻,这是一个漫长的篇章!
如果您想检查您的实现,请查看 GitHub 上的项目快照
什么是 Cookie
MDN Web Docs 将 HTTP cookie 定义为
[...] 服务器向用户网络浏览器发送的一小段数据。浏览器可能会存储 Cookie, 并在后续请求中将其发送回同一服务器。
我们可以使用 Cookie 来实现之前尝试过的查询参数策略:
- 用户输入无效凭证并提交表单
POST /login设置包含错误消息的 Cookie,并将用户重定向回GET /login- 浏览器调用
GET /login,并传入当前为用户设置的 Cookie 值 GET /login的请求处理程序检查 Cookie,以确定是否有需要渲染的错误消息GET /login将 HTML 表单返回给调用者,并从 Cookie 中删除错误消息。
URL 不会被触及——所有与错误相关的信息都通过侧信道(Cookie)进行交换, 而这些侧信道对浏览器历史记录不可见。算法的最后一步确保了错误消息确实是短暂的——在渲染错误消息时,Cookie 会被“消耗”。如果页面重新加载,错误消息将不会再次显示。
我们刚才描述的一次性通知技术被称为闪现消息。
登录失败的集成测试
到目前为止,我们已经进行了相当自由的实验——我们编写了一些代码,启动了应用程序,并对其进行了各种尝试。
我们现在正接近设计的最终迭代,如果能使用一些黑盒测试来捕捉所需的行为,那就太好了。
就像我们迄今为止对项目支持的所有用户流程所做的那样。
编写测试也有助于我们熟悉 Cookie 及其行为。
我们想验证登录失败时会发生什么,这是我们之前几个章节一直在讨论的主题。
现在,让我们先在测试套件中添加一个新的登录模块:
//! tests/main.rs
// [...]
mod login;
//! tests/api/login.rs
// Empty for now
我们需要发送一个 POST /login 请求——让我们为 TestApp (用于在测试中与我们的应用程序交互的 HTTP 客户端) 添加一个帮助方法:
//! tests/api/helpers.rs
// [...]
impl TestApp {
pub async fn post_login<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
reqwest::Client::new()
.post(format!("{}/login", &self.address))
// This `reqwest` method makes sure that the body is URL-encoded
// and the `Content-Type` header is set accordingly
.form(body)
.send()
.await
.expect("Failed to execute request.")
}
// [...]
}
现在我们可以开始勾勒测试用例了。
在处理 Cookie 之前,我们先来一个简单的断言: 它返回一个重定向, 状态码为 303。
//! tests/api/login.rs
use crate::helpers::spawn_app;
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// Arrange
let app = spawn_app().await;
// Act
let login_body = serde_json::json!({
"username": "random-username",
"password": "random-password"
});
let response = app.post_login(&login_body).await;
// Assert
assert_eq!(response.status().as_u16(), 303);
}
测试失败了!
---- login::an_error_flash_message_is_set_on_failure stdout ----
thread 'login::an_error_flash_message_is_set_on_failure' panicked at tests/api/login.rs:16:5:
assertion `left == right` failed
left: 200
right: 303
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
无论成功还是失败,我们的端点都返回了 303 错误! 这是怎么回事?
答案可以在 reqwest 的文档中找到:
默认情况下,客户端会自动处理 HTTP 重定向,重定向链的最大跳数为 10。要自定义此行 为,可以将
redirect::Policy与ClientBuilder结合使用。
reqwest::Client 看到 303 状态码后,会自动继续调用 GET /login, 即 Location 标头中指定的路径,并返回 200——也就是我们在断言恐慌消息中看到的状态码。
为了测试目的,我们不希望 reqwest::Client 遵循重定向——让我们按照其文档中提供的指导来自定义HTTP 客户端的行为:
//! tests/api/helpers.rs
// [...]
impl TestApp {
pub async fn post_login<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap()
// [...]
}
// [...]
}
测试现在应该可以通过了。
我们可以更进一步——检查 Location 标头的值。
//! tests/api/helpers.rs
// [...]
// Little helper function - we will be doing this check serveral times throughout
// this chapter and the next one.
pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {
assert_eq!(response.status().as_u16(), 303);
assert_eq!(response.headers().get("Location").unwrap(), location);
}
//! tests/api/login.rs
use crate::helpers::assert_is_redirect_to;
// [...]
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// [...]
// Assert
assert_is_redirect_to(&response, "/login");
}
您应该会看到另一个失败:
assertion `left == right` failed
left: "/login?error=Authentication%20failed&tag=bebe215d4cc4e2617d18153cc1dd6215e94cd6e616b972
6dd6c4cf9b3284ff72"
right: "/login"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
端点仍在使用查询参数传递错误消息。让我们从请求处理程序中移除该功能:
//! src/routes/login/post.rs
// A few imports are now unused and can be removed.
// [...]
pub async fn login(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
// We no longer need `HmacSecret`!
) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(credentials, &pool).await {
Ok(/* */) => { /* */ }
Err(e) => {
let e = match e {
AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()),
};
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, "/login"))
.finish();
Err(InternalError::from_response(e, response))
}
}
}
我知道,感觉像是在倒退——你需要一点耐心!
测试应该会通过。现在我们可以开始查看 Cookie 了,这就引出了一个问题——“设置 Cookie”到底是什么意思?
Cookie 是通过在响应中附加一个特殊的 HTTP 标头来设置的——Set-Cookie。
其最简单的形式如下:
Set-Cookie: <cookie-name>=<cookie-value>
Set-Cookie 可以多次指定——每个要设置的 Cookie 都需要指定一次。
reqwest 提供了 get_all 方法来处理多值标头:
//! tests/api/login.rs
// [...]
use reqwest::header::HeaderValue;
use std::collections::HashSet;
async fn an_error_flash_message_is_set_on_failure() {
// [...]
let response = app.post_login(&login_body).await;
let cookies: HashSet<_> = response
.headers()
.get_all("Set-Cookie")
.into_iter()
.collect();
// Assert
// [...]
assert!(cookies.contains(&HeaderValue::from_str("_flash=Authentication failed").unwrap()));
}
说实话, Cookie 如此普遍,值得专门创建一个 API, 这样我们就可以省去处理原始标头的麻烦。
reqwest 将此功能锁定在 cookies 功能标志后面 - 让我们启用它:
#! Cargo.toml
# [...]
# Using multi-line format for brevity
[dependencies.reqwest]
version = "[...]"
default-features = false
features = ["json", "rustls-tls", "cookies"]
//! tests/api/login.rs
// [...]
use reqwest::header::HeaderValue;
use std::collections::HashSet;
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// Arrange
let app = spawn_app().await;
// Act
let login_body = serde_json::json!({
"username": "random-username",
"password": "random-password"
});
let response = app.post_login(&login_body).await;
// Assert
let flash_cookie = response.cookies().find(|c| c.name() == "_flash").unwrap();
assert_eq!(flash_cookie.value(), "Authentication failed");
assert_is_redirect_to(&response, "/login");
}
如您所见,cookie API 明显更加符合人体工程学。尽管如此,至少一次直接接触它抽象出来的东西也是有价值的。
测试应该会像预期的那样失败。
如何在 actix-web 中设置 Cookie
如何在 actix-web 中为传出的响应设置 Cookie?
我们可以直接使用标头:
//! src/routes/login/post.rs
// [...]
pub async fn login(
// [...]
) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(credentials, &pool).await {
Ok(/* */) => {/* */}
Err(e) => {
// [...]
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, "/login"))
.insert_header(("Set-Cookie", format!("_flash={e}")))
.finish();
Err(InternalError::from_response(e, response))
}
}
}
这项更改应该足以让测试通过。
与 reqwest 一样, actix-web 也提供了专用的 Cookie API。 Cookie::new 接受两个参数:
Cookie 的名称和值。让我们使用它:
//! src/routes/login/post.rs
use actix_web::cookie::Cookie;
// [...]
pub async fn login(
// [...]
) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(credentials, &pool).await {
Ok(user_id) => {
// [...]
}
Err(e) => {
// [...]
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, "/login"))
.cookie(Cookie::new("_flash", e.to_string()))
.finish();
// [...]
}
}
}
测试应该保持通过。
登录失败的集成测试 - 第 2 部分
现在让我们关注故事的另一面—— GET /login 。我们想要验证 _flash cookie 中传递的错误消息是否在重定向后真正呈现在用户看到的登录表单上方。
首先, 让我们在 TestApp 上添加一个 get_login_html 辅助方法:
impl TestApp {
// Our tests will only look at the HTML page, therefore
// we do not expose the underlying reqwest::Response
pub async fn get_login_html(&self) -> String {
reqwest::Client::new()
.get(format!("{}/login", self.address))
.send()
.await
.expect("Failed to execute request.")
.text()
.await
.unwrap()
}
}
//! tests/api/login.rs
// [...]
async fn an_error_flash_message_is_set_on_failure() {
// [...]
// Act - Part 2
let html_page = app.get_login_html().await;
assert!(html_page.contains(r#"<p><i>Authentication failed</i></p>"#))
}
测试应该会失败。
目前,我们无法让它通过:我们在向 GET /login 发送请求时,并没有传播 POST /login 设置的 Cookie——浏览器在正常情况下应该会完成这项任务。
那么 reqwest 能解决这个问题吗?
默认情况下,它不支持 cookie 传播 - 但可以配置!
我们只需将 true 传递给 reqwest::ClientBuilder::cookie_store。
不过需要注意的是 - 如果我们希望 cookie 传播功能正常工作,则必须对所有发送到 API 的请求使用相同的 reqwest::Client 实例。这需要在 TestApp 中进行一些重构 - 我们目前正在每个辅助方法中创建一个新的 reqwest::Client 实例。让我们修改一下 TestApp::spawn_app, 使其创建并存储一个 reqwest::Client 实例, 并在其所有辅助方法中使用它。
//! tests/api/helpers.rs
// [...]
pub struct TestApp {
// [...]
// New field!
pub api_client: reqwest::Client,
}
pub async fn spawn_app() -> TestApp {
// [...]
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.cookie_store(true)
.build()
.unwrap();
let test_app = TestApp {
// [...]
api_client: client,
};
// [...]
}
impl TestApp {
pub async fn get_login_html(&self) -> String {
self.api_client
.get(/* */)
// [..]
}
pub async fn post_login<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(/* */)
// [...]
}
pub async fn post_subscriptions(&self, body: String) -> reqwest::Response {
self.api_client
.post(/* */)
// [...]
}
// [...]
pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response {
self.api_client
.post(/* */)
// [...]
}
}
Cookie 传播现在应该可以按预期工作。
如何在 actix-web 中读取 Cookie
现在是时候再次查看我们的 GET /login 请求处理程序了
//! src/routes/login/get.rs
use actix_web::{HttpResponse, http::header::ContentType, web};
use hmac::{Hmac, Mac};
use secrecy::ExposeSecret;
use crate::startup::HmacSecret;
#[derive(serde::Deserialize)]
pub struct QueryParams {
error: String,
tag: String,
}
impl QueryParams {
fn verify(self, secret: &HmacSecret) -> Result<String, anyhow::Error> {
// [...]
}
}
pub async fn login_form(
query: Option<web::Query<QueryParams>>,
secret: web::Data<HmacSecret>,
) -> HttpResponse {
let error_html = match query {
None => "".into(),
Some(query) => match query.0.verify(&secret) {
Ok(error) => format!("<p><i>{}</i><p>", htmlescape::encode_minimal(&error)),
Err(e) => {
tracing::warn!(
error.message = %e,
error.cause_chain = ?e,
"Failed to verify query parameters using the HMAC tag"
);
"".into()
}
},
};
HttpResponse::Ok()
.content_type(ContentType::html())
.body(format!(/* HTML */))
}
让我们首先删除与查询参数及其(加密)验证相关的所有代码:
//! src/routes/login/get.rs
use actix_web::http::header::ContentType;
use actix_web::HttpResponse;
pub async fn login_form() -> HttpResponse {
let error_html: String = todo!();
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>Login</title>
</head>
<body>
{error_html}
<form action="/login" method="post">
<label>Username
<input
type="text"
placeholder="Enter Username"
name="username"
>
</label>
<label>Password
<input
type="password"
placeholder="Enter Password"
name="password"
>
</label>
<button type="submit">Login</button>
</form>
</body>
</html>
"#
))
}
回到基础。让我们抓住这个机会,移除我们在 HMAC 探索过程中添加的依赖项——sha2、hmac 和 hex。
为了访问传入请求的 cookie, 我们需要获取 HttpRequest 本身。让我们
将它添加为 login_form 的参数:
//! src/routes/login/get.rs
// [...]
pub async fn login_form(request: HttpRequest) -> HttpResponse {
// [...]
}
然后我们可以使用 HttpRequest::cookie 来检索给定名称的 cookie:
//! src/routes/login/get.rs
// [...]
pub async fn login_form(request: HttpRequest) -> HttpResponse {
let error_html: String = match request.cookie("_flash") {
None => "".into(),
Some(cookie) => {
format!("<p><i>{}</i></p>", cookie.value())
}
};
// [...]
}
现在我们的集成测试应该可以通过了!
怎么在 acitx-web 中删除 Cookie
如果在登录失败后刷新页面会发生什么?
错误信息仍然存在!
如果您打开新标签页并直接导航到 localhost:8000/login,也会发生同样的事情——登录表单顶部会显示“身份验证失败”信息。
这与我们之前所说的错误信息应该是短暂的截然不同。
该如何解决这个问题? 没有 Unset-cookie 标头——如何从用户的浏览器中删除 _flash cookie?
让我们深入探讨一下 Cookie 的生命周期。
说到持久性,Cookie 分为两种类型:会话 Cookie 和持久 Cookie。
会话 Cookie 存储在内存中——会话结束时(即浏览器关闭时)会被删除。而持久 Cookie 则保存在磁盘上,即使您重新打开浏览器,它们仍然会存在。
一个普通的 Set-Cookie 标头会创建一个会话 Cookie。要设置持久性 Cookie,您必须使用 Cookie 属性指定过期策略 - Max-Age 或 Expires。
Max-Age 表示 Cookie 过期前的剩余秒数 - 例如: Set-Cookie: _flash=omg; Max-Age=5 会创建一个持久性的 _flash Cookie,其有效期为接下来的 5 秒。
Expires 则需要一个日期 - 例如: Set-Cookie: _flash=omg; Expires=Thu, 31 Dec 2022 23:59:59 GMT; 会创建一个持久性的 Cookie,其有效期至 2022 年底。
将 Max-Age 设置为 0 会指示浏览器立即使 Cookie 过期 - 即取消设置, 这正是我们想要的!有点 hack 吗? 是的, 但这就是它。
让我们开始实现吧。我们可以先修改集成测试来应对这种情况——如果我们在第一次重定向后重新加载登录页面,则不应该显示错误消息:
//! tests/api/login.rs
// [...]
async fn an_error_flash_message_is_set_on_failure() {
// Arrange
// [...]
// Act - Part 1 - Try to login
// [...]
// Act - Part 2 - Follow the redirect
// [...]
// Act - Part 3 - Reload the login page
let html_page = app.get_login_html().await;
assert!(!html_page.contains(r"<p><i>Authentication failed</i></p>"))
}
cargo test 应该会报告失败。现在我们需要修改请求处理程序——我们必须在响应中设置
_flash cookie, 并将 Max-Age 设置为 0, 以删除存储在用户浏览器中的 Flash 消息。
//! src/routes/login/get.rs
use actix_web::{
HttpRequest, HttpResponse,
cookie::{Cookie, time::Duration},
http::header::ContentType,
};
pub async fn login_form(request: HttpRequest) -> HttpResponse {
// [...]
HttpResponse::Ok()
.content_type(ContentType::html())
.cookie(Cookie::build("_flash", "").max_age(Duration::ZERO).finish())
.body(/* [...] */)
}
测试现在应该可以通过了!
我们可以重构处理程序,使用 add_removal_cookie 方法,让我们的意图更清晰:
//! src/routes/login/get.rs
use actix_web::cookie::{Cookie, time::Duration};
// [...]
pub async fn login_form(request: HttpRequest) -> HttpResponse {
// [...]
let mut response = HttpResponse::Ok()
.content_type(ContentType::html())
.body(/* [...] */);
response
.add_removal_cookie(&Cookie::new("_flash", ""))
.unwrap();
response
}
在底层,它执行完全相同的操作,但不需要读者拼凑将 Max-Age 设置为零的含义。
Cookie 安全
我们在使用 Cookie 时面临哪些安全挑战?
使用 Cookie 仍然可能发起 XSS 攻击,但与查询参数相比,它需要更多努力——您无法创建指向我们网站的链接来设置或操纵 Cookie。然而,粗暴地使用 Cookie 可能会让我们暴露给不法分子。
哪些类型的攻击可以针对 Cookie 发起?
广义上讲,我们希望防止攻击者篡改我们的 Cookie(即完整性)或嗅探其内容(即机密性)。
首先,通过不安全的连接(即 HTTP 而不是 HTTPS)传输 Cookie 会让我们面临中间人攻击——浏览器发送到服务器的请求可能会被拦截、读取,其内容也可能被任意修改。
第一道防线是我们的 API——它应该拒绝通过未加密通道发送的请求。我们可以通过将新创建的 Cookie 标记为“安全”来获得额外的防御层: 这会指示浏览器仅将 Cookie 附加到通过安全连接传输的请求中。
对我们 Cookie 的机密性和完整性的第二大威胁是 JavaScript:运行在客户端的脚本可以与 Cookie 存储交互,读取/修改现有 Cookie 或设置新的 Cookie。
根据经验,最低权限策略是一个不错的默认策略:除非有令人信服的理由,否则 Cookie 不应该对脚本可见。
我们可以将新创建的 Cookie 标记为“仅 HTTP”策略,以便将它们隐藏在客户端代码中——浏览器会像往常一样存储它们并将其附加到发出的请求中,但脚本将无法看到它们。
“仅 HTTP”策略是一个不错的默认策略,但它并非万能的——JavaScript 代码可能无法访问我们的“仅 HTTP”策略 Cookie,但有一些方法可以覆盖它们, 并诱使后端执行一些意外或不必要的操作。
最后但同样重要的是,用户也可能构成威胁!他们可以使用浏览器提供的开发者工具随意修改其 Cookie 存储的内容。虽然这在查看 Flash 消息时可能不成问题,但处理其他类型的 Cookie(例如,我们稍后会讨论的身份验证会话)时,这绝对是一个问题。
我们应该建立多层防御。
我们已经知道一种无论前端通道发生什么都能确保完整性的方法,不是吗?
消息认证码 (MAC),我们用来保护查询参数的那些!
带有 HMAC 标签的 Cookie 值通常被称为签名 Cookie。通过在后端验证标签, 我们可以确信签名 Cookie 的值没有被篡改,就像我们对查询参数所做的那样。
actix-web-flash-messages
我们可以使用 actix-web 提供的 cookie API 来强化基于 cookie 的 Flash 消息实现——有些功能很简单(例如 Secure、Http-Only),有些则需要一些额外的工作(例如 HMAC),但只要我们付出一些努力,这些功能都是可以实现的。
在讨论查询参数时,我们已经深入探讨了 HMAC 标签,因此, 从头开始实现签名 cookie 并没有什么教育意义。我们将使用 actix-web 社区生态系统中的一个 crate: actix-web-flash-messages。
actix-web-flash-messages 提供了一个用于在 actix-web 中使用 flash 消息的框架,该框架与 Django 的消息框架紧密相关。
让我们将其添加为依赖项:
#! Cargo.toml
# [...]
[dependencies]
actix-web-flash-messages = { version = "0.5.0", features = ["cookies"] }
# [...]
要开始使用 flash 消息,我们需要在 actix_web 的 App 中注册 FlashMessagesFramework 作为中间件:
//! src/startup.rs
// [...]
pub fn run(/* */) -> Result<Server, std::io::Error> {
// [..]
let message_framework = FlashMessagesFramework::builder(todo!()).build();
let server = HttpServer::new(move || {
App::new()
.wrap(message_framework.clone())
.wrap(TracingLogger::default())
// [...]
})
.// [...]
}
FlashMessagesFramework::builder 需要一个存储后端作为参数 - flash 消息应该存储在哪里?
并从哪里检索?
actix-web-flash-messages 提供了一个基于 Cookie 的实现, CookieMessageStore。
//! src/startup.rs
// [...]
use actix_web_flash_messages::storage::CookieMessageStore;
pub fn run(/* */) -> Result<Server, std::io::Error> {
// [...]
let message_store = CookieMessageStore::builder(todo!()).build();
let message_framework = FlashMessagesFramework::builder(message_store).build();
// [...]
}
CookieMessageStore 强制要求用于存储的 Cookie 必须经过签名,因此我们必须向其构建器提供
密钥。我们可以复用在处理 HMAC 标签时引入的 hmac_secret 来作为查询参数:
//! src/startup.rs
// [...]
use secrecy::ExposeSecret;
use actix_web::cookie::Key;
// [...]
pub fn run(/* */) -> Result<Server, std::io::Error> {
// [...]
let message_store =
CookieMessageStore::builder(Key::from(hmac_secret.expose_secret().as_bytes())).build();
// [...]
}
现在我们可以开始发送 FlashMessage 了。
每个 FlashMessage 都有一个级别和一个内容字符串。消息级别可用于过滤
和渲染 - 例如:
- 在生产环境中仅显示信息级别或更高级别的 Flash 消息,同时保留 调试级别的消息以供本地开发使用;
- 在 UI 中使用不同的颜色显示消息(例如,红色表示错误,橙色表示警告, 等等)。
我们可以重新设计 POST /login 来发送 FlashMessage:
//! src/routes/login/post.rs
// [...]
pub async fn login(/* */) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(/* */).await {
Ok(/* */) => {/* */}
Err(e) => {
let e = /* */;
FlashMessage::error(e.to_string()).send();
let response = HttpResponse::SeeOther()
// No cookies here now!
.insert_header((LOCATION, "/login"))
.finish();
Err(InternalError::from_response(e, response))
}
}
}
FlashMessagesFramework 中间件负责处理所有繁重的后台工作——创建 Cookie、签名、设置正确的属性等等。
我们还可以将多条 Flash 消息附加到单个响应中——框架负责如何组合它们并在存储层中呈现。
接收端如何工作? 如何在 GET /login 中读取传入的 Flash 消息?
我们可以使用 IncomingFlashMessages 提取器:
//! src/routes/login/get.rs
// [...]
use actix_web_flash_messages::{IncomingFlashMessages, Level};
use std::fmt::Write;
// No need to access the raw request anymore!
pub async fn login_form(flash_messages: IncomingFlashMessages) -> HttpResponse {
let mut error_html = String::new();
for m in flash_messages.iter().filter(|m| m.level() == Level::Error) {
writeln!(error_html, "<p><i>{}</i></p>", m.content()).unwrap();
}
HttpResponse::Ok()
// No more removal cookie!
.content_type(ContentType::html())
.body(format!(/* */))
}
代码需要稍微修改一下,以适应收到多条 Flash 消息的情况,但总体来说效果差不多。特别是,我们不再需要处理 Cookie API,也不需要检索传入的 Flash 消息,也不需要确保读取后删除它们——
actix-web-flash-messages 会处理这些事情。Cookie 签名的有效性也会在后台验证,
在调用请求处理程序之前。
我们的测试怎么样?
它们失败了:
thread 'login::an_error_flash_message_is_set_on_failure' panicked at tests/api/login.rs:18:5:
assertion `left == right` failed
left: "Ik4JlkXTiTlc507ERzy2Ob4Xc4qXAPzJ7MiX6EB04c4%3D%5B%7B%22content%22%3A%22Authentication%20fa
iled%22,%22level%22%3A%22Error%22%7D%5D"
right: "Authentication failed"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
我们的断言与实现细节过于接近了——我们只需要验证渲染的 HTML 是否包含(或不包含)预期的错误消息。让我们修改一下测试代码:
//! tests/api/login.rs
// [...]
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// Arrange
let app = spawn_app().await;
// Act - Part 1 - Try to login
let login_body = serde_json::json!({
"username": "random-username",
"password": "random-password"
});
let response = app.post_login(&login_body).await;
// Assert
// No longer asserting facts related to cookies
assert_is_redirect_to(&response, "/login");
// Act - Part 2 - Follow the redirect
let html_page = app.get_login_html().await;
assert!(html_page.contains(r#"<p><i>Authentication failed</i></p>"#));
// Act - Part 3 - Reload the login page
let html_page = app.get_login_html().await;
assert!(!html_page.contains(r"<p><i>Authentication failed</i></p>"))
}
现在测试应该通过了