Skip to content

Commit

Permalink
Set login error msg in cookies | 10.7 sessions
Browse files Browse the repository at this point in the history
  • Loading branch information
samituga committed Oct 24, 2024
1 parent 973ff01 commit 728c44a
Show file tree
Hide file tree
Showing 10 changed files with 527 additions and 238 deletions.
584 changes: 365 additions & 219 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ name = "zero2prod"

[dependencies]
actix-web = "4"
actix-web-flash-messages = { version = "0.5.0", features = ["cookies"] }
anyhow = "1.0.89"
argon2 = { version = "0.5.3", features = ["std"] }
async-trait = "0.1.80"
Expand All @@ -22,11 +23,11 @@ base64 = "0.22.1"
chrono = { version = "0.4.38", default-features = false, features = ["clock"] }
config = { version = "0.14.0", features = ["toml"], default-features = false }
dotenvy = "0.15.7"
htmlescape = "0.3.1"
http = "1.1.0"
log = "0.4.21"
once_cell = "1.20.1"
rand = "0.8.5"
reqwest = { version = "0.12.4", features = ["json"] }
secrecy = { version = "0.8.0", features = ["serde"] }
serde = { version = "1.0.200", features = ["derive"] }
serde-aux = "4"
Expand All @@ -39,9 +40,15 @@ tracing-bunyan-formatter = "0.3.9"
tracing-log = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["registry", "env-filter"] }
unicode-segmentation = "1.11.0"
urlencoding = "2.1.3"
uuid = { version = "1.8.0", features = ["v4"] }
validator = "0.18.0"

[dependencies.reqwest]
version = "0.12.4"
default-features = false
features = ["json", "rustls-tls", "cookies"]

[dependencies.sqlx]
version = "0.8.1"
default-features = false
Expand Down
1 change: 1 addition & 0 deletions configuration/base.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[application]
port = 8080
hmac_secret = "long-and-very-secret-random-key-needed-to-verify-message-identity"

[database]
host = "127.0.0.1"
Expand Down
1 change: 1 addition & 0 deletions src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub struct ApplicationSettings {
pub host: String,
#[serde(deserialize_with = "deserialize_base_url")]
pub base_url: Uri,
pub hmac_secret: Secret<String>,
}

fn deserialize_base_url<'de, D>(deserializer: D) -> Result<Uri, D::Error>
Expand Down
40 changes: 38 additions & 2 deletions src/routes/login/get.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,44 @@
use actix_web::http::header::ContentType;
use actix_web::HttpResponse;
use actix_web_flash_messages::{IncomingFlashMessages, Level};
use std::fmt::Write;

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();
}

pub async fn login_form() -> HttpResponse {
HttpResponse::Ok()
.content_type(ContentType::html())
.body(include_str!("login.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>"#,
))
}
38 changes: 24 additions & 14 deletions src/routes/login/post.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use crate::authentication::{validate_credentials, AuthError, Credentials};
use crate::routes::error_chain_fmt;
use actix_web::error::InternalError;
use actix_web::http::header::LOCATION;
use actix_web::http::StatusCode;
use actix_web::{web, HttpResponse, ResponseError};
use actix_web_flash_messages::FlashMessage;
use secrecy::Secret;
use sqlx::PgPool;
use std::fmt::Debug;
Expand All @@ -19,29 +22,36 @@ pub struct FormData {
pub async fn login(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, LoginError> {
) -> Result<HttpResponse, InternalError<LoginError>> {
let credentials = Credentials {
username: form.0.username,
password: form.0.password,
};
match validate_credentials(credentials, &pool).await {
Ok(user_id) => {
tracing::Span::current().record("user_id", tracing::field::display(&user_id));
Ok(HttpResponse::SeeOther()
.insert_header(("location", "/"))
.finish())
}
Err(e) => {
let e = match e {
AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()),
};
FlashMessage::error(e.to_string()).send();
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, "/login"))
.finish();

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())
Err(InternalError::from_response(e, response))
}
}
}

#[derive(thiserror::Error)]
pub enum LoginError {
#[error("Invalid credentials.")]
#[error("Authentication failed")]
AuthError(#[source] anyhow::Error),
#[error("Something went wrong.")]
UnexpectedError(#[from] anyhow::Error),
Expand Down
13 changes: 13 additions & 0 deletions src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ use crate::email::email_client::{EmailClient, EmailService};
use crate::routes::{
confirm, health_check, home, login, login_form, publish_newsletter, subscribe,
};
use actix_web::cookie::Key;
use actix_web::dev::Server;
use actix_web::{web, App, HttpServer};
use actix_web_flash_messages::storage::CookieMessageStore;
use actix_web_flash_messages::FlashMessagesFramework;
use http::Uri;
use secrecy::{ExposeSecret, Secret};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::net::TcpListener;
Expand Down Expand Up @@ -49,6 +53,7 @@ impl Application {
email_service,
dependencies.email_client,
configuration.application.base_url,
configuration.application.hmac_secret,
)?;

Ok(Self { port, server })
Expand All @@ -65,19 +70,27 @@ impl Application {

pub struct ApplicationBaseUrl(pub Uri);

pub struct HmacSecret(pub Secret<String>);

pub fn run(
listener: TcpListener,
db_pool: PgPool,
email_service: EmailService,
email_client: Arc<dyn EmailClient>,
base_url: Uri,
hmac_secret: Secret<String>,
) -> Result<Server, std::io::Error> {
let message_store =
CookieMessageStore::builder(Key::from(hmac_secret.expose_secret().as_bytes())).build();
let message_framework = FlashMessagesFramework::builder(message_store).build();

let db_pool = web::Data::new(db_pool);
let email_service = web::Data::new(email_service);
let email_client: web::Data<dyn EmailClient> = web::Data::from(email_client.clone());
let base_url = web::Data::new(ApplicationBaseUrl(base_url));
let server = HttpServer::new(move || {
App::new()
.wrap(message_framework.clone())
.wrap(TracingLogger::default())
.route("/", web::get().to(home))
.route("/login", web::get().to(login_form))
Expand Down
39 changes: 37 additions & 2 deletions tests/api/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ pub async fn spawn_app() -> TestApp {
.expect("Failed to build application.");
let application_port = application.port();
let address = format!("http://127.0.0.1:{}", application_port);
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.cookie_store(true)
.build()
.unwrap();

let _ = tokio::spawn(application.run_until_stopped());

Expand All @@ -45,6 +50,7 @@ pub async fn spawn_app() -> TestApp {
db_pool: get_connection_pool(&configuration.database),
aws_request_wrapper: AwsRequestsWrapper::new(requests),
test_user: TestUser::generate(),
api_client: client,
};
test_app.test_user.store(&test_app.db_pool).await;
test_app
Expand All @@ -56,11 +62,12 @@ pub struct TestApp {
pub db_pool: PgPool,
pub aws_request_wrapper: AwsRequestsWrapper,
pub test_user: TestUser,
pub api_client: reqwest::Client,
}

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)
Expand Down Expand Up @@ -92,14 +99,37 @@ impl TestApp {
}

pub async fn publish_newsletter(&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)
.send()
.await
.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
.expect("Failed to read response body.")
}
}

pub struct ConfirmationLinks {
Expand Down Expand Up @@ -177,3 +207,8 @@ async fn create_database(config: &DatabaseSettings) {
.await
.expect("Failed to create database.");
}

pub fn assert_is_redirected_to(response: &reqwest::Response, location: &str) {
assert_eq!(response.status().as_u16(), 303);
assert_eq!(response.headers().get("location").unwrap(), location);
}
39 changes: 39 additions & 0 deletions tests/api/login.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use crate::api::helpers::{assert_is_redirected_to, spawn_app};

#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// Arrange
let app = spawn_app().await;
let login_body = serde_json::json!( {
"username": "username",
"password": "password"
});

// Act
let response = app.post_login(&login_body).await;

Check failure on line 13 in tests/api/login.rs

View workflow job for this annotation

GitHub Actions / Code coverage

unused variable: `response`

Check failure on line 13 in tests/api/login.rs

View workflow job for this annotation

GitHub Actions / Lint

unused variable: `response`

Check failure on line 13 in tests/api/login.rs

View workflow job for this annotation

GitHub Actions / Test

unused variable: `response`

// Assert
let html_page = app.get_login_html().await;
assert!(html_page.contains(r#"<p><i>Authentication failed</i></p>"#));
}

#[tokio::test]
async fn an_error_flash_message_is_not_set_after_failure_reload() {
// Arrange
let app = spawn_app().await;
let login_body = serde_json::json!( {
"username": "username",
"password": "password"
});

// Act
let response = app.post_login(&login_body).await;

// Assert
assert_is_redirected_to(&response, "/login");

// load the login twice
app.get_login_html().await;
let html_page = app.get_login_html().await;
assert!(!html_page.contains(r#"<p><i>Authentication failed</i></p>"#));
}
1 change: 1 addition & 0 deletions tests/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod health_check;
mod helpers;
mod login;
mod newsletter;
mod subscriptions;
mod subscriptions_confirm;

0 comments on commit 728c44a

Please sign in to comment.