Skip to content

Commit 8a62d3b

Browse files
committed
feat: add authentication middleware
1 parent ff9b442 commit 8a62d3b

34 files changed

+1249
-261
lines changed

Cargo.lock

Lines changed: 337 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
1717
config = "0.14"
1818
serde = { version = "1.0", features = ["derive"] }
1919
tokio = { version = "1", features = ["macros", "rt-multi-thread", "rt"] }
20-
uuid = { version = "1.7", features = ["v4"] }
20+
uuid = { version = "1.7", features = ["v4", "serde"] }
2121
secrecy = { version = "0.8", features = ["serde"] }
2222
tracing = { version = "0.1.40", features = ["log"] }
2323
tracing-log = "0.2"
@@ -34,6 +34,9 @@ actix-web-lab = "0.20"
3434
argon2 = { version = "0.5", features = ["std"] }
3535
urlencoding = "2"
3636
htmlescape = "0.3.1"
37+
actix-web-flash-messages = { version = "0.4", features = ["cookies"] }
38+
actix-session = { version = "0.9", features = ["redis-rs-tls-session"] }
39+
serde_json = "1.0"
3740

3841
[dependencies.reqwest]
3942
version = "0.12"

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ Check the Justfile for the commands to run the project.
1515
- [ ] Use a proper templating solution for our emails (e.g. tera);
1616
- [ ] Anything that comes to your mind!
1717

18+
### Section 10
19+
- [ ] 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.
20+
Add these validation checks to our POST /admin/password endpoint.
21+
- [X] Add a "Send a newsletter issue" link to the admin dashboard
22+
- [ ] Add an HTML form at GET /admin/newsletters to submit a new issue;
23+
- [ ] Adapt POST /newsletters to process the form data:
24+
- Change the route to POST /admin/newsletters;
25+
- Migrate from ‘Basic’ to session-based authentication;
26+
- Use the Form extractor (application/x-www-form-urlencoded) instead of the Json extractor (application/json) to handle the request body
27+
- Adapt the tests
28+
- [ ] OAuth
1829

1930
## Troubleshooting
2031

configuration/base.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
application:
22
port: 8000
33
host: 0.0.0.0
4+
hmac_secret_key: "super-long-and-secret-random-key-needed-to-verify-message-integrity"
45
database:
56
host: localhost
67
port: 5432
@@ -13,3 +14,4 @@ email_client:
1314
sender_email: test@example.com
1415
authorization_token: "my-secret-token"
1516
timeout_milliseconds: 10000
17+
redis_uri: redis://127.0.0.1:6379

docker-compose.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ services:
2222
image: postgres:16.1-alpine
2323
command: -N 1000
2424
ports:
25-
- ${DB_PORT}:5432
25+
- ${DB_PORT:-5432}:5432
2626
volumes:
2727
- postgres:/data/postgres
2828
environment:
@@ -36,5 +36,21 @@ services:
3636
timeout: 5s
3737
retries: 5
3838

39+
redis:
40+
restart: always
41+
container_name: z2p-redis
42+
image: redis:7-alpine
43+
ports:
44+
- ${REDIS_PORT:-6379}:6379
45+
volumes:
46+
- redis:/data/redis
47+
command: redis-server --appendonly yes
48+
healthcheck:
49+
test: ["CMD", "redis-cli", "ping"]
50+
interval: 5s
51+
timeout: 5s
52+
retries: 5
53+
3954
volumes:
4055
postgres:
56+
redis:
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Add migration script here
2+
INSERT INTO users (user_id, username, password_hash)
3+
VALUES (
4+
'dbec5e8d-2748-4068-a02d-9354020e36eb',
5+
'admin',
6+
'$argon2id$v=19$m=15000,t=2,p=1$6Ogi5jk9uSH3WtxvlaCl3g$i1LiNaI+CA/HP9E7B6j0uTAYe7QzIbr49wBllXJGGK0'
7+
)

src/authentication/middleware.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
use crate::commons::{e500, see_other};
2+
use crate::session_state::TypedSession;
3+
use actix_web::body::MessageBody;
4+
use actix_web::dev::{ServiceRequest, ServiceResponse};
5+
use actix_web::error::InternalError;
6+
use actix_web::{FromRequest, HttpMessage};
7+
use actix_web_lab::middleware::Next;
8+
use std::ops::Deref;
9+
use uuid::Uuid;
10+
11+
#[derive(Copy, Clone, Debug)]
12+
pub struct UserId(Uuid);
13+
14+
impl std::fmt::Display for UserId {
15+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16+
self.0.fmt(f)
17+
}
18+
}
19+
20+
impl Deref for UserId {
21+
type Target = Uuid;
22+
23+
fn deref(&self) -> &Self::Target {
24+
&self.0
25+
}
26+
}
27+
28+
pub async fn reject_anonymous_users(
29+
mut req: ServiceRequest,
30+
next: Next<impl MessageBody>,
31+
) -> Result<ServiceResponse<impl MessageBody>, actix_web::Error> {
32+
let session = {
33+
let (http_request, payload) = req.parts_mut();
34+
TypedSession::from_request(http_request, payload).await
35+
}?;
36+
37+
match session.get_user_id().map_err(e500)? {
38+
Some(user_id) => {
39+
req.extensions_mut().insert(UserId(user_id));
40+
next.call(req).await
41+
}
42+
None => {
43+
let response = see_other("/login");
44+
let e = anyhow::anyhow!("The user has not logged in.");
45+
Err(InternalError::from_response(e, response).into())
46+
}
47+
}
48+
}

src/authentication/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
mod middleware;
12
mod password;
23

4+
pub use middleware::reject_anonymous_users;
5+
pub use middleware::UserId;
36
pub use password::{change_password, validate_credentials, AuthError, Credentials};

src/commons.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
use actix_web::http::header::LOCATION;
2+
use actix_web::HttpResponse;
3+
4+
/// Return an opaque 500 while preserving the error root's cause for logging.
5+
pub fn e500<T>(e: T) -> actix_web::Error
6+
where
7+
T: std::fmt::Debug + std::fmt::Display + 'static,
8+
{
9+
actix_web::error::ErrorInternalServerError(e)
10+
}
11+
12+
/// Return a 303 See Other response with the given location.
13+
pub fn see_other(location: &str) -> HttpResponse {
14+
HttpResponse::SeeOther()
15+
.insert_header((LOCATION, location))
16+
.finish()
17+
}

src/configuration.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub struct Settings {
99
pub database: DatabaseSettings,
1010
pub application: ApplicationSettings,
1111
pub email_client: EmailClientSettings,
12+
pub redis_uri: Secret<String>,
1213
}
1314

1415
#[derive(Clone, serde::Deserialize)]
@@ -17,6 +18,7 @@ pub struct ApplicationSettings {
1718
pub port: u16,
1819
pub host: String,
1920
pub base_url: String,
21+
pub hmac_secret_key: Secret<String>,
2022
}
2123

2224
#[derive(Clone, serde::Deserialize)]

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
pub mod authentication;
2+
pub mod commons;
23
pub mod configuration;
34
pub mod domain;
45
pub mod email_client;
56
pub mod routes;
7+
pub mod session_state;
68
pub mod startup;
79
pub mod telemetry;

src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use zero2prod::startup::Application;
33
use zero2prod::telemetry::{get_subscriber, init_subscriber};
44

55
#[tokio::main]
6-
async fn main() -> Result<(), std::io::Error> {
6+
async fn main() -> anyhow::Result<()> {
77
// setup tracing
88
let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout);
99
init_subscriber(subscriber);

src/routes/admin/dashboard.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
use crate::commons::e500;
2+
use crate::session_state::TypedSession;
3+
use actix_web::http::header::LOCATION;
4+
use actix_web::{http::header::ContentType, web, HttpResponse};
5+
use anyhow::Context;
6+
use sqlx::PgPool;
7+
use uuid::Uuid;
8+
9+
pub async fn admin_dashboard(
10+
session: TypedSession,
11+
pool: web::Data<PgPool>,
12+
) -> Result<HttpResponse, actix_web::Error> {
13+
let username = if let Some(user_id) = session.get_user_id().map_err(e500)? {
14+
get_username(user_id, &pool).await.map_err(e500)?
15+
} else {
16+
return Ok(HttpResponse::SeeOther()
17+
.insert_header((LOCATION, "/login"))
18+
.finish());
19+
};
20+
Ok(HttpResponse::Ok()
21+
.content_type(ContentType::html())
22+
.body(format!(
23+
r#"<!DOCTYPE html>
24+
<html lang="en">
25+
<head>
26+
<meta http-equiv="content-type" content="text/html; charset=utf-8">
27+
<title>Admin dashboard</title>
28+
</head>
29+
<body>
30+
<p>Welcome {username}!</p>
31+
<p>Available actions:</p>
32+
<ol>
33+
<li><a href="/admin/newsletters">Create new issue</a></li>
34+
<li><a href="/admin/password">Change password</a></li>
35+
<li>
36+
<form name="logoutForm" action="/admin/logout" method="post">
37+
<input type="submit" value="Logout">
38+
</form>
39+
</li>
40+
</ol>
41+
</body>
42+
</html>"#
43+
)))
44+
}
45+
46+
#[tracing::instrument(name = "Fetching username from the database", skip(pool))]
47+
pub async fn get_username(user_id: Uuid, pool: &sqlx::PgPool) -> Result<String, anyhow::Error> {
48+
let row = sqlx::query!("SELECT username FROM users WHERE user_id = $1", user_id)
49+
.fetch_one(pool)
50+
.await
51+
.context("Failed to fetch user from the database")?;
52+
Ok(row.username)
53+
}

src/routes/admin/logout.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
use crate::commons::{e500, see_other};
2+
use crate::session_state::TypedSession;
3+
use actix_web::HttpResponse;
4+
use actix_web_flash_messages::FlashMessage;
5+
6+
pub async fn log_out(session: TypedSession) -> Result<HttpResponse, actix_web::Error> {
7+
if session.get_user_id().map_err(e500)?.is_none() {
8+
Ok(see_other("/login"))
9+
} else {
10+
session.log_out();
11+
FlashMessage::info("You have successfully logged out.").send();
12+
Ok(see_other("/login"))
13+
}
14+
}

src/routes/admin/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
mod dashboard;
2+
mod logout;
3+
mod newsletter;
4+
mod password;
5+
6+
pub use dashboard::admin_dashboard;
7+
pub use logout::log_out;
8+
pub use newsletter::{publish_newsletter, publish_newsletter_form};
9+
pub use password::{change_password, change_password_form};

src/routes/admin/newsletter/get.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
use actix_web::http::header::ContentType;
2+
use actix_web::HttpResponse;
3+
use actix_web_flash_messages::IncomingFlashMessages;
4+
use std::fmt::Write;
5+
6+
pub async fn publish_newsletter_form(
7+
flash_messages: IncomingFlashMessages,
8+
) -> Result<HttpResponse, actix_web::Error> {
9+
let mut msg_html = String::new();
10+
for m in flash_messages.iter() {
11+
write!(msg_html, "<p><i>{}</i></p>", m.content()).unwrap();
12+
}
13+
14+
Ok(HttpResponse::Ok()
15+
.content_type(ContentType::html())
16+
.body(format!(
17+
r#"<!DOCTYPE html>
18+
<html lang="en">
19+
<head>
20+
<meta http-equiv="content-type" content="text/html; charset=utf-8">
21+
<title>Publish newsletter issue</title>
22+
</head>
23+
<body>
24+
{msg_html}
25+
<form action="/admin/newsletters" method="post">
26+
<label>Title:<br>
27+
<input
28+
type="text"
29+
name="title"
30+
>
31+
</label>
32+
<br>
33+
<label>Plain text content:<br>
34+
<textarea
35+
name="text_content"
36+
rows="20"
37+
cols="50"
38+
></textarea>
39+
</label>
40+
<br>
41+
<label>HTML content:<br>
42+
<textarea
43+
name="html_content"
44+
rows="20"
45+
cols="50"
46+
></textarea>
47+
</label>
48+
<br>
49+
<button type="submit">Publish</button>
50+
</form>
51+
<p><a href="/admin/dashboard">&lt;- Back</a></p>
52+
</body>
53+
</html>"#,
54+
)))
55+
}

src/routes/admin/newsletter/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
mod get;
2+
mod post;
3+
4+
pub use get::publish_newsletter_form;
5+
pub use post::publish_newsletter;

0 commit comments

Comments
 (0)