Skip to main content

filigree/auth/
password.rs

1use std::{ops::Deref, sync::Arc};
2
3use argon2::{
4    password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
5    Argon2,
6};
7use error_stack::{Report, ResultExt};
8use serde_json::json;
9use sqlx::PgPool;
10use tower_cookies::Cookies;
11use tracing::instrument;
12use uuid::Uuid;
13
14use super::{sessions::SessionBackend, AuthError, EmailAndPassword, UserId};
15use crate::errors::FormDataResponse;
16
17/// A wrapper around a hashed password, to help avoid passing a plaintext password where a hashed
18/// password is expected.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct HashedPassword(pub String);
21
22impl Deref for HashedPassword {
23    type Target = String;
24
25    fn deref(&self) -> &Self::Target {
26        &self.0
27    }
28}
29
30/// Hash a password using a randomly-generated salt value
31pub async fn new_hash(password: String) -> Result<HashedPassword, AuthError> {
32    let salt = uuid::Uuid::new_v4();
33    hash_password(password, salt).await
34}
35
36#[instrument]
37async fn hash_password(password: String, salt: Uuid) -> Result<HashedPassword, AuthError> {
38    let hash = tokio::task::spawn_blocking(move || {
39        let saltstring = SaltString::encode_b64(salt.as_bytes())
40            .map_err(|e| AuthError::PasswordHasherError(e.to_string()))?;
41
42        let hash = Argon2::default()
43            .hash_password(password.as_bytes(), saltstring.as_salt())
44            .map_err(|e| AuthError::PasswordHasherError(e.to_string()))?;
45
46        Ok::<_, AuthError>(hash.to_string())
47    })
48    .await
49    .map_err(|e| AuthError::PasswordHasherError(e.to_string()))??;
50
51    Ok(HashedPassword(hash))
52}
53
54/// Verify that the given password matches the stored hash
55pub async fn verify_password(password: String, hash_str: HashedPassword) -> Result<(), AuthError> {
56    tokio::task::spawn_blocking(move || {
57        let hash = PasswordHash::new(&hash_str)
58            .map_err(|e| AuthError::PasswordHasherError(e.to_string()))?;
59
60        Argon2::default()
61            .verify_password(password.as_bytes(), &hash)
62            .map_err(|_| AuthError::IncorrectPassword)
63    })
64    .await
65    .map_err(|e| AuthError::PasswordHasherError(e.to_string()))??;
66
67    Ok(())
68}
69
70/// Look up a user and verify the password, and check that the user is verified.
71pub async fn lookup_user_from_email_and_password(
72    db: &PgPool,
73    email_and_password: EmailAndPassword,
74) -> Result<UserId, Report<AuthError>> {
75    if email_and_password.password.is_empty() {
76        // This should really be caught earlier, but just make sure that nothing weird happens if
77        // the user doesn't have a password (e.g. OAuth only) and someone tries to log in with an empty password.
78        Err(AuthError::Unauthenticated)?;
79    }
80
81    let user_info = sqlx::query!(
82        r#"SELECT user_id as "user_id: UserId", password_hash, email_logins.verified
83        FROM email_logins
84        JOIN users ON users.id = email_logins.user_id
85        WHERE email_logins.email = $1"#,
86        email_and_password.email
87    )
88    .fetch_optional(db)
89    .await
90    .change_context(AuthError::Db)?
91    .ok_or(AuthError::UserNotFound)?;
92
93    let password_hash = HashedPassword(user_info.password_hash.unwrap_or_default());
94
95    verify_password(email_and_password.password, password_hash).await?;
96
97    if !user_info.verified {
98        return Err(Report::new(AuthError::NotVerified))?;
99    }
100
101    Ok(user_info.user_id)
102}
103
104/// Lookup a user based on the email/password, and create a new session.
105/// This returns an error if the email is not found, the password is incorrect, or if the user is
106/// not verified.
107pub async fn login_with_password(
108    session_backend: &SessionBackend,
109    cookies: &Cookies,
110    email_and_password: EmailAndPassword,
111) -> Result<(), Report<AuthError>> {
112    let user_id =
113        lookup_user_from_email_and_password(&session_backend.db, email_and_password.clone())
114            .await
115            .attach_lazy(|| {
116                FormDataResponse::new(Arc::new(json!({ "email": email_and_password.email })))
117            })?;
118
119    session_backend
120        .create_session(&cookies, &user_id)
121        .await
122        .change_context(AuthError::SessionBackend)?;
123    Ok(())
124}
125
126/// Create a password reset token
127pub async fn create_reset_token(db: &sqlx::PgPool, email: &str) -> Result<Uuid, Report<AuthError>> {
128    let token = Uuid::new_v4();
129
130    let result = sqlx::query!(
131        "UPDATE email_logins
132        SET reset_token = $2,
133            reset_expires_at = now() + '1 hour'::interval
134        WHERE email = $1",
135        email,
136        &token
137    )
138    .execute(db)
139    .await
140    .change_context(AuthError::Db)?;
141
142    if result.rows_affected() == 0 {
143        return Err(Report::new(AuthError::Unauthenticated));
144    }
145
146    Ok(token)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[tokio::test]
154    #[cfg_attr(
155        not(any(feature = "test_slow", feature = "test_password")),
156        ignore = "slow password test"
157    )]
158    async fn good_password() -> Result<(), AuthError> {
159        let hash = new_hash("abcdef".into()).await?;
160        verify_password("abcdef".to_string(), hash).await
161    }
162
163    #[tokio::test]
164    #[cfg_attr(
165        not(any(feature = "test_slow", feature = "test_password")),
166        ignore = "slow password test"
167    )]
168    async fn bad_password() -> Result<(), AuthError> {
169        let hash = new_hash("abcdef".into()).await?;
170        verify_password("abcdefg".to_string(), hash)
171            .await
172            .expect_err("non-matching password");
173        Ok(())
174    }
175
176    /// Test that the salt actually results in a different hash every time.
177    #[tokio::test]
178    #[cfg_attr(
179        not(any(feature = "test_slow", feature = "test_password")),
180        ignore = "slow password test"
181    )]
182    async fn unique_password_salt() {
183        let p1 = new_hash("abc".into()).await.unwrap();
184        let p2 = new_hash("abc".into()).await.unwrap();
185        assert_ne!(p1, p2);
186    }
187}