pylon_plugin/builtin/
password_auth.rs1use std::collections::HashMap;
2use std::sync::{Mutex, OnceLock};
3
4use argon2::password_hash::{rand_core::OsRng, SaltString};
5use argon2::{Argon2, PasswordHasher, PasswordVerifier};
6
7use crate::Plugin;
8
9fn dummy_hash() -> &'static str {
14 static CELL: OnceLock<String> = OnceLock::new();
15 CELL.get_or_init(|| {
16 hash_password("dummy-password-for-timing-equalization")
20 })
21}
22
23#[derive(Debug, Clone)]
25#[allow(dead_code)]
26struct PasswordEntry {
27 user_id: String,
28 email: String,
29 hash: String,
31}
32
33pub struct PasswordAuthPlugin {
39 entries: Mutex<HashMap<String, PasswordEntry>>,
40}
41
42impl PasswordAuthPlugin {
43 pub fn new() -> Self {
44 Self {
45 entries: Mutex::new(HashMap::new()),
46 }
47 }
48
49 pub fn register(&self, email: &str, password: &str, user_id: &str) -> Result<(), String> {
51 let mut entries = self.entries.lock().unwrap();
52 if entries.contains_key(email) {
53 return Err("Email already registered".into());
54 }
55
56 let hash = hash_password(password);
57
58 entries.insert(
59 email.to_string(),
60 PasswordEntry {
61 user_id: user_id.to_string(),
62 email: email.to_string(),
63 hash,
64 },
65 );
66
67 Ok(())
68 }
69
70 pub fn verify(&self, email: &str, password: &str) -> Option<String> {
77 let entries = self.entries.lock().unwrap();
78 match entries.get(email) {
79 Some(entry) => {
80 if verify_password(password, &entry.hash) {
81 Some(entry.user_id.clone())
82 } else {
83 None
84 }
85 }
86 None => {
87 let _ = verify_password(password, dummy_hash());
92 None
93 }
94 }
95 }
96
97 pub fn change_password(
99 &self,
100 email: &str,
101 old_password: &str,
102 new_password: &str,
103 ) -> Result<(), String> {
104 let mut entries = self.entries.lock().unwrap();
105 let entry = entries.get_mut(email).ok_or("User not found")?;
106
107 if !verify_password(old_password, &entry.hash) {
108 return Err("Incorrect password".into());
109 }
110
111 entry.hash = hash_password(new_password);
112 Ok(())
113 }
114
115 pub fn is_registered(&self, email: &str) -> bool {
117 self.entries.lock().unwrap().contains_key(email)
118 }
119
120 pub fn reset_password(&self, email: &str, new_password: &str) -> Result<(), String> {
122 let mut entries = self.entries.lock().unwrap();
123 let entry = entries.get_mut(email).ok_or("User not found")?;
124 entry.hash = hash_password(new_password);
125 Ok(())
126 }
127}
128
129impl Plugin for PasswordAuthPlugin {
130 fn name(&self) -> &str {
131 "password-auth"
132 }
133}
134
135pub fn hash_password(password: &str) -> String {
140 let salt = SaltString::generate(&mut OsRng);
141 let argon2 = Argon2::default();
142 argon2
143 .hash_password(password.as_bytes(), &salt)
144 .expect("Argon2 hash should succeed")
145 .to_string()
146}
147
148pub fn verify_password(password: &str, hash: &str) -> bool {
153 use argon2::PasswordHash;
154 let parsed = match PasswordHash::new(hash) {
155 Ok(h) => h,
156 Err(_) => return false,
157 };
158 Argon2::default()
159 .verify_password(password.as_bytes(), &parsed)
160 .is_ok()
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn register_and_verify() {
169 let plugin = PasswordAuthPlugin::new();
170 plugin
171 .register("alice@test.com", "password123", "user-1")
172 .unwrap();
173
174 let user_id = plugin.verify("alice@test.com", "password123").unwrap();
175 assert_eq!(user_id, "user-1");
176 }
177
178 #[test]
179 fn wrong_password_rejected() {
180 let plugin = PasswordAuthPlugin::new();
181 plugin
182 .register("alice@test.com", "password123", "user-1")
183 .unwrap();
184
185 assert!(plugin.verify("alice@test.com", "wrong").is_none());
186 }
187
188 #[test]
189 fn unknown_email_rejected() {
190 let plugin = PasswordAuthPlugin::new();
191 assert!(plugin.verify("nobody@test.com", "password").is_none());
192 }
193
194 #[test]
195 fn duplicate_email_rejected() {
196 let plugin = PasswordAuthPlugin::new();
197 plugin
198 .register("alice@test.com", "pass1", "user-1")
199 .unwrap();
200 let result = plugin.register("alice@test.com", "pass2", "user-2");
201 assert!(result.is_err());
202 }
203
204 #[test]
205 fn change_password() {
206 let plugin = PasswordAuthPlugin::new();
207 plugin
208 .register("alice@test.com", "old-pass", "user-1")
209 .unwrap();
210
211 plugin
212 .change_password("alice@test.com", "old-pass", "new-pass")
213 .unwrap();
214
215 assert!(plugin.verify("alice@test.com", "old-pass").is_none());
216 assert!(plugin.verify("alice@test.com", "new-pass").is_some());
217 }
218
219 #[test]
220 fn change_password_wrong_old() {
221 let plugin = PasswordAuthPlugin::new();
222 plugin
223 .register("alice@test.com", "password", "user-1")
224 .unwrap();
225
226 let result = plugin.change_password("alice@test.com", "wrong", "new");
227 assert!(result.is_err());
228 }
229
230 #[test]
231 fn reset_password() {
232 let plugin = PasswordAuthPlugin::new();
233 plugin
234 .register("alice@test.com", "old-pass", "user-1")
235 .unwrap();
236
237 plugin
238 .reset_password("alice@test.com", "reset-pass")
239 .unwrap();
240 assert!(plugin.verify("alice@test.com", "reset-pass").is_some());
241 }
242
243 #[test]
244 fn is_registered() {
245 let plugin = PasswordAuthPlugin::new();
246 assert!(!plugin.is_registered("alice@test.com"));
247 plugin.register("alice@test.com", "pass", "user-1").unwrap();
248 assert!(plugin.is_registered("alice@test.com"));
249 }
250
251 #[test]
252 fn hash_is_phc_format() {
253 let h = hash_password("test-password");
254 assert!(h.starts_with("$argon2"), "Expected PHC format, got: {}", h);
256 }
257
258 #[test]
259 fn same_password_different_hashes() {
260 let h1 = hash_password("password");
262 let h2 = hash_password("password");
263 assert_ne!(h1, h2);
264 assert!(verify_password("password", &h1));
266 assert!(verify_password("password", &h2));
267 }
268}