会话
我们花了一段时间思考登录失败后应该如何处理。现在该交换一下了: 登录成功后,我们期望看到什么?
身份验证旨在限制对需要更高权限的功能的访问——在我们的例子中, 就是向整个邮件列表发送新一期新闻通讯的功能。我们想要构建一个管理面板——我们将有一个 /admin/dashboard 页面,仅限登录用户访问,以便访问所有管理功能。
我们将分阶段实现。作为第一个里程碑,我们希望:
- 登录成功后重定向到
/admin/dashboard, 并显示欢迎 <username>!问候语 - 如果用户尝试直接导航到
/admin/dashboard并且他们尚未登录,他们将被重定向到登录表单。
此计划需要会话。
基于会话的鉴权
基于会话的身份验证是一种避免在每个页面上都要求用户输入密码的策略。
用户只需通过登录表单进行一次身份验证:如果成功,服务器将生成一个一次性密钥——经过身份验证的会话令牌。
后端 API 将接受会话令牌而不是用户名/密码组合,并 授予对受限功能的访问权限。每次请求都必须提供会话令牌—— 这就是会话令牌以 Cookie 形式存储的原因。浏览器将确保将 Cookie 附加到所有 API 的传出请求中。
从安全角度来看,有效的会话令牌与相应的身份验证密钥(例如用户名/密码组合、生物识别或物理第二因素)一样强大。
我们必须格外小心,避免将会话令牌暴露给攻击者。
OWASP 提供了有关如何保护会话安全的详尽指南——我们将在下一节中实施他们的大部分建议。
会话存储
让我们开始思考具体实现吧!
基于我们目前讨论的内容,我们需要 API 在登录成功后生成一个会话令牌。
该令牌值必须是不可预测的——我们不希望攻击者能够生成或猜测一个有效的会话令牌。OWASP 建议使用加密安全的伪随机数生成器 (CSPRNG)。
仅仅随机性是不够的——我们还需要唯一性。如果我们将两个用户关联到同一个会话令牌,就会遇到麻烦:
- 我们可能会授予其中一个用户高于其应得权限的权限
- 我们可能会泄露个人或机密信息,例如姓名、电子邮件、过往活动等。
我们需要一个会话存储——服务器必须记住它生成的令牌,以便授权已登录用户的未来请求。我们还希望将信息关联到每个活动会话——这被称为会话状态。
选择会话存储
在会话的生命周期中,我们需要执行以下操作:
- 创建会话,当用户登录时
- 检索会话,使用从传入请求中附加的 Cookie 中提取的会话令牌
- 更新会话,当登录用户执行某些操作导致其会话状态发生变化时
- 删除会话,当用户注销时
这些操作通常称为 CRUD(创建、删除、读取、更新)。
我们还需要某种形式的过期机制——会话应该是短暂的。如果没有清理机制, 我们最终会为过期/陈旧的会话占用比活动会话更多的空间。
Postgres
Postgres 是否是一个可行的会话存储方案?
我们可以创建一个新的会话表,以令牌作为主索引——这是一种确保令牌唯一性的简单方法。
对于会话状态,我们有几种选择:
- “经典”关系建模,使用规范化模式(即我们存储应用程序状态的方式)
- 单个状态列,使用 jsonb 数据类型,保存键值对集合。
遗憾的是,Postgres 没有内置的行过期机制。我们必须
添加一个 expires_at 列,并定期触发清理作业来清除过期会话——
这有点繁琐。
Redis
Redis 是另一种流行的会话存储方案。
Redis 是一个内存数据库——它使用内存而非磁盘进行存储,牺牲了持久性来换取速度。
它非常适合存储那些可以建模为键值对集合的数据。
它还原生支持过期时间——我们可以为所有值附加一个生存时间,Redis 会负责处理这些值。
它如何应用于会话?
我们的应用程序从不批量操作会话——我们每次只处理一个会话, 并使用其令牌进行标识。因此,我们可以使用会话令牌作为键,而值则是会话状态的 JSON 表示形式——应用程序负责序列化/反序列化。
会话的生命周期很短——无需担心使用内存而不是磁盘进行持久化, 速度提升是一个不错的附加效果!
正如您可能已经猜到的那样,我们将使用 Redis 作为会话存储后端!
actix-session
actix-session 为 actix-web 应用程序提供会话管理。让我们将它添加到我们的依赖项中:
#! Cargo.toml
# [...]
[dependencies]
actix-session = "0.11.0"
# [...]
actix-session 中的键类型是 SessionMiddleware - 它负责加载会话数据、跟踪状态变化并在请求/响应生命周期结束时将其持久化。
要构建 SessionMiddleware 实例,我们需要提供一个存储后端和一个密钥来对会话 cookie 进行签名(或加密)。该方法与 actix-web-flash-messages 中的 FlashMessagesFramework 所使用的方法非常相似。
//! src/startup.rs
// [...]
use actix_session::SessionMiddleware;
pub fn run(
// [...]
hmac_secret: SecretString,
) -> Result<Server, std::io::Error> {
// [...]
let secret_key = Key::from(hmac_secret.expose_secret().as_bytes());
let message_store =
CookieMessageStore::builder(Key::from(hmac_secret.expose_secret().as_bytes())).build();
// [...]
let server = HttpServer::new(move || {
App::new()
.wrap(message_framework.clone())
.wrap(SessionMiddleware::new(todo!(), secret_key.clone()))
.wrap(TracingLogger::default())
// [...]
})
.listen(listener)?
.run();
Ok(server)
}
actix-session 在存储方面非常灵活——您可以通过实现 SessionStore trait 来提供自己的存储。它还提供了一些开箱即用的实现,隐藏在一系列功能标志后面——其中包括一个 Redis 后端。让我们启用它:
#! Cargo.toml
# [...]
[dependencies]
actix-session = { version = "0.11.0", features = ["redis-session-rustls"] }
现在我们可以访问 RedisSessionStore 了。要构建一个 RedisSessionStore, 我们需要传入一个 Redis 连接字符串作为输入——让我们将 redis_uri 添加到我们的配置结构体中:
//! src/configuration.rs
// [...]
pub struct Settings {
// [...]
pub redis_uri: SecretString,
}
// [...]
# configuration/base.yaml
# 6379 is Redis' default port
redis_uri: "redis://127.0.0.1:6379"
# [...]
让我们使用它来构建一个 RedisSessionStore 实例:
//! src/startup.rs
// [...]
impl Application {
// Async now! We also return anyhow::Error instead of std::io::Error
pub async fn build(configuration: Settings) -> Result<Self, anyhow::Error> {
// [...]
let server = run(
// [...]
configuration.redis_uri,
).await?;
Ok(Self { port, server })
}
// [...]
}
// Now it's asynchronous!
async fn run(
listener: TcpListener,
db_pool: PgPool,
email_client: EmailClient,
base_url: String,
hmac_secret: SecretString,
redis_uri: SecretString,
// Returning anyhow::Error instead of std::io::Error
) -> Result<Server, anyhow::Error> {
// [...]
let redis_store = RedisSessionStore::new(redis_uri.expose_secret()).await?;
let server = HttpServer::new(move || {
App::new()
.wrap(message_framework.clone())
.wrap(SessionMiddleware::new(
redis_store.clone(),
secret_key.clone(),
))
.wrap(TracingLogger::default())
// [...]
})
// [...]
}
//! src/main.rs
// [...]
#[tokio::main]
// anyhow::Result now instead of std::io::Error
async fn main() -> anyhow::Result<()> {
// [...]
}
是时候将正在运行的 Redis 实例添加到我们的设置中了。
我们开发设置中的 Redis
我们需要在 CI 管道中运行一个 Redis 容器,与 Postgres 容器一起运行——请查看书籍存储库中更新的 YAML。
我们还需要在开发机器上运行一个 Redis 容器来执行测试套件并启动应用程序。让我们添加一个脚本来启动它:
# scripts/init_redis.sh
#!/usr/bin/env bash
set -x
set -eo pipefail
# if a redis container is running, print instructions to kill it and exit
RUNNING_CONTAINER=$(docker ps --filter 'name=redis' --format '{{.ID}}')
if [[ -n $RUNNING_CONTAINER ]]; then
echo >&2 "there is a redis container already running, kill it with"
echo >&2 "
docker kill ${RUNNING_CONTAINER}"
exit 1
fi
# Launch Redis using Docker
docker run \
-p "6379:6379" \
-d \
--name "redis_$(date '+%s')" \
redis:8
>&2 echo "Redis is ready to go!"
该脚本需要标记为可执行,然后启动:
chmod +x ./scripts/init_redis.sh
./script/init_redis.sh
Digital Ocean 上的 Redis
Digital Ocean 不支持通过 spec.yaml 文件创建开发版 Redis 集群。您需要访问他们的仪表盘 - 在此处创建一个新的 Redis 集群。请务必选择您部署应用程序的数据中心。集群创建完成后, 您需要完成一个快速的“入门”流程来配置一些参数(可信源、驱逐策略等)。
在“入门”流程的最后,您将能够将连接字符串复制到新配置的 Redis 实例。连接字符串包含用户名和密码,因此我们必须将其视为机密信息。我们将使用环境变量值(在应用程序控制台的“设置”面板中设置 APP_REDIS_URI)将其值注入应用程序。
管理员仪表盘
我们的会话存储现已在所有我们关注的环境中启动并运行。现在是时候实际地 用它做点什么了!
让我们创建一个新页面(管理仪表板)的框架。
//! src/routes/admin.rs
mod dashboard;
pub use dashboard::admin_dashboard;
//! src/routes/admin.dashboard.rs
use actix_web::HttpResponse;
pub async fn admin_dashboard() -> HttpResponse {
HttpResponse::Ok().finish()
}
//! src/routes.rs
// [...]
mod admin;
pub use admin::*;
//! src/startup.rs
use crate::routes::admin_dashboard;
// [...]
async fn run(
// [...]
) -> Result<Server, anyhow::Error> {
// [...]
let server = HttpServer::new(move || {
App::new()
// [...]
.route("/admin/dashboard", web::get().to(admin_dashboard))
// [...]
})
// [...]
}
登录成功后跳转
让我们开始着手实现第一个里程碑:
登录成功后重定向到
/admin/dashboard并显示欢迎信息Welcome <username>!;
我们可以在集成测试中对需求进行编码:
//! tests/api/login.rs
// [...]
#[tokio::test]
async fn redirect_to_admin_dashboard_after_login_success() {
// Arrange
let app = spawn_app().await;
// Act - Part 1 - Login
let login_body = serde_json::json!({
"username": &app.test_user.username,
"password": &app.test_user.password,
});
let response = app.post_login(&login_body).await;
assert_is_redirect_to(&response, "/admin/dashboard");
// Act - Part 2 - Follow the redirect
let html_page = app.get_admin_dashboard().await;
assert!(html_page.contains(&format!("Welcome {}", app.test_user.username)));
}
//! tests/api/helpers.rs
// [...]
impl TestApp {
pub async fn get_admin_dashboard(&self) -> String {
self.api_client
.get(format!("{}/admin/dashboard", &self.address))
.send()
.await
.expect("Failed to execute requet.")
.text()
.await
.unwrap()
}
// [...]
}
这个测试将会失败:
thread 'login::redirect_to_admin_dashboard_after_login_success' panicked at tests/api/helpers.rs:24
2:5:
assertion `left == right` failed
left: "/"
right: "/admin/dashboard"
通过第一个断言很容易——我们只需要更改 POST /login 返回的响应中的 Location 标头:
//! src/routes/login/post.rs
// [...]
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> Result</* */> {
// [...]
match validate_credentials(/* */).await {
Ok(/* */) => {
// [...]
Ok(HttpResponse::SeeOther()
.insert_header((LOCATION, "/admin/dashboard"))
.finish())
}
// [...]
}
}
现在测试会在第二个断言失败
thread 'login::redirect_to_admin_dashboard_after_login_success' panicked at tests/api/login.rs:43:5
:
assertion failed: html_page.contains(&format!("Welcome {}", app.test_user.username))
是时候让这些会话发挥作用了。
会话实现
我们需要在用户执行 POST /login 返回的重定向后,访问 GET /admin/dashboard 时识别用户身份——
这是会话的完美用例。
我们将用户标识符存储到 login 中的会话状态中,然后从 admin_dashboard 中的会话状态中检索它。
我们需要熟悉 Session, 它是 actix_session 中的第二个键类型。
SessionMiddleware 负责在传入请求中检查会话 cookie 的所有繁重工作——
如果找到,它会从所选的存储后端加载相应的会话状态。否则,
它会创建一个新的空会话状态。
然后,我们可以使用 Session 作为提取器,在请求处理程序中与该状态进行交互。
让我们在 POST /login 中看看它的实际作用:
//! src/routes/login/post.rs
use actix_session::Session;
// [...]
#[tracing::instrument(
skip(/* */, session),
// [...]
)]
pub async fn login(
// [...]
session: Session,
) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(credentials, &pool).await {
Ok(user_id) => {
tracing::Span::current().record("user_id", tracing::field::display(&user_id));
session.insert("user_id", user_id);
Ok(HttpResponse::SeeOther()
.insert_header((LOCATION, "/admin/dashboard"))
.finish())
}
Err(e) => {
// [...]
}
}
}
#! Cargo.toml
# [...]
[dependencies]
# We need to add the `serde` feature
uuid = { version = "...", features = ["v4", "serde"] }
您可以将 Session 视为 HashMap 上的句柄 - 您可以根据 String 键插入和检索值。
您传入的值必须是可序列化的 - actix-session 会在后台将它们转换为 JSON。
这就是为什么我们必须在 uuid 依赖项中添加 serde 功能。
序列化意味着失败的可能性 - 如果您运行 cargo check, 您会看到编译器
警告我们没有处理 session.insert 返回的结果。让我们来解决这个问题:
//! src/routes/login/post.rs
// [...]
#[tracing::instrument(/* */)]
pub async fn login(
// [...]
session: Session,
) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(credentials, &pool).await {
Ok(user_id) => {
tracing::Span::current().record("user_id", tracing::field::display(&user_id));
session
.insert("user_id", user_id)
.map_err(|e| login_redirect(LoginError::UnexpectedError(e.into())))?;
// [...]
}
Err(e) => {
let e = match e {
AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()),
};
Err(login_redirect(e))
}
}
}
// Redirect to the login page with an error message.
fn login_redirect(e: LoginError) -> InternalError<LoginError> {
FlashMessage::error(e.to_string()).send();
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, "/login"))
.finish();
InternalError::from_response(e, response)
}
如果出现问题,用户将被重定向回 /login 页面,并显示相应的 错误消息。
那么 Session::insert 究竟做了什么呢?
所有针对 Session 的操作都在内存中执行——它们不会影响存储后端看到的
会话状态。处理程序返回响应后, SessionMiddleware
将检查 Session 的内存状态——如果状态发生变化,它将调用 Redis 来更新(或创建)
状态。它还会负责在客户端设置会话 cookie(如果之前没有)。
它能正常工作吗? 让我们尝试在另一端获取 user_id!
//! src/routes/admin/dashboard.rs
use actix_session::Session;
use actix_web::HttpResponse;
use uuid::Uuid;
// Return an opaque 500 while preserving the error's root cause for logging.
fn e500<T>(e: T) -> actix_web::Error
where
T: std::fmt::Debug + std::fmt::Display + 'static,
{
actix_web::error::ErrorInternalServerError(e)
}
pub async fn admin_dashboard(session: Session) -> Result<HttpResponse, actix_web::Error> {
let _username = if let Some(user_id) = session.get::<Uuid>("user_id").map_err(e500)? {
todo!();
} else {
todo!();
};
Ok(HttpResponse::Ok().finish())
}
使用 Session::get 时,我们必须指定要将会话状态条目反序列化为哪种类型——
在本例中是 Uuid。反序列化可能会失败,因此我们必须处理错误情况。
现在我们有了 user_id, 我们可以使用它来获取用户名并返回我们之前讨论过的 "Welcome {username}!" 消息。
//! src/routes/admin/dashboard.rs
// [...]
use actix_session::Session;
use actix_web::{HttpResponse, http::header::ContentType, web};
use anyhow::Context;
use sqlx::PgPool;
use uuid::Uuid;
// Return an opaque 500 while preserving the error's root cause for logging.
fn e500<T>(e: T) -> actix_web::Error
where
T: std::fmt::Debug + std::fmt::Display + 'static,
{
actix_web::error::ErrorInternalServerError(e)
}
pub async fn admin_dashboard(
session: Session,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, actix_web::Error> {
let username = if let Some(user_id) = session.get::<Uuid>("user_id").map_err(e500)? {
get_username(user_id, &pool).await.map_err(e500)?
} else {
todo!()
};
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>
</body>
</html>"#
)))
}
#[tracing::instrument(name = "Get username", skip(pool))]
async fn get_username(user_id: Uuid, pool: &PgPool) -> Result<String, anyhow::Error> {
let row = sqlx::query!(
r#"
SELECT username
FROM users
WHERE user_id = $1
"#,
user_id
)
.fetch_one(pool)
.await
.context("Failed to perform a query to retrieve a username.")?;
Ok(row.username)
}
我们的集成测试现在应该可以通过了! 不过,我们还没完成——目前,我们的登录流程可能容易受到 会话固定攻击。
会话的用途远不止身份验证——例如,在“访客”模式下购物时,用于跟踪已添加到购物车的商品。
这意味着用户可能关联到一个匿名会话,并在身份验证后关联到一个特权会话。攻击者可以利用这一点。 网站竭尽全力阻止恶意行为者嗅探会话令牌,这导致了 另一种攻击策略——在用户登录前向其浏览器植入一个已知的会话令牌, 等待身份验证完成,然后,砰,你就成功了!
我们可以采取一个简单的对策来阻止这种攻击——在用户登录时轮换会话令牌。
这是一种非常常见的做法,你会发现所有主流 Web 框架 (包括 actix-session) 的会话管理 API 都支持它,
通过 Session::renew 来实现。让我们将其添加到:
//! src/routes/login/post.rs
// [...]
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(/* */).await {
Ok(user_id) => {
// [...]
session.renew();
session
.insert("user_id", user_id)
.map_err(|e| login_redirect(LoginError::UnexpectedError(e.into())))?;
// [...]
}
// [...]
}
}
现在我们可以安心了。
会话的类型接口
Session 功能强大,但就其本身而言,它作为构建应用程序状态处理的基础, 却不够稳定。我们使用基于字符串的 API 访问数据, 并注意在插入和检索两端使用相同的键和类型。
当状态非常简单时,它还能正常工作,但如果有多个路由访问相同的数据, 它很快就会变得一团糟——在需要改进架构时,如何确保更新了所有路由?
如何防止键值错误导致生产中断? 测试可以提供帮助,但我们可以使用类型系统彻底解决这个问题。我们将在 Session 之上构建一个强类型 API 来访问和修改状态, 从而在请求处理程序中不再使用字符串键和类型转换。
Session 是一个外部类型(在 actix-session 中定义),因此我们必须使用扩展特征
模式:
//! src/lib.rs
// [...]
pub mod session_state;
//! src/session_state.rs
use actix_session::Session;
use uuid::Uuid;
pub struct TypedSession(Session);
impl TypedSession {
const USER_ID_KEY: &'static str = "user_id";
pub fn renew(&self) {
self.0.renew();
}
pub fn insert_user_id(&self, user_id: Uuid) -> Result<(), actix_session::SessionInsertError> {
self.0.insert(Self::USER_ID_KEY, user_id)
}
pub fn get_user_id(&self) -> Result<Option<Uuid>, actix_session::SessionGetError> {
self.0.get(Self::USER_ID_KEY)
}
}
请求处理程序如何构建 TypedSession 实例?
我们可以提供一个以 Session 作为参数的构造函数。另一个选择是
将 TypedSession 本身变成一个 actix-web 提取器——让我们试试看!
//! src/session_state.rs
// [...]
use actix_session::SessionExt;
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpRequest};
use std::future::{Ready, ready};
impl FromRequest for TypedSession {
// This is a complicated way of saying
// "We return the same error returned by the
// implementation of `FromRequest` for `Session`"
type Error = <Session as FromRequest>::Error;
// Rust does not yet support the `async` syntax in traits.
//
// From request expects a `Future` as return type to allow for extractors
// that need to perform asynchronous operations (e.g. a HTTP call)
// We do not have a `Future`, because we don't perform any I/O,
// so we wrap `TypedSession` into `Ready` to convert it into a `Future` that
// resolves to the wrapped value the first time it's polled by the executor.
type Future = Ready<Result<TypedSession, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
ready(Ok(TypedSession(req.get_session())))
}
}
它只有三行代码,但很可能会让你接触到一些新的 Rust 概念/结构。
花点时间逐行阅读,正确理解正在发生的事情——或者,如果你愿意, 也可以先理解要点,稍后再深入研究!
现在我们可以在请求处理程序中将 Session 替换为 TypedSession 了:
//! src/routes/login/post.rs
// You can now remove the `Session` import
use crate::session_state::TypedSession;
// [...]
pub async fn login(
// [...]
session: TypedSession,
) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(credentials, &pool).await {
Ok(user_id) => {
// [...]
session.renew();
session
.insert_user_id(user_id)
.map_err(|e| login_redirect(LoginError::UnexpectedError(e.into())))?;
// [...]
}
// [...]
}
}
//! src/routes/admin/dashboard.rs
// You can now remove the `Session` import
pub async fn admin_dashboard(
// [...]
// Changed from `Session` to `TypedSession`!
session: TypedSession,
) -> Result<HttpResponse, actix_web::Error> {
let username = if let Some(user_id) = session.get_user_id().map_err(e500)? {
// [...]
} else {
todo!()
};
// [...]
}
测试应该会保持通过。
拒绝未验证的用户
现在我们可以处理第二个里程碑:
如果用户尝试直接导航到 /admin/dashboard 但尚未登录,他们将被重定向到登录表单。
让我们像往常一样在集成测试中对需求进行编码:
//! tests/api/admin_dashboard.rss
use crate::helpers::{spawn_app, assert_is_redirect_to};
use crate::helpers::{assert_is_redirect_to, spawn_app};
#[tokio::test]
async fn you_must_be_logged_in_to_access_the_admin_dashboard() {
// Arrange
let app = spawn_app().await;
// Act
let response = app.get_admin_dashboard().await;
// Assert
assert_is_redirect_to(&response, "/login");
}
//! tests/api/main.rs
mod admin_dashboard;
//! tests/api/helpers.rs
// [...]
impl TestApp {
pub async fn get_admin_dashboard(&self) -> reqwest::Response {
self.api_client
.get(format!("{}/admin/dashboard", &self.address))
.send()
.await
.expect("Failed to execute requet.")
}
pub async fn get_admin_dashboard_html(&self) -> String {
self.get_admin_dashboard().await.text().await.unwrap()
}
}
测试应该会失败——处理程序会 panic。
我们可以通过实现 todo!() 来解决这个问题:
//! src/routes/admin/dashboard.rs
use actix_web::http::header::LOCATION;
// [...]
pub async fn admin_dashboard(
session: TypedSession,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, actix_web::Error> {
let username = if let Some(user_id) = session.get_user_id().map_err(e500)? {
// [...]
} else {
return Ok(HttpResponse::SeeOther()
.insert_header((LOCATION, "/login"))
.finish());
};
// [...]
}
现在测试应该可以通过了。