Skip to content

Commit 40b733d

Browse files
committed
chore: added user authentication
1 parent 7dd5289 commit 40b733d

File tree

6 files changed

+93
-10
lines changed

6 files changed

+93
-10
lines changed

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ reqwest = { version = "0.11.18", default-features = false, features = ["json", "
3333
rand = { version = "0.8", features = ["std_rng"] }
3434
thiserror = "1"
3535
anyhow = "1"
36+
base64 = "0.13.0"
3637

3738
[dev-dependencies]
3839
once_cell = "1"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Add migration script here
2+
CREATE TABLE users(
3+
user_id uuid PRIMARY KEY,
4+
username TEXT NOT NULL UNIQUE,
5+
password TEXT NOT NULL
6+
);

src/routes/newsletters.rs

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use crate::{domain::SubscriberEmail, email_client::EmailClient, routes::error_chain_fmt};
2-
use actix_web::http::StatusCode;
3-
use actix_web::{web, HttpResponse, ResponseError};
2+
use actix_web::http::header::{HeaderMap, HeaderValue};
3+
use actix_web::http::{header, StatusCode};
4+
use actix_web::{web, HttpRequest, HttpResponse, ResponseError};
45
use anyhow::Context;
6+
use secrecy::{ExposeSecret, Secret};
57
use sqlx::PgPool;
68

79
#[derive(serde::Deserialize)]
@@ -39,14 +41,26 @@ pub struct Content {
3941

4042
#[derive(thiserror::Error)]
4143
pub enum PublishError {
44+
#[error("Authentication failed")]
45+
AuthError(#[source] anyhow::Error),
4246
#[error(transparent)]
4347
UnexpectedError(#[from] anyhow::Error),
4448
}
4549

4650
impl ResponseError for PublishError {
47-
fn status_code(&self) -> StatusCode {
51+
fn error_response(&self) -> HttpResponse {
4852
match self {
49-
PublishError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
53+
PublishError::UnexpectedError(_) => {
54+
HttpResponse::new(StatusCode::INTERNAL_SERVER_ERROR)
55+
}
56+
PublishError::AuthError(_) => {
57+
let mut response = HttpResponse::new(StatusCode::UNAUTHORIZED);
58+
let header_value = HeaderValue::from_str(r#"Basic realm="publish""#).unwrap();
59+
response
60+
.headers_mut()
61+
.insert(header::WWW_AUTHENTICATE, header_value);
62+
response
63+
}
5064
}
5165
}
5266
}
@@ -61,7 +75,9 @@ pub async fn publish_newsletter(
6175
body: web::Json<BodyData>,
6276
pool: web::Data<PgPool>,
6377
email_client: web::Data<EmailClient>,
78+
request: HttpRequest,
6479
) -> Result<HttpResponse, PublishError> {
80+
let credentials = basic_authentication(request.headers()).map_err(PublishError::AuthError)?;
6581
let subscribers = get_confirmed_subscribers(&pool).await?;
6682
for subscriber in subscribers {
6783
match subscriber {
@@ -86,3 +102,38 @@ pub async fn publish_newsletter(
86102
}
87103
Ok(HttpResponse::Ok().finish())
88104
}
105+
106+
struct Credentials {
107+
username: String,
108+
password: Secret<String>,
109+
}
110+
111+
fn basic_authentication(headers: &HeaderMap) -> Result<Credentials, anyhow::Error> {
112+
let header_value = headers
113+
.get("Authorization")
114+
.context("Missing authorization header")?
115+
.to_str()
116+
.context("The authorization header value is invalid UTF-8")?;
117+
118+
let base64_encoded_segment = header_value
119+
.strip_prefix("Basic ")
120+
.context("The Authorization scheme was not 'Basic'. ")?;
121+
let decoded_bytes = base64::decode_config(base64_encoded_segment, base64::STANDARD)
122+
.context("Failed to base64 decode 'Basic' credentials.")?;
123+
let decoded_credentials =
124+
String::from_utf8(decoded_bytes).context("The decoded credentials are not valid UTF-8.")?;
125+
126+
let mut credentials = decoded_credentials.splitn(2, ':');
127+
let username = credentials
128+
.next()
129+
.ok_or_else(|| anyhow::anyhow!("The username is missing."))?
130+
.to_owned();
131+
let password = credentials
132+
.next()
133+
.ok_or_else(|| anyhow::anyhow!("The password is missing. Username: {}", username))?;
134+
135+
Ok(Credentials {
136+
username,
137+
password: Secret::new(password.to_owned()),
138+
})
139+
}

tests/api/helpers.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ impl TestApp {
6868
pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response {
6969
reqwest::Client::new()
7070
.post(&format!("{}/newsletters", &self.address))
71+
.basic_auth(Uuid::new_v4().to_string(), Some(Uuid::new_v4().to_string()))
7172
.json(&body)
7273
.send()
7374
.await

tests/api/newsletter.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,26 @@ async fn create_confirmed_subscriber(app: &TestApp) {
124124
.error_for_status()
125125
.unwrap();
126126
}
127+
128+
#[tokio::test]
129+
async fn requests_missing_authorization_are_rejected() {
130+
let app = spawn_app().await;
131+
let response = reqwest::Client::new()
132+
.post(&format!("{}/newsletters", &app.address))
133+
.json(&serde_json::json!({
134+
"title": "Newsletter title",
135+
"content": {
136+
"text": "Newsletter content",
137+
"html": "<p>Newsletter content</p>",
138+
}
139+
}))
140+
.send()
141+
.await
142+
.expect("Failed to execute request.");
143+
144+
assert_eq!(response.status().as_u16(), 401);
145+
assert_eq!(
146+
r#"Basic realm="publish""#,
147+
response.headers()["WWW-Authenticate"]
148+
);
149+
}

0 commit comments

Comments
 (0)