Skip to content

Commit

Permalink
feat: add authentication middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
alexsavio committed Apr 24, 2024
1 parent ff9b442 commit 8a62d3b
Show file tree
Hide file tree
Showing 34 changed files with 1,249 additions and 261 deletions.
344 changes: 337 additions & 7 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
config = "0.14"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "rt"] }
uuid = { version = "1.7", features = ["v4"] }
uuid = { version = "1.7", features = ["v4", "serde"] }
secrecy = { version = "0.8", features = ["serde"] }
tracing = { version = "0.1.40", features = ["log"] }
tracing-log = "0.2"
Expand All @@ -34,6 +34,9 @@ actix-web-lab = "0.20"
argon2 = { version = "0.5", features = ["std"] }
urlencoding = "2"
htmlescape = "0.3.1"
actix-web-flash-messages = { version = "0.4", features = ["cookies"] }
actix-session = { version = "0.9", features = ["redis-rs-tls-session"] }
serde_json = "1.0"

[dependencies.reqwest]
version = "0.12"
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ Check the Justfile for the commands to run the project.
- [ ] Use a proper templating solution for our emails (e.g. tera);
- [ ] Anything that comes to your mind!

### Section 10
- [ ] OWASP’s provides a minimum set of requirements when it comes to password strength - passwords should be longer than 12 characters but shorter than 129 characters.
Add these validation checks to our POST /admin/password endpoint.
- [X] 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 the Json extractor (application/json) to handle the request body
- Adapt the tests
- [ ] OAuth

## Troubleshooting

Expand Down
2 changes: 2 additions & 0 deletions configuration/base.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
application:
port: 8000
host: 0.0.0.0
hmac_secret_key: "super-long-and-secret-random-key-needed-to-verify-message-integrity"
database:
host: localhost
port: 5432
Expand All @@ -13,3 +14,4 @@ email_client:
sender_email: [email protected]
authorization_token: "my-secret-token"
timeout_milliseconds: 10000
redis_uri: redis://127.0.0.1:6379
18 changes: 17 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ services:
image: postgres:16.1-alpine
command: -N 1000
ports:
- ${DB_PORT}:5432
- ${DB_PORT:-5432}:5432
volumes:
- postgres:/data/postgres
environment:
Expand All @@ -36,5 +36,21 @@ services:
timeout: 5s
retries: 5

redis:
restart: always
container_name: z2p-redis
image: redis:7-alpine
ports:
- ${REDIS_PORT:-6379}:6379
volumes:
- redis:/data/redis
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5

volumes:
postgres:
redis:
7 changes: 7 additions & 0 deletions migrations/20240409144926_seed_user.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Add migration script here
INSERT INTO users (user_id, username, password_hash)
VALUES (
'dbec5e8d-2748-4068-a02d-9354020e36eb',
'admin',
'$argon2id$v=19$m=15000,t=2,p=1$6Ogi5jk9uSH3WtxvlaCl3g$i1LiNaI+CA/HP9E7B6j0uTAYe7QzIbr49wBllXJGGK0'
)
48 changes: 48 additions & 0 deletions src/authentication/middleware.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use crate::commons::{e500, see_other};
use crate::session_state::TypedSession;
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())
}
}
}
3 changes: 3 additions & 0 deletions src/authentication/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
mod middleware;
mod password;

pub use middleware::reject_anonymous_users;
pub use middleware::UserId;
pub use password::{change_password, validate_credentials, AuthError, Credentials};
17 changes: 17 additions & 0 deletions src/commons.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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)
}

/// Return a 303 See Other response with the given location.
pub fn see_other(location: &str) -> HttpResponse {
HttpResponse::SeeOther()
.insert_header((LOCATION, location))
.finish()
}
2 changes: 2 additions & 0 deletions src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
pub email_client: EmailClientSettings,
pub redis_uri: Secret<String>,
}

#[derive(Clone, serde::Deserialize)]
Expand All @@ -17,6 +18,7 @@ pub struct ApplicationSettings {
pub port: u16,
pub host: String,
pub base_url: String,
pub hmac_secret_key: Secret<String>,
}

#[derive(Clone, serde::Deserialize)]
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
pub mod authentication;
pub mod commons;
pub mod configuration;
pub mod domain;
pub mod email_client;
pub mod routes;
pub mod session_state;
pub mod startup;
pub mod telemetry;
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use zero2prod::startup::Application;
use zero2prod::telemetry::{get_subscriber, init_subscriber};

#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
async fn main() -> anyhow::Result<()> {
// setup tracing
let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout);
init_subscriber(subscriber);
Expand Down
53 changes: 53 additions & 0 deletions src/routes/admin/dashboard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use crate::commons::e500;
use crate::session_state::TypedSession;
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/newsletters">Create new issue</a></li>
<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>
</body>
</html>"#
)))
}

#[tracing::instrument(name = "Fetching username from the database", skip(pool))]
pub async fn get_username(user_id: Uuid, pool: &sqlx::PgPool) -> Result<String, anyhow::Error> {
let row = sqlx::query!("SELECT username FROM users WHERE user_id = $1", user_id)
.fetch_one(pool)
.await
.context("Failed to fetch user from the database")?;
Ok(row.username)
}
14 changes: 14 additions & 0 deletions src/routes/admin/logout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
use crate::commons::{e500, see_other};
use crate::session_state::TypedSession;
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"))
}
}
9 changes: 9 additions & 0 deletions src/routes/admin/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
mod dashboard;
mod logout;
mod newsletter;
mod password;

pub use dashboard::admin_dashboard;
pub use logout::log_out;
pub use newsletter::{publish_newsletter, publish_newsletter_form};
pub use password::{change_password, change_password_form};
55 changes: 55 additions & 0 deletions src/routes/admin/newsletter/get.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
use actix_web::http::header::ContentType;
use actix_web::HttpResponse;
use actix_web_flash_messages::IncomingFlashMessages;
use std::fmt::Write;

pub async fn publish_newsletter_form(
flash_messages: IncomingFlashMessages,
) -> Result<HttpResponse, actix_web::Error> {
let mut msg_html = String::new();
for m in flash_messages.iter() {
write!(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>Publish newsletter issue</title>
</head>
<body>
{msg_html}
<form action="/admin/newsletters" method="post">
<label>Title:<br>
<input
type="text"
name="title"
>
</label>
<br>
<label>Plain text content:<br>
<textarea
name="text_content"
rows="20"
cols="50"
></textarea>
</label>
<br>
<label>HTML content:<br>
<textarea
name="html_content"
rows="20"
cols="50"
></textarea>
</label>
<br>
<button type="submit">Publish</button>
</form>
<p><a href="/admin/dashboard">&lt;- Back</a></p>
</body>
</html>"#,
)))
}
5 changes: 5 additions & 0 deletions src/routes/admin/newsletter/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
mod get;
mod post;

pub use get::publish_newsletter_form;
pub use post::publish_newsletter;
Loading

0 comments on commit 8a62d3b

Please sign in to comment.