hermod_api/services/
auth.rs

1//! Contains methods used for user authentication and authorization.
2
3use actix_web::http::HeaderMap;
4use actix_web::HttpRequest;
5use anyhow::Context;
6use argon2::{Argon2, PasswordHash, PasswordVerifier};
7use sqlx::PgPool;
8
9use crate::{db::User, handlers::ApplicationError};
10
11/// Validates a HTTP request with request headers
12/// conforming to the [Basic Auth RFC](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication).
13pub async fn validate_request_with_basic_auth(
14    request: HttpRequest,
15    pool: &PgPool,
16) -> Result<User, AuthenticationError> {
17    let credentials =
18        extract_from_headers(request.headers()).map_err(|_| AuthenticationError::InvalidHeaders)?;
19    let user = validate_credentials(credentials, pool).await?;
20    Ok(user)
21}
22
23#[tracing::instrument(name = "services::auth::validate_credentials", skip(credentials, pool), fields(
24    username=%credentials.username,
25))]
26async fn validate_credentials(
27    credentials: Credentials,
28    pool: &PgPool,
29) -> Result<User, AuthenticationError> {
30    let mut user = None;
31    let mut expected_password_hash = "$argon2id$v=19$m=15000,t=2,p=1$\
32        gZiV/M1gPc22ElAH/Jh1Hw$\
33        CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
34        .to_string();
35
36    if let Some((stored_user, stored_password_hash)) =
37        get_stored_credentials(&credentials.username, pool)
38            .await
39            .map_err(AuthenticationError::UnexpectedError)?
40    {
41        user = Some(stored_user);
42        expected_password_hash = stored_password_hash;
43    }
44
45    actix_web::rt::task::spawn_blocking(move || {
46        verify_password_hash(expected_password_hash, credentials.password)
47    })
48    .await
49    .context("Failed to spawn blocking task.")
50    .map_err(AuthenticationError::UnexpectedError)??;
51
52    user.ok_or(AuthenticationError::InvalidCredentials)
53}
54
55async fn get_stored_credentials(
56    username: &str,
57    pool: &PgPool,
58) -> Result<Option<(User, String)>, anyhow::Error> {
59    let username = username.to_lowercase();
60    let row = sqlx::query_as!(
61        User,
62        r#"
63        SELECT *
64        FROM account
65        WHERE username = $1
66        "#,
67        username,
68    )
69    .fetch_optional(pool)
70    .await
71    .context("Failed to performed a query to retrieve stored credentials.")?
72    .map(|row| (row.clone(), row.password));
73    Ok(row)
74}
75
76fn verify_password_hash(
77    expected_password_hash: String,
78    password_candidate: String,
79) -> Result<(), AuthenticationError> {
80    let expected_password_hash = PasswordHash::new(&expected_password_hash)
81        .context("Failed to parse hash in PHC string format.")
82        .map_err(AuthenticationError::UnexpectedError)?;
83
84    Argon2::default()
85        .verify_password(password_candidate.as_bytes(), &expected_password_hash)
86        .context("Invalid password.")
87        .map_err(|_| AuthenticationError::InvalidCredentials)
88}
89
90fn extract_from_headers(headers: &HeaderMap) -> Result<Credentials, anyhow::Error> {
91    let header_value = headers
92        .get("Authorization")
93        .context("The 'Authorization' header was missing.")?
94        .to_str()
95        .context("The 'Authorization' header was not a valid UTF-8 string.")?;
96    let base64_encoded_segment = header_value
97        .strip_prefix("Basic ")
98        .context("The authorization scheme was not 'Basic'.")?;
99    let decoded_bytes = base64::decode_config(base64_encoded_segment, base64::STANDARD)
100        .context("Failed to base64-decode 'Basic' credentials.")?;
101    let decoded_credentials = String::from_utf8(decoded_bytes)
102        .context("The decoded credential string is not valid UTF-8.")?;
103
104    let mut credentials = decoded_credentials.splitn(2, ':');
105    let username = credentials
106        .next()
107        .ok_or_else(|| anyhow::anyhow!("A username must be provided in 'Basic' auth."))?
108        .to_string();
109    let password = credentials
110        .next()
111        .ok_or_else(|| anyhow::anyhow!("A password must be provided in 'Basic' auth."))?
112        .to_string();
113
114    Ok(Credentials { username, password })
115}
116
117struct Credentials {
118    username: String,
119    password: String,
120}
121
122/// Error derived while handling an authentication request
123#[derive(thiserror::Error)]
124pub enum AuthenticationError {
125    #[error(transparent)]
126    UnexpectedError(#[from] anyhow::Error),
127    #[error("Invalid headers.")]
128    InvalidHeaders,
129    #[error("Invalid credentials.")]
130    InvalidCredentials,
131    #[error("Not logged in.")]
132    Unauthorized,
133}
134
135impl std::fmt::Debug for AuthenticationError {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        crate::services::error::error_chain_fmt(self, f)
138    }
139}
140
141impl From<AuthenticationError> for ApplicationError {
142    fn from(e: AuthenticationError) -> Self {
143        Self::AuthError(e)
144    }
145}