Skip to content

Commit d1330ea

Browse files
committed
Feat: introduced hma secret feature
1 parent c387e50 commit d1330ea

17 files changed

+333
-9
lines changed

Diff for: Cargo.lock

+17
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: Cargo.toml

+5
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ thiserror = "1"
3535
anyhow = "1"
3636
base64 = "0.13.0"
3737
argon2 = { version = "0.3", features = ["std"] }
38+
urlencoding = "2"
39+
htmlescape = "0.3"
40+
hmac = { version = "0.12", features = ["std"] }
41+
sha2 = "0.10"
42+
hex = "0.4"
3843

3944
[dev-dependencies]
4045
once_cell = "1"

Diff for: configuration/base.yml

+1
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: "super-long-and-secret-random-key-needed-to-verify-message-integrity"
45
database:
56
host: "127.0.0.1"
67
port: 5432

Diff for: src/lib.rs

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
pub mod authentication;
12
pub mod configuration;
23
pub mod domain;
34
pub mod email_client;
45
pub mod routes;
6+
pub mod session_state;
57
pub mod startup;
68
pub mod telemetry;
9+
pub mod utils;

Diff for: src/routes/home/home.html

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
5+
<title>Home</title>
6+
</head>
7+
<body>
8+
<p>Welcome to our newsletter!</p>
9+
</body>
10+
</html>

Diff for: src/routes/home/mod.rs

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
use actix_web::{http::header::ContentType, HttpResponse};
2+
3+
pub async fn home() -> HttpResponse {
4+
HttpResponse::Ok()
5+
.content_type(ContentType::html())
6+
.body(include_str!("home.html"))
7+
}

Diff for: src/routes/login/get.rs

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
use crate::startup::HmacSecret;
2+
use actix_web::{http::header::ContentType, web, HttpResponse};
3+
use hmac::{Hmac, Mac};
4+
use secrecy::ExposeSecret;
5+
6+
#[derive(serde::Deserialize)]
7+
pub struct QueryParams {
8+
error: String,
9+
tag: String,
10+
}
11+
12+
impl QueryParams {
13+
fn verify(self, secret: &HmacSecret) -> Result<String, anyhow::Error> {
14+
let tag = hex::decode(self.tag)?;
15+
let query_string = format!("error={}", urlencoding::Encoded::new(&self.error));
16+
17+
let mut mac =
18+
Hmac::<sha2::Sha256>::new_from_slice(secret.0.expose_secret().as_bytes()).unwrap();
19+
mac.update(query_string.as_bytes());
20+
mac.verify_slice(&tag)?;
21+
22+
Ok(self.error)
23+
}
24+
}
25+
26+
pub async fn login_form(
27+
query: Option<web::Query<QueryParams>>,
28+
secret: web::Data<HmacSecret>,
29+
) -> HttpResponse {
30+
let error_html = match query {
31+
None => "".into(),
32+
Some(query) => match query.0.verify(&secret) {
33+
Ok(error) => {
34+
format!("<p><i>{}</i></p>", htmlescape::encode_minimal(&error))
35+
}
36+
Err(e) => {
37+
tracing::warn!(
38+
error.message = %e,
39+
error.cause_chain = ?e,
40+
"Failed to verify query parameters using the HMAC tag"
41+
);
42+
"".into()
43+
}
44+
},
45+
};
46+
HttpResponse::Ok()
47+
.content_type(ContentType::html())
48+
.body(format!(
49+
r#"<!DOCTYPE html>
50+
<html lang="en">
51+
<head>
52+
<meta http-equiv="content-type" content="text/html; charset=utf-8">
53+
<title>Login</title>
54+
</head>
55+
<body>
56+
{error_html}
57+
<form action="/login" method="post">
58+
<label>Username
59+
<input
60+
type="text"
61+
placeholder="Enter Username"
62+
name="username"
63+
>
64+
</label>
65+
<label>Password
66+
<input
67+
type="password"
68+
placeholder="Enter Password"
69+
name="password"
70+
>
71+
</label>
72+
<button type="submit">Login</button>
73+
</form>
74+
</body>
75+
</html>"#,
76+
))
77+
}

Diff for: src/routes/login/login.html

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
5+
<title>Login</title>
6+
</head>
7+
<body>
8+
<form action="/login" method="post">
9+
<label
10+
>Username
11+
<input type="text" placeholder="Enter Username" name="username" />
12+
</label>
13+
<label
14+
>Password
15+
<input type="password" placeholder="Enter Password" name="password" />
16+
</label>
17+
<button type="submit">Login</button>
18+
</form>
19+
</body>
20+
</html>

Diff for: src/routes/login/mod.rs

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
mod get;
2+
mod post;
3+
4+
pub use get::login_form;
5+
pub use post::login;

Diff for: src/routes/login/post.rs

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
use crate::authentication::AuthError;
2+
use crate::authentication::{validate_credentials, Credentials};
3+
use crate::routes::error_chain_fmt;
4+
use crate::startup::HmacSecret;
5+
use actix_web::error::InternalError;
6+
use actix_web::http::header::LOCATION;
7+
use actix_web::web;
8+
use actix_web::HttpResponse;
9+
use hmac::{Hmac, Mac};
10+
use secrecy::{ExposeSecret, Secret};
11+
use sqlx::PgPool;
12+
13+
#[derive(serde::Deserialize)]
14+
pub struct FormData {
15+
username: String,
16+
password: Secret<String>,
17+
}
18+
19+
#[tracing::instrument(
20+
skip(form, pool, secret),
21+
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
22+
)]
23+
// We are now injecting `PgPool` to retrieve stored credentials from the database
24+
pub async fn login(
25+
form: web::Form<FormData>,
26+
pool: web::Data<PgPool>,
27+
secret: web::Data<HmacSecret>,
28+
) -> Result<HttpResponse, InternalError<LoginError>> {
29+
let credentials = Credentials {
30+
username: form.0.username,
31+
password: form.0.password,
32+
};
33+
tracing::Span::current().record("username", &tracing::field::display(&credentials.username));
34+
match validate_credentials(credentials, &pool).await {
35+
Ok(user_id) => {
36+
tracing::Span::current().record("user_id", &tracing::field::display(&user_id));
37+
Ok(HttpResponse::SeeOther()
38+
.insert_header((LOCATION, "/"))
39+
.finish())
40+
}
41+
Err(e) => {
42+
let e = match e {
43+
AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
44+
AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()),
45+
};
46+
let query_string = format!("error={}", urlencoding::Encoded::new(e.to_string()));
47+
let hmac_tag = {
48+
let mut mac =
49+
Hmac::<sha2::Sha256>::new_from_slice(secret.0.expose_secret().as_bytes())
50+
.unwrap();
51+
mac.update(query_string.as_bytes());
52+
mac.finalize().into_bytes()
53+
};
54+
let response = HttpResponse::SeeOther()
55+
.insert_header((LOCATION, format!("/login?{query_string}&tag={hmac_tag:x}")))
56+
.finish();
57+
Err(InternalError::from_response(e, response))
58+
}
59+
}
60+
}
61+
62+
#[derive(thiserror::Error)]
63+
pub enum LoginError {
64+
#[error("Authentication failed")]
65+
AuthError(#[source] anyhow::Error),
66+
#[error("Something went wrong")]
67+
UnexpectedError(#[from] anyhow::Error),
68+
}
69+
70+
impl std::fmt::Debug for LoginError {
71+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72+
error_chain_fmt(self, f)
73+
}
74+
}

Diff for: src/routes/mod.rs

+4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
mod health_check;
2+
mod home;
3+
mod login;
24
mod newsletters;
35
mod subscriptions;
46
mod subscriptions_confirm;
57

68
pub use health_check::*;
9+
pub use home::*;
10+
pub use login::*;
711
pub use newsletters::*;
812
pub use subscriptions::*;
913
pub use subscriptions_confirm::*;

Diff for: src/routes/newsletters.rs

+22-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::{
22
domain::SubscriberEmail, email_client::EmailClient, routes::error_chain_fmt,
33
telemetry::spawn_blocking_with_tracing,
4+
authentication::AuthError;
45
};
56
use actix_web::http::header::{HeaderMap, HeaderValue};
67
use actix_web::http::{header, StatusCode};
@@ -10,6 +11,7 @@ use argon2::{Argon2, PasswordHash, PasswordVerifier};
1011
use secrecy::{ExposeSecret, Secret};
1112
use sqlx::PgPool;
1213

14+
1315
#[derive(serde::Deserialize)]
1416
pub struct BodyData {
1517
title: String,
@@ -156,16 +158,29 @@ async fn validate_credentials(
156158
credentials: Credentials,
157159
pool: &PgPool,
158160
) -> Result<uuid::Uuid, PublishError> {
159-
let (user_id, expected_password_hash) = get_stored_credentials(&credentials.username, pool)
160-
.await
161-
.map_err(PublishError::UnexpectedError)?
162-
.ok_or_else(|| PublishError::AuthError(anyhow::anyhow!("Unknown username")))?;
163-
161+
let mut user_id = None;
162+
let mut expected_password_hash = Secret::new(
163+
"$argon2id$v=19$m=15000,t=2,p=1$\
164+
gZiV/M1gPc22ElAH/Jh1Hw$\
165+
CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
166+
.to_string(),
167+
);
168+
if let Some((stored_user_id, stored_password_hash)) =
169+
get_stored_credentials(&credentials.username, pool)
170+
.await
171+
.map_err(PublishError::UnexpectedError)?
172+
{
173+
user_id = Some(stored_user_id);
174+
expected_password_hash = stored_password_hash;
175+
}
164176
spawn_blocking_with_tracing(move || {
165177
verify_password_hash(expected_password_hash, credentials.password)
166-
});
178+
})
179+
.await
180+
.context("Failed to spawn blocking task.")
181+
.map_err(PublishError::UnexpectedError)??;
167182

168-
Ok(user_id)
183+
user_id.ok_or_else(|| PublishError::AuthError(anyhow::anyhow!("Unknown username.")))
169184
}
170185

171186
#[tracing::instrument(

Diff for: src/session_state.rs

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
use actix_session::SessionExt;
2+
use actix_session::{Session, SessionGetError, SessionInsertError};
3+
use actix_web::dev::Payload;
4+
use actix_web::{FromRequest, HttpRequest};
5+
use std::future::{ready, Ready};
6+
use uuid::Uuid;
7+
8+
pub struct TypedSession(Session);
9+
10+
impl TypedSession {
11+
const USER_ID_KEY: &'static str = "user_id";
12+
13+
pub fn renew(&self) {
14+
self.0.renew();
15+
}
16+
17+
pub fn insert_user_id(&self, user_id: Uuid) -> Result<(), SessionInsertError> {
18+
self.0.insert(Self::USER_ID_KEY, user_id)
19+
}
20+
21+
pub fn get_user_id(&self) -> Result<Option<Uuid>, SessionGetError> {
22+
self.0.get(Self::USER_ID_KEY)
23+
}
24+
25+
pub fn log_out(self) {
26+
self.0.purge()
27+
}
28+
}
29+
30+
impl FromRequest for TypedSession {
31+
type Error = <Session as FromRequest>::Error;
32+
type Future = Ready<Result<TypedSession, Self::Error>>;
33+
34+
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
35+
ready(Ok(TypedSession(req.get_session())))
36+
}
37+
}

0 commit comments

Comments
 (0)