5.8 KBRaw
| 1 | use crate::domain::{Actor, DomainError, Email, repository::UserRepository}; |
| 2 | |
| 3 | use super::{error::Result, port::PasswordHasher}; |
| 4 | |
| 5 | /// A hash to verify against when no user matched. |
| 6 | /// |
| 7 | /// Without this, an unknown email returns before hashing and a known one doesn't, so |
| 8 | /// response time tells an attacker which addresses have accounts. Verifying a dummy |
| 9 | /// keeps both paths doing the same work. |
| 10 | const ABSENT_USER_HASH: &str = |
| 11 | "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHRzYWx0c2FsdA$YXNkZmFzZGZhc2RmYXNkZmFzZGZhc2Rm"; |
| 12 | |
| 13 | /// Verifies credentials and returns the resulting actor. |
| 14 | /// |
| 15 | /// Every failure — unknown email, malformed email, wrong password — yields |
| 16 | /// [`DomainError::InvalidCredentials`]. Distinguishing them would let anyone probe |
| 17 | /// which addresses have accounts. |
| 18 | pub async fn login( |
| 19 | email: &str, |
| 20 | password: &str, |
| 21 | users: &impl UserRepository, |
| 22 | hasher: &impl PasswordHasher, |
| 23 | ) -> Result<Actor> { |
| 24 | let user = match Email::new(email) { |
| 25 | Ok(email) => users.find_by_email(&email).await?, |
| 26 | // A malformed address can't match anyone, but still costs the same work. |
| 27 | Err(_) => None, |
| 28 | }; |
| 29 | |
| 30 | let Some(user) = user else { |
| 31 | let _ = hasher.verify( |
| 32 | password, |
| 33 | &crate::domain::PasswordHash::from_trusted(ABSENT_USER_HASH), |
| 34 | ); |
| 35 | return Err(DomainError::InvalidCredentials.into()); |
| 36 | }; |
| 37 | |
| 38 | if hasher.verify(password, &user.password_hash)? { |
| 39 | Ok(Actor::User(user.id)) |
| 40 | } else { |
| 41 | Err(DomainError::InvalidCredentials.into()) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | #[cfg(test)] |
| 46 | mod tests { |
| 47 | use super::*; |
| 48 | use crate::{ |
| 49 | application::error::Error, |
| 50 | domain::{OrgId, PasswordHash, User, UserId}, |
| 51 | infrastructure::{password::StubHasher, repository::InMemoryUserRepo}, |
| 52 | }; |
| 53 | |
| 54 | async fn repo_with_user(email: &str, password: &str) -> (InMemoryUserRepo, UserId) { |
| 55 | let repo = InMemoryUserRepo::new(); |
| 56 | let hasher = StubHasher::new(); |
| 57 | let user = User::new( |
| 58 | UserId::generate(), |
| 59 | Email::new(email).expect("valid email"), |
| 60 | hasher.hash(password).expect("hash"), |
| 61 | OrgId::generate(), |
| 62 | ); |
| 63 | let id = user.id.clone(); |
| 64 | repo.save(&user).await.expect("save"); |
| 65 | (repo, id) |
| 66 | } |
| 67 | |
| 68 | fn assert_invalid_credentials(error: Error) { |
| 69 | assert!( |
| 70 | matches!(error, Error::Domain(DomainError::InvalidCredentials)), |
| 71 | "expected InvalidCredentials, got {error:?}" |
| 72 | ); |
| 73 | } |
| 74 | |
| 75 | #[tokio::test] |
| 76 | async fn correct_credentials_yield_the_user() { |
| 77 | let (users, id) = repo_with_user("dev@example.com", "hunter2").await; |
| 78 | |
| 79 | let actor = login("dev@example.com", "hunter2", &users, &StubHasher::new()) |
| 80 | .await |
| 81 | .expect("login"); |
| 82 | |
| 83 | assert_eq!(actor, Actor::User(id)); |
| 84 | } |
| 85 | |
| 86 | #[tokio::test] |
| 87 | async fn the_email_is_normalised_before_lookup() { |
| 88 | let (users, id) = repo_with_user("dev@example.com", "hunter2").await; |
| 89 | |
| 90 | let actor = login(" DEV@Example.COM ", "hunter2", &users, &StubHasher::new()) |
| 91 | .await |
| 92 | .expect("login"); |
| 93 | |
| 94 | assert_eq!(actor, Actor::User(id)); |
| 95 | } |
| 96 | |
| 97 | #[tokio::test] |
| 98 | async fn a_wrong_password_is_rejected() { |
| 99 | let (users, _) = repo_with_user("dev@example.com", "hunter2").await; |
| 100 | |
| 101 | let error = login("dev@example.com", "wrong", &users, &StubHasher::new()) |
| 102 | .await |
| 103 | .expect_err("should reject"); |
| 104 | |
| 105 | assert_invalid_credentials(error); |
| 106 | } |
| 107 | |
| 108 | #[tokio::test] |
| 109 | async fn an_unknown_email_is_rejected_the_same_way() { |
| 110 | let (users, _) = repo_with_user("dev@example.com", "hunter2").await; |
| 111 | |
| 112 | let error = login("nobody@example.com", "hunter2", &users, &StubHasher::new()) |
| 113 | .await |
| 114 | .expect_err("should reject"); |
| 115 | |
| 116 | assert_invalid_credentials(error); |
| 117 | } |
| 118 | |
| 119 | #[tokio::test] |
| 120 | async fn a_malformed_email_is_rejected_the_same_way() { |
| 121 | let (users, _) = repo_with_user("dev@example.com", "hunter2").await; |
| 122 | |
| 123 | let error = login("not-an-email", "hunter2", &users, &StubHasher::new()) |
| 124 | .await |
| 125 | .expect_err("should reject"); |
| 126 | |
| 127 | assert_invalid_credentials(error); |
| 128 | } |
| 129 | |
| 130 | #[tokio::test] |
| 131 | async fn an_empty_password_does_not_match_a_stored_hash() { |
| 132 | let (users, _) = repo_with_user("dev@example.com", "hunter2").await; |
| 133 | |
| 134 | let error = login("dev@example.com", "", &users, &StubHasher::new()) |
| 135 | .await |
| 136 | .expect_err("should reject"); |
| 137 | |
| 138 | assert_invalid_credentials(error); |
| 139 | } |
| 140 | |
| 141 | /// Guards the timing defence. If `ABSENT_USER_HASH` stops being parseable, the |
| 142 | /// real hasher returns early instead of doing the work, and the unknown-email path |
| 143 | /// becomes measurably faster than the known-email one again — silently. |
| 144 | #[test] |
| 145 | fn the_absent_user_hash_is_parseable_by_the_real_hasher() { |
| 146 | use crate::infrastructure::password::Argon2Hasher; |
| 147 | |
| 148 | let outcome = |
| 149 | Argon2Hasher::new().verify("anything", &PasswordHash::from_trusted(ABSENT_USER_HASH)); |
| 150 | |
| 151 | assert!( |
| 152 | matches!(outcome, Ok(false)), |
| 153 | "dummy hash must verify to Ok(false), got {outcome:?}" |
| 154 | ); |
| 155 | } |
| 156 | |
| 157 | #[tokio::test] |
| 158 | async fn a_corrupt_stored_hash_surfaces_rather_than_reading_as_a_bad_password() { |
| 159 | let users = InMemoryUserRepo::new(); |
| 160 | users |
| 161 | .save(&User::new( |
| 162 | UserId::generate(), |
| 163 | Email::new("dev@example.com").expect("valid email"), |
| 164 | PasswordHash::from_trusted("not-a-stub-hash"), |
| 165 | OrgId::generate(), |
| 166 | )) |
| 167 | .await |
| 168 | .expect("save"); |
| 169 | |
| 170 | let error = login("dev@example.com", "hunter2", &users, &StubHasher::new()) |
| 171 | .await |
| 172 | .expect_err("should fail"); |
| 173 | |
| 174 | assert!( |
| 175 | matches!(error, Error::Password(_)), |
| 176 | "a broken hash is an operational fault, not a failed login: got {error:?}" |
| 177 | ); |
| 178 | } |
| 179 | } |