An Introduction To Session-based Authentication In Rust
- 19234 words
- 97 min
This article is a sample from Zero To Production In Rust, a hands-on introduction to backend development in Rust.
You can get a copy of the book at zero2prod.com.
TL;DR
In the previous episode we established that 'Basic' Authentication is not a great fit when dealing with a browser: it requires the user to present their credentials on every single page.
In this episode we will explore a viable alternative, session-based authentication.
The user is asked to authenticate once, via a login form: if successful, the server generates a one-time secret, stored in the browser as a cookie.
We will build - from scratch - an admin dashboard. It will include a login form, a logout button and a form to change your password. It will give us an opportunity to discuss a few security challenges (i.e. XSS), introduce new concepts (e.g. cookies, HMAC tags) and try out new tooling (e.g. flash messages, actix-session
).
This chapter, like others in the book, chooses to "do it wrong" first for teaching purposes. Make sure to read until the end if you don't want to pick up bad security habits!
Chapter 10 - Part 1
- 1. Serving HTML Pages
- 2. Login
- 2.1. HTML Forms
- 2.2. Redirect On Success
- 2.3. Processing Form Data
- 2.4. Contextual Errors
- 2.4.1. Naive Approach
- 2.4.2. Query Parameters
- 2.4.3. Cross-Site Scripting (XSS)
- 2.4.4. Message Authentication Codes
- 2.4.5. Add An HMAC Tag To Protect Query Parameters
- 2.4.6. Verifying The HMAC Tag
- 2.4.7. Error Messages Must Be Ephemeral
- 2.4.8. What Is A Cookie?
- 2.4.9. An Integration Test For Login Failures
- 2.4.10. How To Set A Cookie In
actix-web
- 2.4.11. An Integration Test For Login Failures - Part 2
- 2.4.12. How To Read A Cookie In
actix-web
- 2.4.13. How To Delete A Cookie In
actix-web
- 2.4.14. Cookie Security
- 2.4.15.
actix-web-flash-messages
- 3. Sessions
- 4. Seed Users
- 5. Refactoring
- 6. Summary
1. Serving HTML Pages
So far we have steered away from the complexity of browsers and web pages - it helped us in limiting the number of new concepts we had to pick up early on in our learning journey.
We have now built enough expertise to make the jump - we will handle both the HTML page and the payload submission for our login form.
Let's start from the basics: how do we return an HTML page from our API?
We can begin by adding a dummy home page endpoint.
//! src/routes/mod.rs
// [...]
// New module!
mod home;
pub use home::*;
//! src/routes/home/mod.rs
use actix_web::HttpResponse;
pub async fn home() -> HttpResponse {
HttpResponse::Ok().finish()
}
//! src/startup.rs
use crate::routes::home;
// [...]
fn run(/* */) -> Result</* */> {
// [...]
let server = HttpServer::new(move || {
App::new()
// [...]
.route("/", web::get().to(home))
// [...]
})
// [...]
}
Not much to be seen here - we are just returning a 200 OK
without a body.
Let's add a very simple HTML landing page1 to the mix:
<!-- src/routes/home/home.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Home</title>
</head>
<body>
<p>Welcome to our newsletter!</p>
</body>
</html>
We want to read this file and return it as the body of our GET /
endpoint.
We can use include_str!
, a macro from Rust's standard library: it reads the file at the provided path and returns its content as a &'static str
.
This is possible because include_str!
operates at compile-time - the file content is stored as part of the application binary, therefore ensuring that a pointer to its content (&str
) remains valid indefinitely ('static
)2.
//! src/routes/home/mod.rs
// [...]
pub async fn home() -> HttpResponse {
HttpResponse::Ok().body(include_str!("home.html"))
}
If you launch your application with cargo run
and visit http://localhost:8000
in the browser you should see the Welcome to our newsletter!
message.
The browser is not entirely happy though - if you open the browser's console3, you should see a warning.
On Firefox 93.0:
The character encoding of the HTML document was not declared.
The document will render with garbled text in some browser configurations if the document contains characters from outside the US-ASCII range.
The character encoding of the page must be declared in the document or in the transfer protocol.
In other words - the browser has inferred that we are returning HTML content, but it would very much prefer to be told explicitly.
We have two options:
- Add a special HTML
meta
tag in the document; - Set the
Content-Type
HTTP header ("transfer protocol").
Better to use both.
Embedding the information inside the document works nicely for browsers and bot crawlers (e.g. Googlebot) while the Content-Type
HTTP header is understood by all HTTP clients, not just browsers.
When returning an HTML page, the content type should be set to text/html; charset=utf-8
.
Let's add it in:
<!-- src/routes/home/home.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<!-- This is equivalent to a HTTP header -->
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<title>Home</title>
</head>
<!-- [...] -->
</html>
//! src/routes/home/mod.rs
// [...]
use actix_web::http::header::ContentType;
pub async fn home() -> HttpResponse {
HttpResponse::Ok()
.content_type(ContentType::html())
.body(include_str!("home.html"))
}
The warning should have disappeared from your browsers' console.
Congrats, you have just served your first well-formed web page!
2. Login
Let's start working on our login form.
We need to wire up an endpoint placeholder, just like we did for GET /
. We will serve the login form at GET /login
.
//! src/routes/mod.rs
// [...]
// New module!
mod login;
pub use login::*;
//! src/routes/login/mod.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
use crate::routes::{/* */, login_form};
// [...]
fn run(/* */) -> Result<Server, std::io::Error> {
// [...]
let server = HttpServer::new(move || {
App::new()
// [...]
.route("/login", web::get().to(login_form))
// [...]
})
// [...]
}
2.1. HTML Forms
The HTML will be more convoluted this time:
<!-- src/routes/login/login.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<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;
use actix_web::http::header::ContentType;
pub async fn login_form() -> HttpResponse {
HttpResponse::Ok()
.content_type(ContentType::html())
.body(include_str!("login.html"))
}
form
is the HTML element doing the heavy-lifting here. Its job is to collect a set of data fields and send them over for processing to a backend server.
The fields are defined using the input
element - we have two here: username and password.
Inputs are given a type
attribute - it tells the browser how to display them.
text
and password
will both be rendered as a single-line free-text field, with one key difference: the characters entered into a password
field are obfuscated.
Each input
is wrapped in a label
element:
- clicking on the label name toggles the input field;
- it improves accessibility for screen-readers users (it is read out loud when the user is focused on the element).
On each input we have set two other attributes:
placeholder
, whose value is shown as a suggestion within the text field before the user starts filling the form;name
, the key that we must use in the backend to identify the field value within the submitted form data.
At the end of the form, there is a button
- it will trigger the submission of the provided input to the backend.
What happens if you enter a random username and password and try to submit it?
The page refreshes, the input fields are reset - the URL has changed though!
It should now be localhost:8000/login?username=myusername&password=mysecretpassword
.
This is form
's default behaviour4 - form
submits the data to the very same page it is being served from (i.e. /login
) using the GET
HTTP verb.
This is far from ideal - as you have just witnessed, a form submitted via GET
encodes all input data in clear text as query parameters. Being part of the URL, they end up stored as part of the browser's navigation history. Query parameters are also captured in logs (e.g. http.route
property in our own backend).
We really do not want passwords or any type of sensitive data there.
We can change this behaviour by setting a value for action
and method
on form
:
<!-- src/routes/login/login.html -->
<!-- [...] -->
<form action="/login" method="post">
<!-- [...] -->
We could technically omit action
, but the default behaviour is not particularly well-documented therefore it is clearer to define it explicitly.
Thanks to method="post"
the input data will be passed to the backend using the request body, a much safer option.
If you try to submit the form again, you should see a 404
in the API logs for POST /login
. Let's define the endpoint!
//! src/routes/login/mod.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;
// [...]
fn run(/* */) -> Result</* */> {
// [...]
let server = HttpServer::new(move || {
App::new()
// [...]
.route("/login", web::post().to(login))
// [...]
})
// [...]
}
2.2. Redirect On Success
Try to log in again: the form will disappear, you will be greeted by a blank page. It is not the best kind of feedback - it would be ideal to show a message confirming that the user has logged in successfully. Furthermore, if the user tries to refresh the page, they will be prompted by the browser to confirm that they want to submit the form again.
We can improve the situation by using a redirect - if authentication succeeds, we instruct the browser to navigate back to our home page.
A redirect response requires two elements:
- a redirect status code;
- a
Location
header, set to the URL we want to redirect to.
All redirect status codes are in the 3xx
range - we need to choose the most appropriate one depending on the HTTP verb and the semantic meaning we want to communicate (e.g. temporary vs permanent redirection).
You can find a comprehensive guide on MDN Web Docs. 303 See Other
is the most fitting for our usecase (confirmation page after form submission):
//! src/routes/login/post.rs
use actix_web::http::header::LOCATION;
use actix_web::HttpResponse;
pub async fn login() -> HttpResponse {
HttpResponse::SeeOther()
.insert_header((LOCATION, "/"))
.finish()
}
You should now see Welcome to our newsletter!
after form submission.
2.3. Processing Form Data
Truth be told, we are not redirecting on success - we are always redirecting.
We need to enhance our login
function to actually verify the incoming credentials.
As we have seen in chapter 3, form data is submitted to the backend using the application/x-www-form-urlencoded
content type.
We can parse it out of the incoming request using actix-web
's Form
extractor and a struct that implements serde::Deserialize
:
//! src/routes/login/post.rs
// [...]
use actix_web::web;
use secrecy::Secret;
#[derive(serde::Deserialize)]
pub struct FormData {
username: String,
password: Secret<String>,
}
pub async fn login(_form: web::Form<FormData>) -> HttpResponse {
// [...]
}
We built the foundation of password-based authentication in the earlier part of this chapter - let's look again at the auth code in the handler for 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())
.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
deals with the extraction of credentials from the Authorization
header when using the 'Basic' authentication scheme - not something we are interested in reusing in login
.
validation_credentials
, instead, is what we are looking for: it takes username and password as input, returning either the corresponding user_id
(if authentication is successful) or an error (if credentials are invalid).
The current definition of validation_credentials
is polluted by the concerns of publish_newsletters
:
//! src/routes/newsletters.rs
// [...]
async fn validate_credentials(
credentials: Credentials,
pool: &PgPool,
// We are returning a `PublishError`,
// which is a specific error type detailing
// the relevant failure modes of `POST /newsletters`
// (not just auth!)
) -> Result<uuid::Uuid, PublishError> {
let mut user_id = None;
let mut expected_password_hash = Secret::new(
"$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("Failed to spawn blocking task.")
.map_err(PublishError::UnexpectedError)??;
user_id.ok_or_else(|| PublishError::AuthError(anyhow::anyhow!("Unknown username.")))
}
2.3.1. Building An authentication
Module
Let's refactor validate_credentials
to prepare it for extraction - we want to build a shared authentication
module, which we will use in both POST /login
and POST /newsletters
.
Let's define AuthError
, a new error enum:
//! 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),
}
We are using an enumeration because, just like we did in POST /newsletters
, we want to empower the caller to react differently depending on the error type - i.e. return a 500
for UnexpectedError
, while AuthError
s should result into a 401
.
Let's change the signature of validate_credentials
to return Result<uuid::Uuid, AuthError>
now:
//! src/routes/newsletters.rs
use crate::authentication::AuthError;
// [...]
async fn validate_credentials(
// [...]
) -> Result<uuid::Uuid, AuthError> {
// [...]
if let Some(/* */) = get_stored_credentials(/* */).await?
{/* */}
spawn_blocking_with_tracing(/* */)
.await
.context("Failed to spawn blocking task.")??;
user_id
.ok_or_else(|| anyhow::anyhow!("Unknown username."))
.map_err(AuthError::InvalidCredentials)
}
cargo check
returns two errors now:
error[E0277]: `?` couldn't convert the error to `AuthError`
--> src/routes/newsletters.rs
|
| .context("Failed to spawn blocking task.")??;
| ^
the trait `From<PublishError>` is not implemented for `AuthError`
error[E0277]: `?` couldn't convert the error to `PublishError`
--> src/routes/newsletters.rs
|
| let user_id = validate_credentials(credentials, &pool).await?;
| ^
the trait `From<AuthError>` is not implemented for `PublishError`
|
The first error comes from validate_credentials
itself - we are calling verify_password_hash
, which is still returning a PublishError
.
//! src/routes/newsletters.rs
// [...]
#[tracing::instrument(/* */)]
fn verify_password_hash(
expected_password_hash: Secret<String>,
password_candidate: Secret<String>,
) -> 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)
}
Let's fix it:
//! src/routes/newsletters.rs
// [...]
#[tracing::instrument(/* */)]
fn verify_password_hash(/* */) -> Result<(), AuthError> {
let expected_password_hash = PasswordHash::new(/* */)
.context("Failed to parse hash in PHC string format.")?;
Argon2::default()
.verify_password(/* */)
.context("Invalid password.")
.map_err(AuthError::InvalidCredentials)
}
Let's deal with second error now:
error[E0277]: `?` couldn't convert the error to `PublishError`
--> src/routes/newsletters.rs
|
| let user_id = validate_credentials(credentials, &pool).await?;
| ^
the trait `From<AuthError>` is not implemented for `PublishError`
|
This comes from the call to verify_credentials
inside publish_newsletters
, the request handler.
AuthError
does not implement a conversion into PublishError
, therefore the ?
operator cannot be used.
We will call map_err
to perform the mapping inline:
//! src/routes/newsletters.rs
// [...]
pub async fn publish_newsletter(/* */) -> Result<HttpResponse, PublishError> {
// [...]
let user_id = validate_credentials(credentials, &pool)
.await
// We match on `AuthError`'s variants, but we pass the **whole** error
// into the constructors for `PublishError` variants. This ensures that
// the context of the top-level wrapper 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()),
})?;
// [...]
}
The code should compile now.
Let's complete the extraction by moving validate_credentials
, Credentials
, get_stored_credentials
and verify_password_hash
into the authentication
module:
//! src/authentication.rs
use crate::telemetry::spawn_blocking_with_tracing;
use anyhow::Context;
use secrecy::{Secret, ExposeSecret};
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use sqlx::PgPool;
// [...]
pub struct Credentials {
// These two fields were not marked as `pub` before!
pub username: String,
pub password: Secret<String>,
}
#[tracing::instrument(/* */)]
pub async fn validate_credentials(/* */) -> Result</* */> {
// [...]
}
#[tracing::instrument(/* */)]
fn verify_password_hash(/* */) -> Result</* */> {
// [...]
}
#[tracing::instrument(/* */)]
async fn get_stored_credentials(/* */) -> Result</* */> {
// [...]
}
//! src/routes/newsletters.rs
// [...]
use crate::authentication::{validate_credentials, AuthError, Credentials};
// There will be warnings about unused imports, follow the compiler to fix them!
// [...]
2.3.2. Rejecting Invalid Credentials
The extracted authentication
module is now ready to be used in our login
function.
Let's plug it in:
//! src/routes/login/post.rs
use crate::authentication::{validate_credentials, Credentials};
use actix_web::http::header::LOCATION;
use actix_web::web;
use actix_web::HttpResponse;
use secrecy::Secret;
use sqlx::PgPool;
#[derive(serde::Deserialize)]
pub struct FormData {
username: String,
password: Secret<String>,
}
#[tracing::instrument(
skip(form, pool),
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
// We are now injecting `PgPool` to retrieve stored credentials from the database
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!()
}
}
}
A login attempt using random credentials should now fail: the request handler panics due to validation_credentials
returning an error, which in turn leads to actix-web
dropping the connection. It is not a graceful failure - the browser is likely to show something along the lines of The connection was reset
.
We should try as much as possible to avoid panics in request handlers - all errors should be handled gracefully.
Let's introduce a LoginError
:
//! src/routes/login/post.rs
// [...]
use crate::authentication::AuthError;
use crate::routes::error_chain_fmt;
use actix_web::http::StatusCode;
use actix_web::{web, ResponseError};
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> 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) -> StatusCode {
match self {
LoginError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
LoginError::AuthError(_) => StatusCode::UNAUTHORIZED,
}
}
}
The code is very similar to what we wrote a few sections ago while refactoring POST /newsletters
.
What is the effect on the browser?
Submission of the form triggers a page load, resulting in Authentication failed
being shown on screen5.
Much better than before, we are making progress!
2.4. Contextual Errors
The error message is clear enough - but what should the user do next?
It is reasonable to assume that they want to try to enter their credentials again - they have probably misspelled their username or their password.
We need the error message to appear on top of the login form - providing the user with information while allowing them to quickly retry.
2.4.1. Naive Approach
What is the simplest possible approach?
We could return the login HTML page from ResponseError
, injecting an additional paragraph (<p>
HTML element) reporting the error to the user.
It would look like this:
//! src/routes/login/post.rs
// [...]
impl ResponseError for LoginError {
fn error_response(&self) -> HttpResponse {
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
))
}
fn status_code(&self) -> StatusCode {
match self {
LoginError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
LoginError::AuthError(_) => StatusCode::UNAUTHORIZED,
}
}
}
This approach has a few drawbacks:
- we have two slightly-different-but-almost-identical login pages, defined in two different places. If we decide to make changes to the login form, we need to remember to edit both;
- the user is prompted to confirm form resubmission if they try to refresh the page after an unsuccessful login attempt.
To solve the second issue, we need the user to land on a GET
endpoint.
To solve the first issue, we need to find a way to reuse the HTML we wrote in GET /login
, instead of duplicating it.
We can achieve both goals with another redirect: if authentication fails, we send the user back to GET /login
.
//! src/routes/login/post.rs
// [...]
impl ResponseError for LoginError {
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code())
.insert_header((LOCATION, "/login"))
.finish()
}
fn status_code(&self) -> StatusCode {
StatusCode::SEE_OTHER
}
}
Unfortunately a vanilla redirect is not enough - the browser would show the login form to the user again, with no feedback explaining that their login attempt was unsuccessful.
We need to find a way to instruct GET /login
to show an error message.
Let's explore a few options.
2.4.2. Query Parameters
The value of the Location
header determines the URL the user will be redirected to.
It does not end there though - we can also specify query parameters!
Let's encode the authentication error message in an error
query parameter.
Query parameters are part of the URL - therefore we need to URL-encode the display representation of LoginError
.
#! Cargo.toml
# [...]
[dependencies]
urlencoding = "2"
# [...]
//! src/routes/login/post.rs
// [...]
impl ResponseError for LoginError {
fn error_response(&self) -> HttpResponse {
let encoded_error = urlencoding::Encoded::new(self.to_string());
HttpResponse::build(self.status_code())
.insert_header((LOCATION, format!("/login?error={}", encoded_error)))
.finish()
}
// [...]
}
The error
query parameter can then be extracted in the request handler for GET /login
.
//! src/routes/login/get.rs
use actix_web::{web, HttpResponse, http::header::ContentType};
#[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"))
}
Finally, we can customise the returned HTML page based on its value:
//! 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>"#,
))
}
It works6!
2.4.3. Cross-Site Scripting (XSS)
Query parameters are not private - our backend server cannot prevent users from tweaking the URL.
In particular, it cannot prevent attackers from playing with them.
Try to navigate to the following 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.
On top of the login form you will see
Your account has been locked, please submit your details here to resolve the issue.
with here
being a link to another website (zero2prod.com, in this case).
In a more realistic scenario, here
would link to a website controlled by an attacker, luring the victim into disclosing their login credentials.
This is known as a cross-site scripting attack (XSS).
The attacker injects HTML fragments or JavaScript snippets into a trusted website by exploiting dynamic content built from untrusted sources - e.g. user inputs, query parameters, etc.
From a user perspective, XSS attacks are particularly insidious - the URL matches the one you wanted to visit, therefore you are likely to trust the displayed content.
OWASP provides an extensive cheat sheet on how to prevent XSS attacks - I strongly recommend familiarising with it if you are working on a web application.
Let's look at the guidance for our issue here: we want to display untrusted data (the value of a query parameter) inside an HTML element (<p><i>UNTRUSTED DATA HERE</i></p>
).
According to OWASP's guidelines, we must HTML entity-encode the untrusted input - i.e.:
- convert
&
to&
; - convert
<
to<
; - convert
>
to>
; - convert
"
to"
; - convert
'
to'
; - convert
/
to/
.
HTML entity encoding prevents the insertion of further HTML elements by escaping the characters required to define them.
Let's amend our login_form
handler:
#! Cargo.toml
# [...]
[dependencies]
htmlescape = "0.3"
# [...]
//! src/routes/login/get.rs
// [...]
pub async fn login_form(/* */) -> HttpResponse {
let error_html = match query.0.error {
None => "".into(),
Some(error_message) => format!(
"<p><i>{}</i></p>",
htmlescape::encode_minimal(&error_message)
),
};
// [...]
}
Load the compromised URL again - you will see a different message:
Your account has been locked, please submit your details <a href="https://zero2prod.com">here</a> to resolve the issue.
The HTML a
element is no longer rendered by the browser - the user has now reasons to suspect that something fishy is going on.
Is it enough?
At the very least, users are less likely to copy-paste and navigate to the link compared to just clicking on here
. Nonetheless, attackers are not naive - they will amend the injected message as soon as they notice that our website is performing HTML entity encoding. It could be as simple as
Your account has been locked, please call +CC3332288777 to resolve the issue.
This might be good enough to lure in a couple of victims. We need something stronger than character escaping.
2.4.4. Message Authentication Codes
We need a mechanism to verify that the query parameters have been set by our API and that they have not been altered by a third party.
This is known as message authentication - it guarantees that the message has not been modified in transit (integrity) and it allows you to verify the identity of the sender (data origin authentication).
Message authentication codes (MACs) are a common technique to provide message authentication - a tag is added to the message allowing verifiers to check its integrity and origin.
HMAC are a well-known family of MACs - hash-based message authentication codes.
HMACs are built around a secret and a hash function.
The secret is prepended to the message and the resulting string is fed into the hash function. The resulting hash is then concatenated to the secret and hashed again - the output is the message tag.
In pseudo-code:
let hmac_tag = hash(
concat(
key,
hash(concat(key, message))
)
);
We are deliberately omitting a few nuances around key padding - you can find all the details in RFC 2104.
2.4.5. Add An HMAC Tag To Protect Query Parameters
Let's try to use a HMAC to verify integrity and provenance for our query parameters.
The Rust Crypto organization provides an implementation of HMACs, the hmac
crate. We will also need a hash function - let's go for SHA-256.
#! Cargo.toml
# [...]
[dependencies]
hmac = { version = "0.12", features = ["std"] }
sha2 = "0.10"
# [...]
Let's add another query parameter to our Location
header, tag
, to store the HMAC of our error message.
//! src/routes/login/post.rs
use hmac::{Hmac, Mac};
// [...]
impl ResponseError for LoginError {
fn error_response(&self) -> HttpResponse {
let query_string = format!(
"error={}",
urlencoding::Encoded::new(self.to_string())
);
// We need the secret here - how do we get it?
let secret: &[u8] = todo!();
let hmac_tag = {
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(secret).unwrap();
mac.update(query_string.as_bytes());
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?{}&tag={:x}", query_string, hmac_tag)
))
.finish()
}
// [...]
}
The code snippet is almost perfect - we just need a way to get our secret!
Unfortunately it will not be possible from within ResponseError
- we only have access to the error type (LoginError
) that we are trying to convert into an HTTP response. ResponseError
is just a specialised Into
trait.
In particular, we do not have access to the application state (i.e. we cannot use the web::Data
extractor), which is where we would be storing the secret.
Let's move our code back into the request handler:
//! src/routes/login/post.rs
use secret::ExposeSecret;
// [...]
#[tracing::instrument(
skip(form, pool, secret),
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
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<Secret<String>>,
// 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?{query_string}&tag={hmac_tag:x}")
))
.finish()
}
}
}
// The `ResponseError` implementation for `LoginError` has been deleted.
This is a viable approach - and it compiles.
It has one drawback - we are no longer propagating upstream, to the middleware chain, the error context. This is concerning when dealing with a LoginError::UnexpectedError
- our logs should really capture what has gone wrong.
Luckily enough, there is a way to have our cake and eat it too: actix_web::error::InternalError
.
InternalError
can be built from a HttpResponse
and an error. It can be returned as an error from a request handler (it implements ResponseError
) and it returns to the caller the HttpResponse
you passed to its constructor - exactly what we needed!
Let's change login
once again to use it:
//! src/routes/login/post.rs
// [...]
use actix_web::error::InternalError;
#[tracing::instrument(/* */)]
// 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))
}
}
}
Error reporting has been saved.
One last task left for us: injecting the secret used by our HMACs into the application state.
//! src/configuration.rs
// [...]
#[derive(serde::Deserialize, Clone)]
pub struct ApplicationSettings {
// [...]
pub hmac_secret: Secret<String>
}
//! src/startup.rs
use secrecy::Secret;
// [...]
impl Application {
pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> {
// [...]
let server = run(
// [...]
configuration.application.hmac_secret,
)?;
// [...]
}
}
fn run(
// [...]
hmac_secret: Secret<String>,
) -> Result<Server, std::io::Error> {
let server = HttpServer::new(move || {
// [...]
.app_data(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"
# [...]
Using Secret<String>
as the type injected into the application state is far from ideal. String
is a primitive type and there is a significant risk of conflict - i.e. another middleware or service registering another Secret<String>
against the application state, overriding our HMAC secret (or vice versa).
Let's create a wrapper type to sidestep the issue:
//! src/startup.rs
// [...]
fn run(
// [...]
hmac_secret: HmacSecret,
) -> Result<Server, std::io::Error> {
let server = HttpServer::new(move || {
// [...]
.app_data(Data::new(HmacSecret(hmac_secret.clone())))
})
// [...]
}
#[derive(Clone)]
pub struct HmacSecret(pub Secret<String>);
//! src/routes/login/post.rs
use crate::startup::HmacSecret;
// [...]
#[tracing::instrument(/* */)]
pub async fn login(
// [...]
// Inject the wrapper type!
secret: web::Data<HmacSecret>,
) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(/* */).await {
Ok(/* */) => {/* */}
Err(/* */) => {
// [...]
let hmac_tag = {
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(
secret.0.expose_secret().as_bytes()
).unwrap();
// [...]
};
// [...]
}
}
}
2.4.6. Verifying The HMAC Tag
Time to validate that tag in GET /login
!
Let's start by extracting the tag
query parameter.
We are currently using the Query
extractor to parse the incoming query parameters into a QueryParams
struct, which features an optional error
field.
Going forward, we foresee two scenarios:
- There is no error (e.g. you just landed on the login page), therefore we do not expect any query parameter;
- There is an error to be reported, therefore we expect to see both an
error
and atag
query parameter.
Changing QueryParams
from
#[derive(serde::Deserialize)]
pub struct QueryParams {
error: Option<String>,
}
to
#[derive(serde::Deserialize)]
pub struct QueryParams {
error: Option<String>,
tag: Option<String>,
}
would not capture the new requirements accurately - it would allow callers to pass a tag
parameter while omitting the error
one, or vice versa. We would need to do extra validation in the request handler to make sure this is not the case.
We can avoid this issue entirely by making all fields in QueryParams
required while QueryParams
itself becomes optional:
//! 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.0.error))
}
};
// [...]
}
A neat little reminder to make illegal state impossible to represent using types!
To verify the tag we will need access to the HMAC shared secret - let's inject it:
//! 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
was a byte slice encoded as a hex string. We will need the hex
crate to decode it back to bytes in GET /login
. Let's add it as a dependency:
#! Cargo.toml
# [...]
[dependencies]
# [...]
hex = "0.4"
We can now define a verify
method on QueryParams
itself: it will return the error string if the message authentication code matches our expectations, an error otherwise.
//! 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)
}
}
We now need to amend the request handler to call it, which raises a question: what do we want to do if the verification fails?
One approach is to fail the entire request by returning a 400
. Alternatively, we can log the verification failure as a warning and skip the error message when rendering the HTML.
Let's go for the latter - a user being redirected with some dodgy query parameters will see our login page, an acceptable scenario.
//! src/routes/login/get.rs
// [...]
pub async fn login_form(/* */) -> 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()
}
},
};
// [...]
}
You can try again to load our scammy 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.
No error message should be rendered by the browser!
2.4.7. Error Messages Must Be Ephemeral
Implementation-wise, we are happy: the error is rendered as expected and nobody can tamper with our messages thanks to the HMAC tag. Should we deploy it?
We chose a query parameter to pass along the error message because query parameters are a part of the URL - it is easy to pass them along in the value of the Location
header when redirecting back to the login form on failures. This is both their strength and their weakness: URLs are stored in the browser history, which is in turn used to provide autocomplete suggestions when typing a URL into the address bar. You can experiment with this yourself: try to type localhost:8000
in your address bar - what are the suggestions?
Most of them will be URLs including the error
query parameter due to all the experiments we have been doing so far. If you pick one with a valid tag
, the login form will feature an Authentication failed
error message... even though it has been a while since your last login attempt. This is undesirable.
We would like the error message to be ephemeral.
It is shown right after a failed login attempt, but it is not stored in your browser history. The only way to trigger the error message again should be to... fail to log in one more time.
We established that query parameters do not meet our requirements. Do we have other options?
Yes, cookies!
This is a great moment to take a break, this is a long chapter!
Check out the project snapshot on GitHub if you want to check your implementation.
2.4.8. What Is A Cookie?
MDN Web Docs defines an HTTP cookie as
[...] a small piece of data that a server sends to a user's web browser. The browser may store the cookie and send it back to the same server with later requests.
We can use cookies to implement the same strategy we tried with query parameters:
- The user enters invalid credentials and submits the form;
POST /login
sets a cookie containing the error message and redirects the user back toGET /login
;- The browser calls
GET /login
, including the values of the cookies currently set for the user; GET /login
's request handler checks the cookies to see if there is an error message to be rendered;GET /login
returns the HTML form to the caller and deletes the error message from the cookie.
The URL is never touched - all error-related information is exchanged via a side-channel (cookies), invisible to the browser history. The last step in the algorithm ensures that the error message is indeed ephemeral - the cookie is "consumed" when the error message is rendered. If the page is reloaded, the error message will not be shown again.
One-time notifications, the technique we just described, are known as flash messages.
2.4.9. An Integration Test For Login Failures
So far we have experimented quite freely - we wrote some code, launched the application, played around with it.
We are now approaching the final iteration of our design and it would be nice to capture the desired behaviour using some black-box tests, as we did so far for all the user flows supported by our project. Writing a test will also help us to get familiar with cookies and their behaviour.
We want to verify what happens on login failures, the topic we have been obsessing over for a few sections now.
Let's start by adding a new login
module to our test suite:
//! tests/api/main.rs
// [...]
mod login;
//! tests/api/login.rs
// Empty for now
We will need to send a POST /login
request - let's add a little helper to our TestApp
, the HTTP client used to interact with our application in our tests:
//! 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.")
}
// [...]
}
We can now start to sketch our test case. Before touching cookies, we will begin with a simple assertion - it returns a redirect, status code 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);
}
The test fails!
---- login::an_error_flash_message_is_set_on_failure stdout ----
thread 'login::an_error_flash_message_is_set_on_failure' panicked at
'assertion failed: `(left == right)`
left: `200`,
right: `303`'
Our endpoint already returns a 303
- both in case of failure and success! What is going on?
The answer can be found in reqwest
's documentation:
By default, a
Client
will automatically handle HTTP redirects, having a maximum redirect chain of 10 hops. To customize this behavior, aredirect::Policy
can be used with aClientBuilder
.
reqwest::Client
sees the 303
status code and automatically proceeds to call GET /login
, the path specified in the Location
header, which return a 200
- the status code we see in the assertion panic message.
For the purpose of our testing, we do not want reqwest::Client
to follow redirects - let's customise the HTTP client behaviour by following the guidance provided in its documentation:
//! 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()
// [...]
}
// [...]
}
The test should pass now.
We can go one step further - inspect the value of the Location
header.
//! tests/api/helpers.rs
// [...]
// Little helper function - we will be doing this check several 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");
}
You should see another failure:
---- login::an_error_flash_message_is_set_on_failure stdout ----
thread 'login::an_error_flash_message_is_set_on_failure' panicked at
'assertion failed: `(left == right)`
left: `"/login?error=Authentication%20failed.&tag=2f8fff5[...]"`,
right: `"/login"`'
The endpoint is still using query parameters to pass along the error message. Let's remove that functionality from the request handler:
//! src/routes/login/post.rs
// A few imports are now unused and can be removed.
// [...]
#[tracing::instrument(/* */)]
pub async fn login(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
// We no longer need `HmacSecret`!
) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(/* */).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))
}
}
}
I know, it feels like we are going backwards - you need have a bit of patience!
The test should pass. We can now start looking at cookies, which begs the question - what does "set a cookie" actually mean?
Cookies are set by attaching a special HTTP header to the response - Set-Cookie
.
In its simplest form it looks like this:
Set-Cookie: <cookie-name>=<cookie-value>
Set-Cookie
can be specified multiple times - one for each cookie you want to set.
reqwest
provides the get_all
method to deal with multi-value headers:
//! tests/api/login.rs
// [...]
use reqwest::header::HeaderValue;
use std::collections::HashSet;
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// [...]
let cookies: HashSet<_> = response
.headers()
.get_all("Set-Cookie")
.into_iter()
.collect();
assert!(cookies
.contains(&HeaderValue::from_str("_flash=Authentication failed").unwrap())
);
}
Truth be told, cookies are so ubiquitous to deserve a dedicated API, sparing us the pain of working with the raw headers. reqwest
locks this functionality behind the cookies
feature-flag - let's enable it:
#! Cargo.toml
# [...]
# Using multi-line format for brevity
[dependencies.reqwest]
version = "0.11"
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() {
// [...]
let flash_cookie = response.cookies().find(|c| c.name() == "_flash").unwrap();
assert_eq!(flash_cookie.value(), "Authentication failed");
}
As you can see, the cookie API is significantly more ergonomic. Nonetheless there is value in touching directly what it abstracts away, at least once.
The test should fail, as expected.
2.4.10. How To Set A Cookie In actix-web
How do we set a cookie on the outgoing response in actix-web
?
We can work with headers directly:
//! src/routes/login/post.rs
// [...]
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> Result<HttpResponse, InternalError<LoginError>> {
match validate_credentials(/* */).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))
}
}
}
This change should be enough to get the test to pass.
Just like reqwest
, actix-web
provides a dedicated cookie API. Cookie::new
takes two arguments - the name and the value of the cookie. Let's use it:
//! src/routes/login/post.rs
use actix_web::cookie::Cookie;
// [...]
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> Result<HttpResponse, InternalError<LoginError>> {
match validate_credentials(/* */).await {
Ok(/* */) => {/* */}
Err(e) => {
// [...]
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, "/login"))
.cookie(Cookie::new("_flash", e.to_string()))
.finish();
Err(InternalError::from_response(e, response))
}
}
}
The test should stay green.
2.4.11. An Integration Test For Login Failures - Part 2
Let's focus on the other side of the story now - GET /login
. We want to verify that the error message, passed along in the _flash
cookie, is actually rendered above the login form shown to the user after the redirect.
Let's start by adding a get_login_html
helper method on TestApp
:
//! tests/api/helpers.rs
// [...]
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()
}
// [...]
}
We can then extend our existing test to call get_login
after having submitted invalid credentials to POST /login
:
//! tests/api/login.rs
// [...]
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// [...]
// Act
let login_body = serde_json::json!({
"username": "random-username",
"password": "random-password"
});
let response = app.post_login(&login_body).await;
// Assert
// [...]
// Act - Part 2
let html_page = app.get_login_html().await;
assert!(html_page.contains(r#"<p><i>Authentication failed</i></p>"#));
}
The test should fail.
As it stands, there is no way for us to get it to pass: we are not propagating the cookies set by POST /login
when sending a request to GET /login
- the browser would be expected to fulfill this task in normal circumstances. Can reqwest
take care of it?
By default, it does not - but it can be configured to! We just need to pass true
to reqwest::ClientBuilder::cookie_store
.
There is a caveat though - we must use the same instance of reqwest::Client
for all requests to our API if we want cookie propagation to work. This requires a bit of refactoring in TestApp
- we are currently creating a new reqwest::Client
instance inside every helper method. Let's change TestApp::spawn_app
to create and store an instance of reqwest::Client
which we will in turn use in all its helper methods.
//! tests/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 post_subscriptions(/* */) -> reqwest::Response {
self.api_client
.post(/* */)
// [...]
}
pub async fn post_newsletters(/* */) -> reqwest::Response {
self.api_client
.post(/* */)
// [...]
}
pub async fn post_login<Body>(/* */) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(/* */)
// [...]
}
pub async fn get_login_html(/* */) -> String {
self.api_client
.get(/* */)
// [...]
}
// [...]
}
Cookie propagation should now work as expected.
2.4.12. How To Read A Cookie In actix-web
It's time to look again at our request handler for GET /login
:
//! src/routes/login/get.rs
use crate::startup::HmacSecret;
use actix_web::http::header::ContentType;
use actix_web::{web, HttpResponse};
use hmac::{Hmac, Mac, NewMac};
#[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!(/* */);
"".into()
}
},
};
HttpResponse::Ok()
.content_type(ContentType::html())
.body(format!(/* HTML */))
}
Let's begin by ripping out all the code related to query parameters and their (cryptographic) validation:
//! 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!(/* HTML */))
}
Back to the basics. Let's seize this opportunity to remove the dependencies we added during our HMAC adventure - sha2
, hmac
and hex
.
To access cookies on an incoming request we need to get our hands on HttpRequest
itself. Let's add it as an input to login_form
:
//! src/routes/login/get.rs
// [...]
use actix_web::HttpRequest;
pub async fn login_form(request: HttpRequest) -> HttpResponse {
// [...]
}
We can then use HttpRequest::cookie
to retrieve a cookie given its name:
//! src/routes/login/get.rs
// [...]
pub async fn login_form(request: HttpRequest) -> HttpResponse {
let error_html = match request.cookie("_flash") {
None => "".into(),
Some(cookie) => {
format!("<p><i>{}</i></p>", cookie.value())
}
};
// [...]
}
Our integration test should pass now!
2.4.13. How To Delete A Cookie In actix-web
What happens if you refresh the page after a failed login attempt? The error message is still there!
The same thing happens if you open a new tab and navigate straight to localhost:8000/login
- Authentication failed
will show up on top of the login form.
This is not what we had in mind when we said that error messages should be ephemeral. How do we fix it?
There is no Unset-cookie
header - how do we delete the _flash
cookie from the user's browser?
Let's zoom in on the lifecycle of a cookie.
When it comes to durability, there are two types of cookies: session cookies and persistent cookies. Session cookies are stored in memory - they are deleted when the session ends (i.e. the browser is closed). Persistent cookies, instead, are saved to disk and will still be there when you re-open the browser.
A vanilla Set-Cookie
header creates a session cookie. To set a persistent cookie you must specify an expiration policy using a cookie attribute - either Max-Age
or Expires
.
Max-Age
is interpreted as the number of seconds remaining until the cookie expires - e.g. Set-Cookie: _flash=omg; Max-Age=5
creates a persistent _flash
cookie that will be valid for the next 5 seconds.
Expires
, instead, expects a date - e.g. Set-Cookie: _flash=omg; Expires=Thu, 31 Dec 2022 23:59:59 GMT;
creates a persistent cookie that will be valid until the end of 2022.
Setting Max-Age
to 0 instructs the browser to immediately expire the cookie - i.e. to unset it, which is exactly what we want! A bit hacky? Yes, but it is what it is.
Let's kick-off the implementation work. We can start by modifying our integration test to account for this scenario - the error message should not be shown if we reload the login page after the first redirect:
//! tests/api/login.rs
// [...]
#[tokio::test]
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
should report a failure. We now need to change our request handler - we must set the _flash
cookie on the response with Max-Age=0
to remove the flash messages stored in the user's browser.:
//! src/routes/login/get.rs
use actix_web::cookie::{Cookie, time::Duration};
//! [...]
pub async fn login_form(request: HttpRequest) -> HttpResponse {
// [...]
HttpResponse::Ok()
.content_type(ContentType::html())
.cookie(
Cookie::build("_flash", "")
.max_age(Duration::ZERO)
.finish(),
)
.body(/* */)
}
The test should pass now!
We can make our intent clearer by refactoring our handler to use the add_removal_cookie
method:
//! 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
}
Under the hood, it performs the exact same operation but it does not require the reader to piece together the meaning of setting Max-Age
to zero.
2.4.14. Cookie Security
What security challenges do we face when working with cookies?
It is still possible to perform a XSS attack using cookies, but it requires a bit more effort compared to query parameters - you cannot craft a link to our website that sets or manipulates cookies. Nonetheless, using cookies naively can expose us to bad actors.
What type of attacks can be mounted against cookies?
Broadly speaking, we want to prevent attackers from tampering with our cookies (i.e. integrity) or sniffing their content (i.e. confidentiality).
First and foremost, transmitting cookies over an insecure connection (i.e. HTTP instead of HTTPS) exposes us to man in the middle attacks - the request sent to the server by the browser can be intercepted, read and its content modified arbitrarily.
The first line of defense is our API - it should reject requests sent over unencrypted channels. We can benefit from an additional layer of defense by marking newly created cookies as Secure
: this instructs browsers to only attach the cookie to requests transmitted over secure connections.
The second major threat to the confidentiality and integrity of our cookies is JavaScript: scripts running client-side can interact with the cookie store, read/modify existing cookies or set new ones. As a rule of thumb, a least-privilege policy is a good default: cookies should not be visible to scripts unless there is a compelling reason to do otherwise.
We can mark newly created cookies as Http-Only
to hide them from client-side code - the browser will store them and attach them to outgoing requests, as usual, but scripts will not be able to see them.
Http-Only
is a good default, but it is not a panacea - JavaScript code might not be able to access our Http-Only
cookie, but there are ways to overwrite them7 and trick the backend to perform some unexpected or undesired actions.
Last but not least, users can be a threat as well! They can freely manipulate the content of their cookie storage using the developer tools provided by their browser. While this might not be an issue when looking at flash messages, it definitely becomes a concern when working with other types of cookies (e.g. auth sessions, which we will be looking at shortly).
We should have multiple layers of defense.
We already know of an approach to ensure integrity, no matter what happens in the front-channel, don't we?
Message authentication codes (MAC), the ones we used to secure our query parameters! A cookie value with an HMAC tag attached is often referred to as a signed cookie. By verifying the tag on the backend we can be confident that the value of a signed cookie has not been tampered with, just like we did for query parameters.
2.4.15. actix-web-flash-messages
We could use the cookie API provided by actix-web
to harden our cookie-based implementation of flash messages - some things are straight-forward (Secure
, Http-Only
), others requires a bit more work (HMAC), but they are all quite achievable if we put in some effort.
We have already covered HMAC tags in depth when discussing query parameters, so there would be little educational benefit in implementing signed cookies from scratch. We will instead plug in one of the crates from actix-web
's community ecosystem: actix-web-flash-messages
8.
actix-web-flash-messages
provides a framework to work with flash messages in actix-web
, closely modeled after Django's message framework.
Let's add it as a dependency:
#! Cargo.toml
# [...]
[dependencies]
actix-web-flash-messages = { version = "0.4", features = ["cookies"] }
# [...]
To start playing around with flash messages we need to register FlashMessagesFramework
as a middleware on our actix_web
's App
:
//! src/startup.rs
// [...]
use actix_web_flash_messages::FlashMessagesFramework;
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
expects a storage backend as argument - where should flash messages be stored and retrieved from?
actix-web-flash-messages
provides a cookie-based implementation, CookieMessageStore
.
//! src/startup.rs
// [...]
use actix_web_flash_messages::storage::CookieMessageStore;
fn run(/* */) -> Result<Server, std::io::Error> {
// [...]
let message_store = CookieMessageStore::builder(todo!()).build();
let message_framework = FlashMessagesFramework::builder(message_store).build();
// [...]
}
CookieMessageStore
enforces that the cookie used as storage is signed, therefore we must provide a Key
to its builder. We can reuse the hmac_secret
we introduced when working on HMAC tags for query parameters:
//! src/startup.rs
// [...]
use secrecy::ExposeSecret;
use actix_web::cookie::Key;
fn run(/* */) -> Result<Server, std::io::Error> {
// [...]
let message_store = CookieMessageStore::builder(
Key::from(hmac_secret.expose_secret().as_bytes())
).build();
// [...]
}
We can now start to send FlashMessage
s.
Each FlashMessage
has a level and a string of content. The message level can be used for filtering and rendering - for example:
- Only show flash messages at
info
level or above in a production environment, while retainingdebug
level messages for local development; - Use different colours, in the UI, to display messages (e.g. red for errors, orange for warnings, etc.).
We can rework POST /login
to send a FlashMessage
:
//! src/routes/login/post.rs
// [...]
use actix_web_flash_messages::FlashMessage;
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> Result</* */> {
// [...]
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();
// [...]
}
}
}
The FlashMessagesFramework
middleware takes care of all the heavy-lifting behind the scenes - creating the cookie, signing it, setting the right properties, etc.
We can also attach multiple flash messages to a single response - the framework takes care of how they should be combined and represented in the storage layer.
How does the receiving side work? How do we read incoming flash messages in GET /login
?
We can use the IncomingFlashMessages
extractor:
//! 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!(/* */))
}
The code needs to change a bit to accommodate the chance of having received multiple flash messages, but overall it is almost equivalent. In particular, we no longer have to deal with the cookie API, neither to retrieve incoming flash messages nor to make sure that they get erased after having been read - actix-web-flash-messages
takes care of it. The validity of the cookie signature is verified in the background as well, before the request handler is invoked.
What about our tests?
They are failing:
---- login::an_error_flash_message_is_set_on_failure stdout ----
thread 'login::an_error_flash_message_is_set_on_failure' panicked at
'assertion failed: `(left == right)`
left: `"Ik4JlkXTiTlc507ERzy2Ob4Xc4qXAPzJ7MiX6EB04c4%3D%5B%7B%2[...]"`,
right: `"Authentication failed"`'
Our assertions are a bit too close to the implementation details - we should only verify that the rendered HTML contains (or does not contain) the expected error message. Let's amend the test code:
//! tests/api/login.rs
// [...]
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// Arrange
// [...]
// Act - Part 1 - Try to login
// [...]
// 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("<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("<p><i>Authentication failed</i></p>"));
}
The test should pass now.
3. Sessions
We focused for a while on what should happen on a failed login attempt. Time to swap: what do we expect to see after a successful login?
Authentication is meant to restrict access to functionality that requires higher privileges - in our case, the capability to send out a new issue of the newsletter to the entire mailing list. We want to build an administration panel - we will have a /admin/dashboard
page, restricted to logged-in users, to access all admin functionality.
We will get there in stages. As the very first milestone, we want to:
- redirect to
/admin/dashboard
after a successful login attempt to show aWelcome <username>!
greeting message; - if a user tries to navigate directly to
/admin/dashboard
and they are not logged in, they will be redirected to the login form.
This plan requires sessions.
3.1. Session-based Authentication
Session-based authentication is a strategy to avoid asking users to provide their password on every single page. Users are asked to authenticate once, via a login form: if successful, the server generates a one-time secret - an authenticated session token9.
The backend API will accept the session token instead of the username/password combination and grant access to the restricted functionality. The session token must be provided on every request - this is why session tokens are stored as cookies. The browser will make sure to attach the cookie to all outgoing requests for the API.
From a security point of view, a valid session token is as powerful as the corresponding authentication secrets - e.g. the username/password combination, biometrics or physical second factors. We must take extreme care to avoid exposing session tokens to attackers.
OWASP provides extensive guidance on how to secure sessions - we will be implementing most of their recommendations in the next sections.
3.2. Session Store
Let's start to think about the implementation!
Based on what we discussed so far, we need the API to generate a session token after a successful login. The token value must be unpredictable - we do not want attackers to be able to generate or guess a valid session token10. OWASP recommends using a cryptographically secure pseudorandom number generator (CSPRNG).
Randomness on its own is not enough - we also need uniqueness. If we were to associate two users with the same session token we would be in trouble:
- we could be granting higher privileges to one of the two compared to what they deserve;
- we risk exposing personal or confidential information, such as names, emails, past activity, etc.
We need a session store - the server must remember the tokens it has generated in order to authorize future requests for logged-in users. We also want to associate information to each active session - this is known as session state.
3.3. Choosing A Session Store
During the lifecycle of a session we need to perform the following operations:
- creation, when a user logs in;
- retrieval, using the session tokens extracted from the cookie attached to the incoming requests;
- update, when a logged-in user performs some actions that lead to a change in their session state;
- deletion, when the user logs out.
These are commonly known as CRUD (create, delete, read, update).
We also need some form of expiration - sessions are meant to be short-lived. Without a clean-up mechanism we are going to end up using more space for outdated/stale session than active ones.
3.3.1. Postgres
Would Postgres be a viable session store?
We could create a new sessions
table with the token as primary index - an easy way to ensure token uniqueness. We have a few options for the session state:
- "classical" relational modelling, using a normalised schema (i.e. the way we approached storage of our application state);
- a single
state
column holding a collection of key-value pairs, using thejsonb
data type.
Unfortunately, there is no built-in mechanism for row expiration in Postgres. We would have to add a expires_at
column and trigger a cleanup job on a regular schedule to purge stale sessions - somewhat cumbersome.
3.3.2. Redis
Redis is another popular option when it comes to session storage.
Redis is an in-memory database - it uses RAM instead of disk for storage, trading off durability for speed. It is great fit, in particular, for data that can be modelled as a collection of key-value pairs. It also provides native support for expiration - we can attach a time-to-live to all values and Redis will take care of disposal.
How would it work for sessions?
Our application never manipulates sessions in bulk - we always work on a single session at a time, identified using its token. Therefore, we can use the session token as key while the value is the JSON representation of the session state - the application takes care of serialization/deserialization.
Sessions are meant to be short-lived - no reason to be concerned by the usage of RAM instead of disk for persistence, the speed boost is a nice side effect!
As you might have guessed at this point, we will be using Redis as our session storage backend!
3.4. actix-session
actix-session
provides session management for actix-web
applications. Let's add it to our dependencies:
#! Cargo.toml
# [...]
[dependencies]
# [...]
actix-session = "0.6"
The key type in actix-session
is SessionMiddleware
- it takes care of loading the session data, tracking changes to the state and persisting them at the end of the request/response lifecycle.
To build an instance of SessionMiddleware
we need to provide a storage backend and a secret key to sign (or encrypt) the session cookie. The approach is quite similar to the one used by FlashMessagesFramework
in actix-web-flash-messages
.
//! src/startup.rs
// [...]
use actix_session::SessionMiddleware;
fn run(
// [...]
) -> Result<Server, std::io::Error> {
// [...]
let secret_key = Key::from(hmac_secret.expose_secret().as_bytes());
let message_store = CookieMessageStore::builder(secret_key.clone()).build();
// [...]
let server = HttpServer::new(move || {
App::new()
.wrap(message_framework.clone())
.wrap(SessionMiddleware::new(todo!(), secret_key.clone()))
.wrap(TracingLogger::default())
// [...]
})
// [...]
}
actix-session
is quite flexible when it comes to storage - you can provide your own by implementing the SessionStore
trait. It also offers some implementations out of the box, hidden behind a set of feature flags - including a Redis backend. Let's enable it:
#! Cargo.toml
# [...]
[dependencies]
# [...]
actix-session = { version = "0.6", features = ["redis-rs-tls-session"] }
We can now access RedisSessionStore
. To build one we will have to pass a Redis connection string as input - let's add redis_uri
to our configuration struct:
//! src/configuration.rs
// [...]
#[derive(serde::Deserialize, Clone)]
pub struct Settings {
// [...]
// We have not created a stand-alone settings struct for Redis,
// let's see if we need more than the uri first!
// The URI is marked as secret because it may embed a password.
pub redis_uri: Secret<String>,
}
# configuration/base.yaml
# 6379 is Redis' default port
redis_uri: "redis://127.0.0.1:6379"
# [...]
Let's use it to build a RedisSessionStore
instance:
//! src/startup.rs
// [...]
use actix_session::storage::RedisSessionStore;
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?;
// [...]
}
}
// Now it's asynchronous!
async fn run(
// [...]
redis_uri: Secret<String>,
// 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<()> {
// [...]
}
Time to add a running Redis instance to our setup.
3.4.1. Redis In Our Development Setup
We need to run a Redis container alongside the Postgres container in our CI pipeline - check out the updated YAML in the book repository.
We also need a running Redis container on our development machine to execute the test suite and launch the application. Let's add a script to launch it:
# 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:6
>&2 echo "Redis is ready to go!"
The script needs to be marked as executable and then launched:
chmod +x ./scripts/init_redis.sh
./script/init_redis.sh
3.4.2. Redis On Digital Ocean
Digital Ocean does not support the creation of a development Redis cluster via the spec.yaml
file. You need to go through their dashboard - create a new Redis cluster here. Make sure to select the datacenter where you deployed the application. Once the cluster has been created, you have to go through a quick "Get started" flow to configure a few knobs (trusted sources, eviction policy, etc.).
At the end of the "Get started" flow you will be able to copy a connection string to your newly provisioned Redis instance. The connection string embeds a username and a password, therefore we must treat it as a secret. We will inject its value into the application using an environment value - set APP_REDIS_URI
from the Settings
panel in your application console.
3.5. Admin Dashboard
Our session store is now up and running in all the environments we care about. It is time to actually do something with it!
Let's create the skeleton for a new page, the admin dashboard.
//! src/routes/admin/mod.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/mod.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))
// [...]
})
// [...]
}
3.5.1. Redirect On Login Success
Let's start to work on the first milestone:
Redirect to
/admin/dashboard
after a successful login attempt to show aWelcome <username>!
greeting message;
We can encode the requirements in an integration test:
//! 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 request.")
.text()
.await
.unwrap()
}
}
The test should fail:
---- login::redirect_to_admin_dashboard_after_login_success stdout ----
thread 'login::redirect_to_admin_dashboard_after_login_success' panicked at
'assertion failed: `(left == right)`
left: `"/"`,
right: `"/admin/dashboard"`'
Getting past the first assertion is easy enough - we just need to change the Location
header in the response returned by POST /login
:
//! 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())
}
// [...]
}
}
The test will now fail on the second assertion:
---- login::redirect_to_admin_dashboard_after_login_success stdout ----
thread 'login::redirect_to_admin_dashboard_after_login_success' panicked at
'assertion failed: html_page.contains(...)',
Time to put those sessions to work.
3.5.2. Session
We need to identify the user once it lands on GET /admin/dashboard
after following the redirect returned by POST /login
- this is a perfect usecase for sessions.
We will store the user identifier into the session state in login
and then retrieve it from the session state in admin_dashboard
.
We need to become familiar with Session
, the second key type from actix_session
.
SessionMiddleware
does all the heavy lifting of checking for a session cookie in incoming requests - if it finds one, it loads the corresponding session state from the chosen storage backend. Otherwise, it creates a new empty session state.
We can then use Session
as an extractor to interact with that state in our request handlers.
Let's see it in action in POST /login
:
//! src/routes/login/post.rs
use actix_session::Session;
// [...]
#[tracing::instrument(
skip(form, pool, session),
// [...]
)]
pub async fn login(
// [...]
session: Session,
) -> Result</* */> {
// [...]
match validate_credentials(/* */).await {
Ok(user_id) => {
// [...]
session.insert("user_id", user_id);
Ok(HttpResponse::SeeOther()
.insert_header((LOCATION, "/admin/dashboard"))
.finish())
}
// [...]
}
}
#! Cargo.toml
# [...]
[dependencies]
# We need to add the `serde` feature
uuid = { version = "1", features = ["v4", "serde"] }
You can think of Session
as a handle on a HashMap
- you can insert and retrieve values against String
keys.
The values you pass in must be serializable - actix-session
converts them into JSON behind the scenes. That's why we had to add the serde
feature to our uuid
dependency.
Serialisation implies the possibility of failure - if you run cargo check
you will see that the compiler warns us that we are not handling the Result
returned by session.insert
. Let's take care of that:
//! src/routes/login/post.rs
// [...]
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(/* */).await {
Ok(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)
}
If something goes wrong, the user will be redirected back to the /login
page with an appropriate error message.
What does Session::insert
actually do, though?
All operations performed against Session
are executed in memory - they do not affect the state of the session as seen by the storage backend. After the handler returns a response, SessionMiddleware
will inspect the in-memory state of Session
- if it changed, it will call Redis to update (or create) the state. It will also take care of setting a session cookie on the client, if there wasn't one already.
Does it work though? Let's try to get the user_id
on the other side!
//! src/routes/admin/dashboard.rs
use actix_session::Session;
use actix_web::{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())
}
When using Session::get
we must specify what type we want to deserialize the session state entry into - a Uuid
in our case. Deserialization may fail, so we must handle the error case.
Now that we have the user_id
, we can use it to fetch the username and return the "Welcome {username}!" message we talked about before.
//! src/routes/admin/dashboard.rs
// [...]
use actix_web::http::header::ContentType;
use actix_web::web;
use anyhow::Context;
use sqlx::PgPool;
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)
}
Our integration test should pass now!
Stay there though, we are not finished yet - as it stands, our login flow is potentially vulnerable to session fixation attacks.
Sessions can be used for more than authentication - e.g. to keep track of what items have been added to the basket when shopping in "guest" mode. This implies that a user might be associated to an anonymous session and, after they authenticate, to a privileged session. This can be leveraged by attackers.
Websites go to great lengths to prevent malicious actors from sniffing session tokens, leading to another attack strategy - seed the user's browser with a known session token before they log in, wait for authentication to happen and, boom, you are in!
There is a simple countermeasure we can take to disrupt this attack - rotating the session token when the user logs in.
This is such a common practice that you will find it supported in the session management API of all major web frameworks - including actix-session
, via Session::renew
. Let's add it in:
//! 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())))?;
// [...]
}
// [...]
}
}
Now we can sleep better.
3.5.3. A Typed Interface To Session
Session
is powerful but, taken as is, it is a brittle foundation to build your application state-handling on. We are accessing data using a string-based API, being careful to use the same keys and types on both sides - insertion and retrieval. It works when the state is very simple, but it quickly degrades into a mess if you have several routes accessing the same data - how can you be sure that you updated all of them when you want to evolve the schema? How do we prevent a key typo from causing a production outage?
Tests can help, but we can use the type system to make the problem go away entirely. We will build a strongly-typed API on top of Session
to access and modify the state - no more string keys and type casting in our request handlers.
Session
is a foreign type (defined in actix-session
) therefore we must use the extension trait pattern:
//! 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<(), serde_json::Error> {
self.0.insert(Self::USER_ID_KEY, user_id)
}
pub fn get_user_id(&self) -> Result<Option<Uuid>, serde_json::Error> {
self.0.get(Self::USER_ID_KEY)
}
}
#! Cargo.toml
# [...]
[dependencies]
serde_json = "1"
# [...]
How will the request handlers build an instance of TypedSession
?
We could provide a constructor that takes a Session
as argument. Another option is to make TypedSession
itself an actix-web
extractor - let's try that out!
//! 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())))
}
}
It is just three lines long, but it does probably expose you to a few new Rust concepts/constructs. Take the time you need to go line by line and properly understand what is happening - or, if you prefer, understand the gist and come back later to deep dive!
We can now swap Session
for TypedSession
in our request handlers:
//! src/routes/login/post.rs
// You can now remove the `Session` import
use crate::session_state::TypedSession;
// [...]
#[tracing::instrument(/* */)]
pub async fn login(
// [...]
// Changed from `Session` to `TypedSession`!
session: TypedSession,
) -> Result</* */> {
// [...]
match validate_credentials(/* */).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
use crate::session_state::TypedSession;
// [...]
pub async fn admin_dashboard(
// Changed from `Session` to `TypedSession`!
session: TypedSession,
// [...]
) -> Result</* */> {
let username = if let Some(user_id) = session.get_user_id().map_err(e500)? {
// [...]
} else {
todo!()
};
// [...]
}
The test suite should stay green.
3.5.4. Reject Unauthenticated Users
We can now take care of the second milestone:
If a user tries to navigate directly to
/admin/dashboard
and they are not logged in, they will be redirected to the login form.
Let's encode the requirements in an integration test, as usual:
//! tests/api/main.rs
mod admin_dashboard;
// [...]
//! tests/api/admin_dashboard.rs
use crate::helpers::{spawn_app, assert_is_redirect_to};
#[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_html().await;
// Assert
assert_is_redirect_to(&response, "/login");
}
//! 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 request.")
}
pub async fn get_admin_dashboard_html(&self) -> String {
self.get_admin_dashboard().await.text().await.unwrap()
}
}
The test should fail - the handler panics.
We can fix it by fleshing out that 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());
};
// [...]
}
The test will pass now.
4. Seed Users
Everything looks great - in our test suite.
We have not done any exploratory testing for the most recent functionality - we stopped messing around in the browser more or less at the same time we started to work on the happy path. It is not a coincidence - we currently cannot exercise the happy path!
There is no user in the database and we do not have a sign up flow for admins - the implicit expectation has been that the application owner would become the first admin of the newsletter somehow11
It is time to make that a reality.
We will create a seed user - i.e. add a migration that creates a user into a database when the application is deployed for the first time. The seed user will have a pre-determined username and password12; they will then be able to change their password after they log in for the first time.
4.1. Database Migration
Let's create a new migration using sqlx
:
sqlx migrate add seed_user
We need to insert a new row into the users
table. We need:
- a user id (UUID);
- a username;
- a PHC string.
Pick your favourite UUID generator to get a valid user id. We will use admin
as username.
Getting a PHC string is a bit more cumbersome - we will use everythinghastostartsomewhere
as a password, but how do we generate the corresponding PHC string?
We can cheat by leveraging the code we wrote in our test suite:
//! 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!` is a macro that prints and returns the value
// of an expression for quick and dirty debugging.
dbg!(&password_hash);
// [...]
}
}
This is just a temporary edit - it is then enough to run cargo test -- --nocapture
to get a well-formed PHC string for our migration script. Revert the changes once you have it.
The migration script will look like this:
--- 20211217223217_seed_user.sql
INSERT INTO users (user_id, username, password_hash)
VALUES (
'ddf8994f-d522-4659-8d02-c1d479057be6',
'admin',
'$argon2id$v=19$m=15000,t=2,p=1$OEx/rcq+3ts//WUDzGNl2g$Am8UFBA4w5NJEmAtquGvBmAlu92q/VQcaoL5AyJPfc8'
);
sqlx migrate run
Run the migration and then launch your application with cargo run
- you should finally be able to log in successfully!
If everything works as expected, a "Welcome admin!" message should greet you at /admin/dashboard
. Congrats!
4.2. Password Reset
Let's look at the current situation from another perspective - we just provisioned a highly privileged user with a known username/password combination. This is dangerous territory.
We need to give our seed user the possibility to change their password. It is going to be the first piece of functionality hosted on the admin dashboard!
No new concepts will be required to build this functionality - take this section as an opportunity to revise and make sure that you have a solid grasp on everything we covered so far!
4.2.1. Form Skeleton
Let's start by putting in place the required scaffolding. It is a form-based flow, just like the login one - we need a GET
endpoint to return the HTML form and a POST
endpoint to process the submitted information:
//! src/routes/admin/mod.rs
// [...]
mod password;
pub use password::*;
//! src/routes/admin/password/mod.rs
mod get;
pub use get::change_password_form;
mod post;
pub use post::change_password;
//! src/routes/admin/password/get.rs
use actix_web::http::header::ContentType;
use actix_web::HttpResponse;
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::Secret;
#[derive(serde::Deserialize)]
pub struct FormData {
current_password: Secret<String>,
new_password: Secret<String>,
new_password_check: Secret<String>,
}
pub async fn change_password(
form: web::Form<FormData>,
) -> Result<HttpResponse, actix_web::Error> {
todo!()
}
//! src/startup.rs
use crate::routes::{change_password, change_password_form};
// [...]
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))
// [...]
})
// [...]
}
Just like the admin dashboard itself, we do not want to show the change password form to users who are not logged in. Let's add two integration tests:
//! 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 to 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 crate::helpers::{spawn_app, assert_is_redirect_to};
use uuid::Uuid;
#[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_be_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");
}
We can then satisfy the requirements by adding a check in the request handlers13:
//! src/routes/admin/password/get.rs
use crate::session_state::TypedSession;
use crate::utils::{e500, see_other};
// [...]
pub async fn change_password_form(
session: TypedSession
) -> Result</* */> {
if session.get_user_id().map_err(e500)?.is_none() {
return Ok(see_other("/login"));
};
// [...]
}
//! src/routes/admin/password/post.rs
use crate::session_state::TypedSession;
use crate::utils::{e500, see_other};
// [...]
pub async fn change_password(
// [...]
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;
use actix_web::http::header::LOCATION;
// Return an opaque 500 while preserving the error root's 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 definition of e500 has been moved to src/utils.rs
use crate::utils::e500;
// [...]
We do not want the change password form to be an orphan page either - let's add a list of available actions to our admin dashboard, with a link to our new page:
//! 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>"#,
)))
}
4.2.2. Unhappy Path: New Passwords Do Not Match
We have taken care of all the preliminary steps, it is time to start working on the core functionality.
Let's start with an unhappy case - we asked the user to write the new password twice and the two entries do not match. We expect to be redirected back to the form with an appropriate error message.
//! 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 - Part 2 - 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()
}
}
The test fails because the request handler panics. Let's fix it:
//! src/routes/admin/password/post.rs
use secrecy::ExposeSecret;
// [...]
pub async fn change_password(/* */) -> Result</* */> {
// [...]
// `Secret<String>` 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!()
}
That takes care of the redirect, the first part of the test, but it does not handle the error message:
---- change_password::new_password_fields_must_match stdout ----
thread 'change_password::new_password_fields_must_match' panicked at
'assertion failed: html_page.contains(...)',
We have gone through this journey before for the login form - we can use a flash message again!
//! 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#"<!-- [...] -->
<body>
{msg_html}
<!-- [...] -->
</body>
</html>"#,
)))
}
The test should pass.
4.2.3. Unhappy Path: The Current Password Is Invalid
You might have noticed that we require the user to provide its current password as part of the form. This is to prevent an attacker who managed to acquire a valid session token from locking the legitimate user out of their account.
Let's add an integration test to specify what we expect to see when the provided current password is invalid:
//! 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>"
));
}
To validate the value passed as current_password
we need to retrieve the username and then invoke the validate_credentials
routine, the one powering our login form.
Let's start with the username:
//! 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>,
) -> Result<HttpResponse, actix_web::Error> {
let user_id = session.get_user_id().map_err(e500)?;
if user_id.is_none() {
return Ok(see_other("/login"));
};
let user_id = user_id.unwrap();
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</* */> {
// [...]
}
We can now pass the username and password combination to validate_credentials
- if the validation fails, we need to take different actions depending on the returned error:
//! src/routes/admin/password/post.rs
// [...]
use crate::authentication::{validate_credentials, AuthError, Credentials};
pub async fn change_password(/* */) -> Result</* */> {
// [...]
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!()
}
The test should pass.
4.2.4. Unhappy Path: The New Password Is Too Short
We do not want our users to choose a weak password - it exposes their account to attackers.
OWASP's provides a minimum set of requirements when it comes to password strength - passwords should be longer than 12 characters but shorter than 128 characters.
Add these validation checks to our POST /admin/password
endpoint as an exercise!
4.2.5. Logout
It is finally time to look at the happy path - a user successfully changing their password.
We will use the following scenario to check that everything behaves as expected:
- Log in;
- Change password by submitting the change password form;
- Log out;
- Log in again using the new password.
There is just one roadblock left - we do not have a log-out endpoint yet!
Let's work to bridge this functionality gap before moving forward.
Let's start by encoding our requirements in a test:
//! tests/api/admin_dashboard.rs
// [...]
#[tokio::test]
async fn logout_clears_session_state() {
// 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_html().await;
assert!(html_page.contains(&format!("Welcome {}", app.test_user.username)));
// Act - Part 3 - Logout
let response = app.post_logout().await;
assert_is_redirect_to(&response, "/login");
// Act - Part 4 - Follow the redirect
let html_page = app.get_login_html().await;
assert!(html_page.contains(r#"<p><i>You have successfully logged out.</i></p>"#));
// Act - Part 5 - Attempt to load admin panel
let response = app.get_admin_dashboard().await;
assert_is_redirect_to(&response, "/login");
}
//! tests/api/helpers.rs
// [...]
impl TestApp {
// [...]
pub async fn post_logout(&self) -> reqwest::Response {
self.api_client
.post(&format!("{}/admin/logout", &self.address))
.send()
.await
.expect("Failed to execute request.")
}
}
A log-out is a state-alerting operation: we need to use the POST
method via a HTML button:
//! src/routes/admin/dashboard.rs
// [...]
pub async fn admin_dashboard(/* */) -> Result</* */> {
// [...]
Ok(HttpResponse::Ok()
.content_type(ContentType::html())
.body(format!(
r#"<!-- [...] -->
<p>Available actions:</p>
<ol>
<li><a href="/admin/password">Change password</a></li>
<li>
<form name="logoutForm" action="/admin/logout" method="post">
<input type="submit" value="Logout">
</form>
</li>
</ol>
<!-- [...] -->"#,
)))
}
We now need to add the corresponding POST /admin/logout
request handler.
What does it actually mean to log out?
We are using session-based authentication - a user is "logged in" if there is a valid user id associated with the user_id
key in the session state. To log out it is enough to delete the session - remove the state from the storage backend and unset the client-side cookie.
actix-session
has a dedicated method for this purpose - Session::purge
. We need to expose it in our TypedSession
abstraction and then call it in POST /logout
's request handler:
//! src/session_state.rs
// [...]
impl TypedSession {
// [...]
pub fn log_out(self) {
self.0.purge()
}
}
//! src/routes/admin/logout.rs
use crate::session_state::TypedSession;
use crate::utils::{e500, see_other};
use actix_web::HttpResponse;
use actix_web_flash_messages::FlashMessage;
pub async fn log_out(session: TypedSession) -> Result<HttpResponse, actix_web::Error> {
if session.get_user_id().map_err(e500)?.is_none() {
Ok(see_other("/login"))
} else {
session.log_out();
FlashMessage::info("You have successfully logged out.").send();
Ok(see_other("/login"))
}
}
//! src/routes/login/get.rs
// [...]
pub async fn login_form(/* */) -> HttpResponse {
// [...]
// Display all messages levels, not just errors!
for m in flash_messages.iter() {
// [...]
}
// [...]
}
//! src/routes/admin/mod.rs
// [...]
mod logout;
pub use logout::log_out;
//! src/startup.rs
use crate::routes::log_out;
// [...]
async fn run(/* */) -> Result<Server, anyhow::Error> {
// [...]
let server = HttpServer::new(move || {
App::new()
// [...]
.route("/admin/logout", web::post().to(log_out))
// [...]
})
// [...]
}
4.2.6. Happy Path: The Password Was Changed Successfully
We can now get back to the happy path scenario in our change password flow:
- Log in;
- Change password by submitting the change password form;
- Log out;
- Log in again, successfully, using the new password.
Let's add an integration test:
//! tests/api/change_password.rs
// [...]
#[tokio::test]
async fn changing_password_works() {
// Arrange
let app = spawn_app().await;
let new_password = Uuid::new_v4().to_string();
// 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 - Change password
let response = app
.post_change_password(&serde_json::json!({
"current_password": &app.test_user.password,
"new_password": &new_password,
"new_password_check": &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>Your password has been changed.</i></p>"));
// Act - Part 4 - Logout
let response = app.post_logout().await;
assert_is_redirect_to(&response, "/login");
// Act - Part 5 - Follow the redirect
let html_page = app.get_login_html().await;
assert!(html_page.contains("<p><i>You have successfully logged out.</i></p>"));
// Act - Part 6 - Login using the new password
let login_body = serde_json::json!({
"username": &app.test_user.username,
"password": &new_password
});
let response = app.post_login(&login_body).await;
assert_is_redirect_to(&response, "/admin/dashboard");
}
This is the most complex user scenario we have written so far - a grand total of six steps. This is far from being a record - enterprise applications often require tens of steps to execute real world business processes. It takes a lot of work to keep the test suite readable and maintainable in those scenarios.
The test currently fails at the third step - POST /admin/password
panics because we left a todo!()
invocation after the preliminary input validation steps. To implement the required functionality we will need to compute the hash of the new password and then store it in the database - we can add a new dedicated routine to our authentication
module:
//! src/authentication.rs
use argon2::password_hash::SaltString;
use argon2::{
Algorithm, Argon2, Params, PasswordHash,
PasswordHasher, PasswordVerifier, Version
};
// [...]
#[tracing::instrument(name = "Change password", skip(password, pool))]
pub async fn change_password(
user_id: uuid::Uuid,
password: Secret<String>,
pool: &PgPool,
) -> Result<(), anyhow::Error> {
let password_hash = spawn_blocking_with_tracing(
move || compute_password_hash(password)
)
.await?
.context("Failed to hash password")?;
sqlx::query!(
r#"
UPDATE users
SET password_hash = $1
WHERE user_id = $2
"#,
password_hash.expose_secret(),
user_id
)
.execute(pool)
.await
.context("Failed to change user's password in the database.")?;
Ok(())
}
fn compute_password_hash(
password: Secret<String>
) -> Result<Secret<String>, anyhow::Error> {
let salt = SaltString::generate(&mut rand::thread_rng());
let password_hash = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
Params::new(15000, 2, 1, None).unwrap(),
)
.hash_password(password.expose_secret().as_bytes(), &salt)?
.to_string();
Ok(Secret::new(password_hash))
}
For Argon2
we used the parameters recommended by OWASP, the same ones we were already using in our test suite.
We can now plug this function into the request handler:
//! src/routes/admin/password/post.rs
// [...]
pub async fn change_password(/* */) -> Result</* */> {
// [...]
crate::authentication::change_password(user_id, form.0.new_password, &pool)
.await
.map_err(e500)?;
FlashMessage::error("Your password has been changed.").send();
Ok(see_other("/admin/password"))
}
The test should now pass.
5. Refactoring
We have added many new endpoints that are restricted to authenticated users. For the sake of speed, we have copy-pasted the same authentication logic across multiple request handlers - it is a good idea to take a step back and try to figure out if we can come up with a better solution.
Let's look at POST /admin/passwords
as an example. We currently have:
//! src/routes/admin/password/post.rs
// [...]
pub async fn change_password(/* */) -> Result<HttpResponse, actix_web::Error> {
let user_id = session.get_user_id().map_err(e500)?;
if user_id.is_none() {
return Ok(see_other("/login"));
};
let user_id = user_id.unwrap();
// [...]
}
We can factor it out as a new reject_anonymous_users
function:
//! src/routes/admin/password/post.rs
use actix_web::error::InternalError;
use uuid::Uuid;
// [...]
async fn reject_anonymous_users(
session: TypedSession
) -> Result<Uuid, actix_web::Error> {
match session.get_user_id().map_err(e500)? {
Some(user_id) => Ok(user_id),
None => {
let response = see_other("/login");
let e = anyhow::anyhow!("The user has not logged in");
Err(InternalError::from_response(e, response).into())
}
}
}
pub async fn change_password(/* */) -> Result<HttpResponse, actix_web::Error> {
let user_id = reject_anonymous_users(session).await?;
// [...]
}
Notice how we moved the redirect response on the error path in order to use the ?
operator in our request handler.
We could now go and refactor all other /admin/*
routes to leverage reject_anonymous_users
. Or, if you are feeling adventurous, we could try writing a middleware to handle this for us - let's do it!
5.1. How To Write An actix-web
Middleware
Writing a full-blown middleware in actix-web
can be challenging - it requires us to understand their Transform
and Service
traits.
Those abstractions are powerful, but power comes at the cost of complexity.
Our needs are quite simple, we can get away with less: actix_web_lab::from_fn
.
actix_web_lab
is a crate used to experiment with future additions to the actix_web
framework, with a faster release policy. Let's add it to our dependencies:
#! Cargo.toml
# [...]
[dependencies]
actix-web-lab = "0.16"
# [...]
from_fn
takes an asynchronous function as argument and returns an actix-web
middleware as output. The asynchronous function must have the following signature and structure:
use actix_web_lab::middleware::Next;
use actix_web::body::MessageBody;
use actix_web::dev::{ServiceRequest, ServiceResponse};
async fn my_middleware(
req: ServiceRequest,
next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, Error> {
// before the handler is invoked
// Invoke handler
let response = next.call(req).await;
// after the handler was invoked
}
Let's adapt reject_anonymous_users
to follow those requirements - it will live in our authentication module.
//! src/authentication/mod.rs
mod middleware;
mod password;
pub use password::{
change_password, validate_credentials,
AuthError, Credentials
};
pub use middleware::reject_anonymous_users;
//! src/authentication/password.rs
// Copy over **everything** from the old src/authentication.rs
This will be our empty canvas:
//! src/authentication/middleware.rs
use actix_web_lab::middleware::Next;
use actix_web::body::MessageBody;
use actix_web::dev::{ServiceRequest, ServiceResponse};
pub async fn reject_anonymous_users(
mut req: ServiceRequest,
next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, actix_web::Error> {
todo!()
}
To start out, we need to get our hands on a TypedSession
instance. ServiceRequest
is nothing more than a wrapper around HttpRequest
and Payload
, therefore we can leverage our existing implementation of FromRequest
:
//! src/authentication/middleware.rs
use actix_web_lab::middleware::Next;
use actix_web::body::MessageBody;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::FromRequest;
use crate::session_state::TypedSession;
pub async fn reject_anonymous_users(
mut req: ServiceRequest,
next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, actix_web::Error> {
let session = {
let (http_request, payload) = req.parts_mut();
TypedSession::from_request(http_request, payload).await
}?;
todo!()
}
Now that we have the session handler, we can check if the session state contains a user id:
//! src/authentication/middleware.rs
use actix_web::error::InternalError;
use crate::utils::{e500, see_other};
// [...]
pub async fn reject_anonymous_users(
mut req: ServiceRequest,
next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, actix_web::Error> {
let session = {
let (http_request, payload) = req.parts_mut();
TypedSession::from_request(http_request, payload).await
}?;
match session.get_user_id().map_err(e500)? {
Some(_) => next.call(req).await,
None => {
let response = see_other("/login");
let e = anyhow::anyhow!("The user has not logged in");
Err(InternalError::from_response(e, response).into())
}
}
}
This, as it stands, is already useful - it can be leveraged to protect endpoints that require authentication.
At the same time, it isn't equivalent to what we had before - how are we going to access the retrieved user id in our endpoints?
This is a common issue when working with middlewares that extract information out of incoming requests - it is solved via request extensions.
The middleware inserts the information it wants to pass to downstream request handlers into the type map attached to the incoming request (request.extensions_mut()
).
Request handlers can then access it using the ReqData
extractor.
Let's start by performing the insertion.
We will define a new-type wrapper, UserId
, to prevent conflicts in the type map:
//! src/authentication/mod.rs
// [...]
pub use middleware::UserId;
//! src/authentication/middleware.rs
use uuid::Uuid;
use std::ops::Deref;
use actix_web::HttpMessage;
// [...]
#[derive(Copy, Clone, Debug)]
pub struct UserId(Uuid);
impl std::fmt::Display for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl Deref for UserId {
type Target = Uuid;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub async fn reject_anonymous_users(/* */) -> Result</* */> {
// [...]
match session.get_user_id().map_err(e500)? {
Some(user_id) => {
req.extensions_mut().insert(UserId(user_id));
next.call(req).await
}
None => // [...]
}
}
We can now access it in change_password
:
//! src/routes/admin/password/post.rs
use crate::authentication::UserId;
// [...]
pub async fn change_password(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
// No longer injecting TypedSession!
user_id: web::ReqData<UserId>,
) -> Result<HttpResponse, actix_web::Error> {
let user_id = user_id.into_inner();
// [...]
let username = get_username(*user_id, &pool).await.map_err(e500)?;
// [...]
crate::authentication::change_password(*user_id, form.0.new_password, &pool)
.await
.map_err(e500)?;
// [...]
}
If you run the test suite, you'll be greeted by several failures. If you inspect the logs for one of them, you'll find the following error:
Error encountered while processing the incoming HTTP request:
"Missing expected request extension data"
It makes sense - we never registered our middleware against our App
instance, therefore the insertion of UserId
into the request extensions never takes place.
Let's fix it.
Our routing table currently looks like this:
//! src/startup.rs
// [...]
async fn run(/* */) -> Result<Server, anyhow::Error> {
// [...]
let server = HttpServer::new(move || {
App::new()
.wrap(message_framework.clone())
.wrap(SessionMiddleware::new(
redis_store.clone(),
secret_key.clone(),
))
.wrap(TracingLogger::default())
.route("/", web::get().to(home))
.route("/login", web::get().to(login_form))
.route("/login", web::post().to(login))
.route("/health_check", web::get().to(health_check))
.route("/subscriptions", web::post().to(subscribe))
.route("/subscriptions/confirm", web::get().to(confirm))
.route("/admin/dashboard", web::get().to(admin_dashboard))
.route("/newsletters", web::post().to(publish_newsletter))
.route("/admin/password", web::get().to(change_password_form))
.route("/admin/password", web::post().to(change_password))
.route("/admin/logout", web::post().to(log_out))
.app_data(db_pool.clone())
.app_data(email_client.clone())
.app_data(base_url.clone())
})
.listen(listener)?
.run();
Ok(server)
}
We want to apply our middleware logic exclusively to /admin/*
endpoints, but calling wrap
on App
would apply the middleware to all our routes.
Considering that our target endpoints all share the same common base path, we can achieve our objective by introducing a scope:
//! src/startup.rs
// [...]
async fn run(/* */) -> Result<Server, anyhow::Error> {
// [...]
let server = HttpServer::new(move || {
App::new()
.wrap(message_framework.clone())
.wrap(SessionMiddleware::new(
redis_store.clone(),
secret_key.clone(),
))
.wrap(TracingLogger::default())
.route("/", web::get().to(home))
.route("/login", web::get().to(login_form))
.route("/login", web::post().to(login))
.route("/health_check", web::get().to(health_check))
.route("/newsletters", web::post().to(publish_newsletter))
.route("/subscriptions", web::post().to(subscribe))
.route("/subscriptions/confirm", web::get().to(confirm))
.service(
web::scope("/admin")
.route("/dashboard", web::get().to(admin_dashboard))
.route("/password", web::get().to(change_password_form))
.route("/password", web::post().to(change_password))
.route("/logout", web::post().to(log_out)),
)
.app_data(db_pool.clone())
.app_data(email_client.clone())
.app_data(base_url.clone())
})
.listen(listener)?
.run();
Ok(server)
}
We can now add a middleware restricted to /admin/*
by calling wrap
on web::scope("admin")
instead of the top-level App
:
//! src/startup.rs
use crate::authentication::reject_anonymous_users;
use actix_web_lab::middleware::from_fn;
// [...]
async fn run(/* */) -> Result<Server, anyhow::Error> {
// [...]
let server = HttpServer::new(move || {
App::new()
.wrap(message_framework.clone())
.wrap(SessionMiddleware::new(
redis_store.clone(),
secret_key.clone(),
))
.wrap(TracingLogger::default())
// [...]
.service(
web::scope("/admin")
.wrap(from_fn(reject_anonymous_users))
.route("/dashboard", web::get().to(admin_dashboard))
.route("/password", web::get().to(change_password_form))
.route("/password", web::post().to(change_password))
.route("/logout", web::post().to(log_out)),
)
// [...]
})
// [...]
}
If you run the test suite, it should pass (apart from our idempotency test).
You can now go through the other /admin/*
endpoints and remove the duplicated check-if-logged-in-or-redirect code.
6. Summary
Take a deep breath - we covered a lot of ground in this chapter.
We built, from scratch, a large chunk of the machinery that powers authentication in most of the software you interact with on a daily basis.
API security is an amazingly broad topic - we explored together a selection of key techniques, but this introduction is in no way exhaustive. There are entire areas that we just mentioned but did not have a chance to cover in depth (e.g. OAuth2/OpenID Connect). Look at the bright side - you now learned enough to go and tackle those topics on your own should your applications require them.
It is easy to forget the bigger picture when you spend a lot of time working close to the details - why did we even start to talk about API security?
That's right! We had just built a new endpoint to send out newsletter issues and we did not want to give everyone on the Internet a chance to broadcast content to our audience. We added 'Basic' authentication to POST /newsletters
early in the chapter but we have not yet ported it over to session-based authentication.
As an exercise, before engaging with the new chapter, do the following:
- Add a
Send a newsletter issue
link to the admin dashboard; - Add an HTML form at
GET /admin/newsletters
to submit a new issue; - Adapt
POST /newsletters
to process the form data:- Change the route to
POST /admin/newsletters
; - Migrate from 'Basic' to session-based authentication;
- Use the
Form
extractor (application/x-www-form-urlencoded
) instead of theJson
extractor (application/json
) to handle the request body; - Adapt the test suite.
- Change the route to
It will take a bit of work but - and that's the key here - you know how to do all these things. We have done them together before - feel free to go back to the relevant sections as you progress through the exercise.
On GitHub you can find a project snapshot before and after fulfilling the exercise requirements. The next chapter assumes that the exercise has been completed - make sure to double-check your solution before moving forward!
POST /admin/newsletters
will be under the spotlight during the next chapter - we will be reviewing our initial implementation under a microscope to understand how it behaves when things break down. It will give us a chance to talk more broadly about fault tolerance, scalability and asynchronous processing.
This article is a sample from Zero To Production In Rust, a hands-on introduction to backend development in Rust.
You can get a copy of the book at zero2prod.com.
7. Footnotes
Click to expand!
An in-depth introduction to HTML and CSS is beyond the scope of this book. We will avoid CSS entirely while explaining the required basics of HTML as we introduce new elements to build the pages we need for our newsletter application. Check out Interneting is hard (but it doesn't have to be)
for an excellent introduction to these topics.
There is often confusion around 'static
due to its different meanings depending on the context. Check out this excellent piece on common Rust lifetime misconceptions if you want to learn more about the topic.
Throughout this chapter we will rely on the introspection tools made available by browsers. For Firefox, follow this guide. For Google Chrome, follow this guide.
This might not truly be the case though - mold
is the newest linker on the block and it looks even faster than lld
! It feels a bit early, so we will not be using it as our default linker, but consider checking it out.
It begs the question of why GET
was chosen as default method
, considering it is strictly less secure. We also do not see any warnings in the browser's console, even though we are obviously transmitting sensitive data in clear text via query parameters (a field with type password
, form using GET
as method
).
The default implementation of error_response
provided by actix_web
's ResponseError
trait populates the body using the Display
representation of the error returned by the request handler.
Our web pages are not particularly dynamic - we are mostly looking at the injection of a few elements, format!
does the job without breaking a sweat. The same approach does not scale very well when working on more complex user interfaces - you will need to build reusable components to be shared across multiple pages while performing loops and conditionals on many different pieces of dynamic data. Template engines are a common approach to handle this new level of complexity - tera
and askama
are popular options in the Rust ecosystem.
An attack known as "cookie jar overflow" can be used to delete pre-existing Http-Only
cookies. The cookies can then be overwritten with a value set by the malicious script.
Full disclosure: I am the author of actix-web-flash-messages
.
Naming can be quite confusing - we are using the terms session token/session cookie to refer to the client-side cookie associated to a user session. Later in this chapter, we will talk about the lifecycle of cookies, where session cookie refers to a cookie whose lifetime is tied to a browser session. I'd love to change the naming to be clearer, but this ambiguity is now part of the industry terminology and there is no point in shielding you.
A common example of poorly implemented sessions uses a monotonically increasing integer as session token - e.g. 6, 7, 8, etc. It is easy enough to "explore" nearby numbers by modifying the cookie stored in your browser until you manage to find another logged-in user - bingo, you are in! Not great.
The seed admin should then be able to invite more collaborators if they wish to do so. You could implement this login-protected functionality as an exercise! Look at the subscription flow for inspiration.
In a more advanced scenario, the username and the password of the seed user could be configured by the application operator when they trigger the first deployment of the newsletter - e.g. they could be prompted to provide both by a command-line application used to provide a streamlined installation process.
An alternative approach, to spare us the repetition, is to create a middleware that wraps all the endpoints nested under the /admin/
prefix. The middleware checks the session state and redirects the visitor to /login
if they are not logged in. If you like a challenge, give it a try! Beware though: actix-web
's middlewares can be tricky to implement due to the lack of async syntax in traits.
Book - Table Of Contents
Click to expand!
The Table of Contents is provisional and might change over time. The draft below is the most accurate picture at this point in time.
- Getting Started
- Installing The Rust Toolchain
- Project Setup
- IDEs
- Continuous Integration
- Our Driving Example
- What Should Our Newsletter Do?
- Working In Iterations
- Sign Up A New Subscriber
- Telemetry
- Unknown Unknowns
- Observability
- Logging
- Instrumenting /POST subscriptions
- Structured Logging
- Go Live
- We Must Talk About Deployments
- Choosing Our Tools
- A Dockerfile For Our Application
- Deploy To DigitalOcean Apps Platform
- Rejecting Invalid Subscribers #1
- Requirements
- First Implementation
- Validation Is A Leaky Cauldron
- Type-Driven Development
- Ownership Meets Invariants
- Panics
- Error As Values -
Result
- Reject Invalid Subscribers #2
- Error Handling
- What Is The Purpose Of Errors?
- Error Reporting For Operators
- Errors For Control Flow
- Avoid "Ball Of Mud" Error Enums
- Who Should Log Errors?
- Naive Newsletter Delivery
- User Stories Are Not Set In Stone
- Do Not Spam Unconfirmed Subscribers
- All Confirmed Subscribers Receive New Issues
- Implementation Strategy
- Body Schema
- Fetch Confirmed Subscribers List
- Send Newsletter Emails
- Validation Of Stored Data
- Limitations Of The Naive Approach
- Securing Our API
- Fault-tolerant Newsletter Delivery