mirror of
https://github.com/LukeMathWalker/zero-to-production.git
synced 2024-05-05 14:18:46 +00:00
Last part of chapter 10 - sessions, seed users and change password form
This commit is contained in:
parent
2b6c2c5bc0
commit
df6bdea82b
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"
|
||||
|
|
|
@ -13,4 +13,5 @@ email_client:
|
|||
base_url: "localhost"
|
||||
sender_email: "test@gmail.com"
|
||||
authorization_token: "my-secret-token"
|
||||
timeout_milliseconds: 10000
|
||||
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,22 +47,19 @@ 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()
|
||||
};
|
||||
let response = HttpResponse::SeeOther()
|
||||
.insert_header((LOCATION, format!("/login?{query_string}&tag={hmac_tag:x}")))
|
||||
.finish();
|
||||
Err(InternalError::from_response(e, response))
|
||||
Err(login_redirect(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error)]
|
||||
pub enum LoginError {
|
||||
#[error("Authentication failed")]
|
||||
|
|
|
@ -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