@jpgilldev / steid

5.8 KBRaw
1use crate::domain::{Actor, DomainError, Email, repository::UserRepository};
2
3use 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.
10const 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.
18pub 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)]
46mod 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}