use std::{ops::Deref, sync::Arc};
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use error_stack::{Report, ResultExt};
use serde_json::json;
use sqlx::PgPool;
use tower_cookies::Cookies;
use tracing::instrument;
use uuid::Uuid;
use super::{sessions::SessionBackend, AuthError, EmailAndPassword, UserId};
use crate::errors::FormDataResponse;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HashedPassword(pub String);
impl Deref for HashedPassword {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub async fn new_hash(password: String) -> Result<HashedPassword, AuthError> {
let salt = uuid::Uuid::new_v4();
hash_password(password, salt).await
}
#[instrument]
async fn hash_password(password: String, salt: Uuid) -> Result<HashedPassword, AuthError> {
let hash = tokio::task::spawn_blocking(move || {
let saltstring = SaltString::encode_b64(salt.as_bytes())
.map_err(|e| AuthError::PasswordHasherError(e.to_string()))?;
let hash = Argon2::default()
.hash_password(password.as_bytes(), saltstring.as_salt())
.map_err(|e| AuthError::PasswordHasherError(e.to_string()))?;
Ok::<_, AuthError>(hash.to_string())
})
.await
.map_err(|e| AuthError::PasswordHasherError(e.to_string()))??;
Ok(HashedPassword(hash))
}
pub async fn verify_password(password: String, hash_str: HashedPassword) -> Result<(), AuthError> {
tokio::task::spawn_blocking(move || {
let hash = PasswordHash::new(&hash_str)
.map_err(|e| AuthError::PasswordHasherError(e.to_string()))?;
Argon2::default()
.verify_password(password.as_bytes(), &hash)
.map_err(|_| AuthError::IncorrectPassword)
})
.await
.map_err(|e| AuthError::PasswordHasherError(e.to_string()))??;
Ok(())
}
pub async fn lookup_user_from_email_and_password(
db: &PgPool,
email_and_password: EmailAndPassword,
) -> Result<UserId, Report<AuthError>> {
if email_and_password.password.is_empty() {
Err(AuthError::Unauthenticated)?;
}
let user_info = sqlx::query!(
r#"SELECT user_id as "user_id: UserId", password_hash, email_logins.verified
FROM email_logins
JOIN users ON users.id = email_logins.user_id
WHERE email_logins.email = $1"#,
email_and_password.email
)
.fetch_optional(db)
.await
.change_context(AuthError::Db)?
.ok_or(AuthError::UserNotFound)?;
let password_hash = HashedPassword(user_info.password_hash.unwrap_or_default());
verify_password(email_and_password.password, password_hash).await?;
if !user_info.verified {
return Err(Report::new(AuthError::NotVerified))?;
}
Ok(user_info.user_id)
}
pub async fn login_with_password(
session_backend: &SessionBackend,
cookies: &Cookies,
email_and_password: EmailAndPassword,
) -> Result<(), Report<AuthError>> {
let user_id =
lookup_user_from_email_and_password(&session_backend.db, email_and_password.clone())
.await
.attach_lazy(|| {
FormDataResponse::new(Arc::new(json!({ "email": email_and_password.email })))
})?;
session_backend
.create_session(&cookies, &user_id)
.await
.change_context(AuthError::SessionBackend)?;
Ok(())
}
pub async fn create_reset_token(db: &sqlx::PgPool, email: &str) -> Result<Uuid, Report<AuthError>> {
let token = Uuid::new_v4();
let result = sqlx::query!(
"UPDATE email_logins
SET reset_token = $2,
reset_expires_at = now() + '1 hour'::interval
WHERE email = $1",
email,
&token
)
.execute(db)
.await
.change_context(AuthError::Db)?;
if result.rows_affected() == 0 {
return Err(Report::new(AuthError::Unauthenticated));
}
Ok(token)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[cfg_attr(
not(any(feature = "test_slow", feature = "test_password")),
ignore = "slow password test"
)]
async fn good_password() -> Result<(), AuthError> {
let hash = new_hash("abcdef".into()).await?;
verify_password("abcdef".to_string(), hash).await
}
#[tokio::test]
#[cfg_attr(
not(any(feature = "test_slow", feature = "test_password")),
ignore = "slow password test"
)]
async fn bad_password() -> Result<(), AuthError> {
let hash = new_hash("abcdef".into()).await?;
verify_password("abcdefg".to_string(), hash)
.await
.expect_err("non-matching password");
Ok(())
}
#[tokio::test]
#[cfg_attr(
not(any(feature = "test_slow", feature = "test_password")),
ignore = "slow password test"
)]
async fn unique_password_salt() {
let p1 = new_hash("abc".into()).await.unwrap();
let p2 = new_hash("abc".into()).await.unwrap();
assert_ne!(p1, p2);
}
}