mirror of
https://github.com/LukeMathWalker/zero-to-production.git
synced 2024-11-23 09:11:01 +00:00
Last part of chapter 10 - sessions, seed users and change password form
This commit is contained in:
parent
2b6c2c5bc0
commit
df6bdea82b
28 changed files with 1433 additions and 94 deletions
716
Cargo.lock
generated
716
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
11
Cargo.toml
11
Cargo.toml
|
@ -18,9 +18,9 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
|||
serde = "1.0.115"
|
||||
config = { version = "0.11", default-features = false, features = ["yaml"] }
|
||||
sqlx = { version = "0.5.5", default-features = false, features = [ "runtime-actix-rustls", "macros", "postgres", "uuid", "chrono", "migrate", "offline"] }
|
||||
uuid = { version = "0.8.1", features = ["v4"] }
|
||||
uuid = { version = "0.8.1", features = ["v4", "serde"] }
|
||||
chrono = "0.4.15"
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls", "cookies"] }
|
||||
log = "0.4"
|
||||
tracing = "0.1.19"
|
||||
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
|
||||
|
@ -38,9 +38,10 @@ tracing-actix-web = "0.5"
|
|||
secrecy = { version = "0.8", features = ["serde"] }
|
||||
urlencoding = "2"
|
||||
htmlescape = "0.3"
|
||||
hmac = { version = "0.12", features = ["std"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
actix-web-flash-messages = { version = "0.3", features = ["cookies"] }
|
||||
actix-session = { git = "https://github.com/actix/actix-extras", branch = "master", features = ["redis-rs-tls-session"] }
|
||||
serde_json = "1"
|
||||
actix-web-lab = "0.15"
|
||||
|
||||
[dev-dependencies]
|
||||
once_cell = "1.7.2"
|
||||
|
|
|
@ -14,3 +14,4 @@ email_client:
|
|||
sender_email: "test@gmail.com"
|
||||
authorization_token: "my-secret-token"
|
||||
timeout_milliseconds: 10000
|
||||
redis_uri: "redis://127.0.0.1:6379"
|
6
migrations/20220312175058_seed_user.sql
Normal file
6
migrations/20220312175058_seed_user.sql
Normal file
|
@ -0,0 +1,6 @@
|
|||
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'
|
||||
);
|
20
scripts/init_redis.sh
Executable file
20
scripts/init_redis.sh
Executable file
|
@ -0,0 +1,20 @@
|
|||
#!/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!"
|
48
src/authentication/middleware.rs
Normal file
48
src/authentication/middleware.rs
Normal file
|
@ -0,0 +1,48 @@
|
|||
use crate::session_state::TypedSession;
|
||||
use crate::utils::{e500, see_other};
|
||||
use actix_web::body::MessageBody;
|
||||
use actix_web::dev::{ServiceRequest, ServiceResponse};
|
||||
use actix_web::error::InternalError;
|
||||
use actix_web::{FromRequest, HttpMessage};
|
||||
use actix_web_lab::middleware::Next;
|
||||
use std::ops::Deref;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[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(
|
||||
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(user_id) => {
|
||||
req.extensions_mut().insert(UserId(user_id));
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
5
src/authentication/mod.rs
Normal file
5
src/authentication/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod middleware;
|
||||
mod password;
|
||||
pub use middleware::reject_anonymous_users;
|
||||
pub use middleware::UserId;
|
||||
pub use password::{change_password, validate_credentials, AuthError, Credentials};
|
|
@ -1,10 +1,10 @@
|
|||
use crate::telemetry::spawn_blocking_with_tracing;
|
||||
use anyhow::Context;
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version};
|
||||
use secrecy::{ExposeSecret, Secret};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::telemetry::spawn_blocking_with_tracing;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum AuthError {
|
||||
#[error("Invalid credentials.")]
|
||||
|
@ -88,3 +88,39 @@ fn verify_password_hash(
|
|||
.context("Invalid password.")
|
||||
.map_err(AuthError::InvalidCredentials)
|
||||
}
|
||||
|
||||
#[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))
|
||||
}
|
|
@ -10,6 +10,7 @@ pub struct Settings {
|
|||
pub database: DatabaseSettings,
|
||||
pub application: ApplicationSettings,
|
||||
pub email_client: EmailClientSettings,
|
||||
pub redis_uri: Secret<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
|
|
|
@ -3,5 +3,7 @@ pub mod configuration;
|
|||
pub mod domain;
|
||||
pub mod email_client;
|
||||
pub mod routes;
|
||||
pub mod session_state;
|
||||
pub mod startup;
|
||||
pub mod telemetry;
|
||||
pub mod utils;
|
||||
|
|
|
@ -3,7 +3,7 @@ use zero2prod::startup::Application;
|
|||
use zero2prod::telemetry::{get_subscriber, init_subscriber};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout);
|
||||
init_subscriber(subscriber);
|
||||
|
||||
|
|
60
src/routes/admin/dashboard.rs
Normal file
60
src/routes/admin/dashboard.rs
Normal file
|
@ -0,0 +1,60 @@
|
|||
use crate::session_state::TypedSession;
|
||||
use crate::utils::e500;
|
||||
use actix_web::http::header::LOCATION;
|
||||
use actix_web::{http::header::ContentType, web, HttpResponse};
|
||||
use anyhow::Context;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
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)? {
|
||||
get_username(user_id, &pool).await.map_err(e500)?
|
||||
} else {
|
||||
return Ok(HttpResponse::SeeOther()
|
||||
.insert_header((LOCATION, "/login"))
|
||||
.finish());
|
||||
};
|
||||
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>
|
||||
<li>
|
||||
<a href="javascript:document.logoutForm.submit()">Logout</a>
|
||||
<form name="logoutForm" action="/admin/logout" method="post" hidden>
|
||||
<input hidden type="submit" value="Logout">
|
||||
</form>
|
||||
</li>
|
||||
</ol>
|
||||
</body>
|
||||
</html>"#,
|
||||
)))
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "Get username", skip(pool))]
|
||||
pub 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)
|
||||
}
|
14
src/routes/admin/logout.rs
Normal file
14
src/routes/admin/logout.rs
Normal file
|
@ -0,0 +1,14 @@
|
|||
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"))
|
||||
}
|
||||
}
|
7
src/routes/admin/mod.rs
Normal file
7
src/routes/admin/mod.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
mod dashboard;
|
||||
mod logout;
|
||||
mod password;
|
||||
|
||||
pub use dashboard::admin_dashboard;
|
||||
pub use logout::log_out;
|
||||
pub use password::*;
|
61
src/routes/admin/password/get.rs
Normal file
61
src/routes/admin/password/get.rs
Normal file
|
@ -0,0 +1,61 @@
|
|||
use crate::session_state::TypedSession;
|
||||
use crate::utils::{e500, see_other};
|
||||
use actix_web::http::header::ContentType;
|
||||
use actix_web::HttpResponse;
|
||||
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> {
|
||||
if session.get_user_id().map_err(e500)?.is_none() {
|
||||
return Ok(see_other("/login"));
|
||||
};
|
||||
let mut msg_html = String::new();
|
||||
for m in flash_messages.iter() {
|
||||
writeln!(msg_html, "<p><i>{}</i></p>", m.content()).unwrap();
|
||||
}
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type(ContentType::html())
|
||||
.body(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=utf-8">
|
||||
<title>Change Password</title>
|
||||
</head>
|
||||
<body>
|
||||
{msg_html}
|
||||
<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>"#,
|
||||
)))
|
||||
}
|
4
src/routes/admin/password/mod.rs
Normal file
4
src/routes/admin/password/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
mod get;
|
||||
pub use get::change_password_form;
|
||||
mod post;
|
||||
pub use post::change_password;
|
48
src/routes/admin/password/post.rs
Normal file
48
src/routes/admin/password/post.rs
Normal file
|
@ -0,0 +1,48 @@
|
|||
use crate::authentication::{validate_credentials, AuthError, Credentials, UserId};
|
||||
use crate::routes::admin::dashboard::get_username;
|
||||
use crate::utils::{e500, see_other};
|
||||
use actix_web::{web, HttpResponse};
|
||||
use actix_web_flash_messages::FlashMessage;
|
||||
use secrecy::{ExposeSecret, Secret};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[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>,
|
||||
pool: web::Data<PgPool>,
|
||||
user_id: web::ReqData<UserId>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let user_id = user_id.into_inner();
|
||||
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();
|
||||
return Ok(see_other("/admin/password"));
|
||||
}
|
||||
let username = get_username(*user_id, &pool).await.map_err(e500)?;
|
||||
let credentials = Credentials {
|
||||
username,
|
||||
password: form.0.current_password,
|
||||
};
|
||||
if let Err(e) = validate_credentials(credentials, &pool).await {
|
||||
return match e {
|
||||
AuthError::InvalidCredentials(_) => {
|
||||
FlashMessage::error("The current password is incorrect.").send();
|
||||
Ok(see_other("/admin/password"))
|
||||
}
|
||||
AuthError::UnexpectedError(_) => Err(e500(e)),
|
||||
};
|
||||
}
|
||||
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"))
|
||||
}
|
|
@ -1,48 +1,12 @@
|
|||
use crate::startup::HmacSecret;
|
||||
use actix_web::{http::header::ContentType, web, HttpResponse};
|
||||
use hmac::{Hmac, Mac};
|
||||
use secrecy::ExposeSecret;
|
||||
use actix_web::{http::header::ContentType, HttpResponse};
|
||||
use actix_web_flash_messages::{IncomingFlashMessages};
|
||||
use std::fmt::Write;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct QueryParams {
|
||||
error: String,
|
||||
tag: String,
|
||||
}
|
||||
|
||||
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)
|
||||
pub async fn login_form(flash_messages: IncomingFlashMessages) -> HttpResponse {
|
||||
let mut error_html = String::new();
|
||||
for m in flash_messages.iter() {
|
||||
writeln!(error_html, "<p><i>{}</i></p>", m.content()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login_form(
|
||||
query: Option<web::Query<QueryParams>>,
|
||||
secret: web::Data<HmacSecret>,
|
||||
) -> HttpResponse {
|
||||
let error_html = match query {
|
||||
None => "".into(),
|
||||
Some(query) => match query.0.verify(&secret) {
|
||||
Ok(error) => {
|
||||
format!("<p><i>{}</i></p>", htmlescape::encode_minimal(&error))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error.message = %e,
|
||||
error.cause_chain = ?e,
|
||||
"Failed to verify query parameters using the HMAC tag"
|
||||
);
|
||||
"".into()
|
||||
}
|
||||
},
|
||||
};
|
||||
HttpResponse::Ok()
|
||||
.content_type(ContentType::html())
|
||||
.body(format!(
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
use crate::authentication::AuthError;
|
||||
use crate::authentication::{validate_credentials, Credentials};
|
||||
use crate::routes::error_chain_fmt;
|
||||
use crate::startup::HmacSecret;
|
||||
use crate::session_state::TypedSession;
|
||||
use actix_web::error::InternalError;
|
||||
use actix_web::http::header::LOCATION;
|
||||
use actix_web::web;
|
||||
use actix_web::HttpResponse;
|
||||
use hmac::{Hmac, Mac};
|
||||
use secrecy::{ExposeSecret, Secret};
|
||||
use actix_web_flash_messages::FlashMessage;
|
||||
use secrecy::Secret;
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
|
@ -17,14 +17,14 @@ pub struct FormData {
|
|||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
skip(form, pool, secret),
|
||||
skip(form, pool, session),
|
||||
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>,
|
||||
secret: web::Data<HmacSecret>,
|
||||
session: TypedSession,
|
||||
) -> Result<HttpResponse, InternalError<LoginError>> {
|
||||
let credentials = Credentials {
|
||||
username: form.0.username,
|
||||
|
@ -34,8 +34,12 @@ pub async fn login(
|
|||
match validate_credentials(credentials, &pool).await {
|
||||
Ok(user_id) => {
|
||||
tracing::Span::current().record("user_id", &tracing::field::display(&user_id));
|
||||
session.renew();
|
||||
session
|
||||
.insert_user_id(user_id)
|
||||
.map_err(|e| login_redirect(LoginError::UnexpectedError(e.into())))?;
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.insert_header((LOCATION, "/"))
|
||||
.insert_header((LOCATION, "/admin/dashboard"))
|
||||
.finish())
|
||||
}
|
||||
Err(e) => {
|
||||
|
@ -43,20 +47,17 @@ pub async fn login(
|
|||
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.0.expose_secret().as_bytes())
|
||||
.unwrap();
|
||||
mac.update(query_string.as_bytes());
|
||||
mac.finalize().into_bytes()
|
||||
};
|
||||
Err(login_redirect(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn login_redirect(e: LoginError) -> InternalError<LoginError> {
|
||||
FlashMessage::error(e.to_string()).send();
|
||||
let response = HttpResponse::SeeOther()
|
||||
.insert_header((LOCATION, format!("/login?{query_string}&tag={hmac_tag:x}")))
|
||||
.insert_header((LOCATION, "/login"))
|
||||
.finish();
|
||||
Err(InternalError::from_response(e, response))
|
||||
}
|
||||
}
|
||||
InternalError::from_response(e, response)
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error)]
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
mod admin;
|
||||
mod health_check;
|
||||
mod home;
|
||||
mod login;
|
||||
|
@ -5,6 +6,7 @@ mod newsletters;
|
|||
mod subscriptions;
|
||||
mod subscriptions_confirm;
|
||||
|
||||
pub use admin::*;
|
||||
pub use health_check::*;
|
||||
pub use home::*;
|
||||
pub use login::*;
|
||||
|
|
37
src/session_state.rs
Normal file
37
src/session_state.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
use actix_session::Session;
|
||||
use actix_session::SessionExt;
|
||||
use actix_web::dev::Payload;
|
||||
use actix_web::{FromRequest, HttpRequest};
|
||||
use std::future::{ready, Ready};
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn log_out(self) {
|
||||
self.0.purge()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequest for TypedSession {
|
||||
type Error = <Session as FromRequest>::Error;
|
||||
type Future = Ready<Result<TypedSession, Self::Error>>;
|
||||
|
||||
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
|
||||
ready(Ok(TypedSession(req.get_session())))
|
||||
}
|
||||
}
|
|
@ -1,12 +1,20 @@
|
|||
use crate::authentication::reject_anonymous_users;
|
||||
use crate::configuration::{DatabaseSettings, Settings};
|
||||
use crate::email_client::EmailClient;
|
||||
use crate::routes::{
|
||||
confirm, health_check, home, login, login_form, publish_newsletter, subscribe,
|
||||
admin_dashboard, change_password, change_password_form, confirm, health_check, home, log_out,
|
||||
login, login_form, publish_newsletter, subscribe,
|
||||
};
|
||||
use actix_session::storage::RedisSessionStore;
|
||||
use actix_session::SessionMiddleware;
|
||||
use actix_web::cookie::Key;
|
||||
use actix_web::dev::Server;
|
||||
use actix_web::web::Data;
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use secrecy::Secret;
|
||||
use actix_web_flash_messages::storage::CookieMessageStore;
|
||||
use actix_web_flash_messages::FlashMessagesFramework;
|
||||
use actix_web_lab::middleware::from_fn;
|
||||
use secrecy::{ExposeSecret, Secret};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use std::net::TcpListener;
|
||||
|
@ -18,7 +26,7 @@ pub struct Application {
|
|||
}
|
||||
|
||||
impl Application {
|
||||
pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> {
|
||||
pub async fn build(configuration: Settings) -> Result<Self, anyhow::Error> {
|
||||
let connection_pool = get_connection_pool(&configuration.database)
|
||||
.await
|
||||
.expect("Failed to connect to Postgres.");
|
||||
|
@ -47,7 +55,9 @@ impl Application {
|
|||
email_client,
|
||||
configuration.application.base_url,
|
||||
configuration.application.hmac_secret,
|
||||
)?;
|
||||
configuration.redis_uri,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Self { port, server })
|
||||
}
|
||||
|
@ -70,20 +80,38 @@ pub async fn get_connection_pool(configuration: &DatabaseSettings) -> Result<PgP
|
|||
|
||||
pub struct ApplicationBaseUrl(pub String);
|
||||
|
||||
fn run(
|
||||
async fn run(
|
||||
listener: TcpListener,
|
||||
db_pool: PgPool,
|
||||
email_client: EmailClient,
|
||||
base_url: String,
|
||||
hmac_secret: Secret<String>,
|
||||
) -> Result<Server, std::io::Error> {
|
||||
redis_uri: Secret<String>,
|
||||
) -> Result<Server, anyhow::Error> {
|
||||
let db_pool = Data::new(db_pool);
|
||||
let email_client = Data::new(email_client);
|
||||
let base_url = Data::new(ApplicationBaseUrl(base_url));
|
||||
let secret_key = Key::from(hmac_secret.expose_secret().as_bytes());
|
||||
let message_store = CookieMessageStore::builder(secret_key.clone()).build();
|
||||
let message_framework = FlashMessagesFramework::builder(message_store).build();
|
||||
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())
|
||||
.route("/", web::get().to(home))
|
||||
.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)),
|
||||
)
|
||||
.route("/login", web::get().to(login_form))
|
||||
.route("/login", web::post().to(login))
|
||||
.route("/health_check", web::get().to(health_check))
|
||||
|
|
15
src/utils.rs
Normal file
15
src/utils.rs
Normal file
|
@ -0,0 +1,15 @@
|
|||
use actix_web::http::header::LOCATION;
|
||||
use actix_web::HttpResponse;
|
||||
|
||||
// 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()
|
||||
}
|
43
tests/api/admin_dashboard.rs
Normal file
43
tests/api/admin_dashboard.rs
Normal file
|
@ -0,0 +1,43 @@
|
|||
use crate::helpers::{assert_is_redirect_to, spawn_app};
|
||||
|
||||
#[tokio::test]
|
||||
async fn you_must_be_logged_in_to_access_the_admin_dashboard() {
|
||||
// Arrange
|
||||
let app = spawn_app().await;
|
||||
|
||||
// Act
|
||||
let response = app.get_admin_dashboard().await;
|
||||
|
||||
// Assert
|
||||
assert_is_redirect_to(&response, "/login");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
141
tests/api/change_password.rs
Normal file
141
tests/api/change_password.rs
Normal file
|
@ -0,0 +1,141 @@
|
|||
use crate::helpers::{assert_is_redirect_to, spawn_app};
|
||||
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");
|
||||
}
|
||||
|
||||
#[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>"
|
||||
));
|
||||
}
|
||||
|
||||
#[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>"));
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
|
@ -27,6 +27,7 @@ pub struct TestApp {
|
|||
pub db_pool: PgPool,
|
||||
pub email_server: MockServer,
|
||||
pub test_user: TestUser,
|
||||
pub api_client: reqwest::Client,
|
||||
}
|
||||
|
||||
/// Confirmation links embedded in the request to the email API.
|
||||
|
@ -37,7 +38,7 @@ pub struct ConfirmationLinks {
|
|||
|
||||
impl TestApp {
|
||||
pub async fn post_subscriptions(&self, body: String) -> reqwest::Response {
|
||||
reqwest::Client::new()
|
||||
self.api_client
|
||||
.post(&format!("{}/subscriptions", &self.address))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body(body)
|
||||
|
@ -47,7 +48,7 @@ impl TestApp {
|
|||
}
|
||||
|
||||
pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response {
|
||||
reqwest::Client::new()
|
||||
self.api_client
|
||||
.post(&format!("{}/newsletters", &self.address))
|
||||
.basic_auth(&self.test_user.username, Some(&self.test_user.password))
|
||||
.json(&body)
|
||||
|
@ -56,6 +57,73 @@ impl TestApp {
|
|||
.expect("Failed to execute request.")
|
||||
}
|
||||
|
||||
pub async fn post_login<Body>(&self, body: &Body) -> reqwest::Response
|
||||
where
|
||||
Body: serde::Serialize,
|
||||
{
|
||||
self.api_client
|
||||
.post(&format!("{}/login", &self.address))
|
||||
.form(body)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.")
|
||||
}
|
||||
|
||||
pub async fn get_login_html(&self) -> String {
|
||||
self.api_client
|
||||
.get(&format!("{}/login", &self.address))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.")
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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 get_change_password_html(&self) -> String {
|
||||
self.get_change_password().await.text().await.unwrap()
|
||||
}
|
||||
|
||||
pub async fn post_logout(&self) -> reqwest::Response {
|
||||
self.api_client
|
||||
.post(&format!("{}/admin/logout", &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.")
|
||||
}
|
||||
|
||||
/// Extract the confirmation links embedded in the request to the email API.
|
||||
pub fn get_confirmation_links(&self, email_request: &wiremock::Request) -> ConfirmationLinks {
|
||||
let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap();
|
||||
|
@ -109,6 +177,12 @@ pub async fn spawn_app() -> TestApp {
|
|||
let application_port = application.port();
|
||||
let _ = tokio::spawn(application.run_until_stopped());
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.cookie_store(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let test_app = TestApp {
|
||||
address: format!("http://localhost:{}", application_port),
|
||||
port: application_port,
|
||||
|
@ -117,6 +191,7 @@ pub async fn spawn_app() -> TestApp {
|
|||
.expect("Failed to connect to the database"),
|
||||
email_server,
|
||||
test_user: TestUser::generate(),
|
||||
api_client: client,
|
||||
};
|
||||
|
||||
test_app.test_user.store(&test_app.db_pool).await;
|
||||
|
@ -184,3 +259,8 @@ impl TestUser {
|
|||
.expect("Failed to store test user.");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
25
tests/api/login.rs
Normal file
25
tests/api/login.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
use crate::helpers::{assert_is_redirect_to, 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_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>"));
|
||||
}
|
|
@ -1,5 +1,8 @@
|
|||
mod admin_dashboard;
|
||||
mod change_password;
|
||||
mod health_check;
|
||||
mod helpers;
|
||||
mod login;
|
||||
mod newsletter;
|
||||
mod subscriptions;
|
||||
mod subscriptions_confirm;
|
||||
|
|
Loading…
Reference in a new issue