1use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
8use sqlx::Connection;
9use sqlx::types::Uuid;
10use std::fmt::Debug;
11
12use super::CmdError;
13use crate::config::ConfigError;
14use crate::db::get_connection;
15use crate::error::ServerError;
16
17#[derive(Debug, Clone)]
21pub struct VerifiedUser {
22 pub user_id: Uuid,
23 pub ssh_enabled: bool,
24 pub generation: i32,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Refusal {
30 Quarantined,
32 Disabled,
33}
34
35pub async fn account_refusal(user_id: Uuid) -> Result<Option<Refusal>, ConfigError> {
43 let mut conn = get_connection().await.map_err(|err| {
44 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
45 ConfigError::DB
46 })?;
47
48 let Some(row) = sqlx::query!(
49 "SELECT quarantined_at, verified FROM users WHERE id = $1",
50 user_id
51 )
52 .fetch_optional(&mut *conn)
53 .await?
54 else {
55 return Ok(Some(Refusal::Disabled));
56 };
57
58 Ok(match (row.quarantined_at, row.verified) {
59 (_, false) => Some(Refusal::Disabled),
60 (Some(_), _) => Some(Refusal::Quarantined),
61 _ => None,
62 })
63}
64
65pub struct Account {
67 pub id: Uuid,
68 pub name: String,
69 pub email: String,
70 pub role: String,
71 pub photo: String,
72 pub verified: bool,
73 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
74 pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
75 pub refusal: Option<Refusal>,
76}
77
78pub async fn fetch_account(user_id: Uuid) -> Result<Option<Account>, ConfigError> {
86 let mut conn = get_connection().await.map_err(|err| {
87 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
88 ConfigError::DB
89 })?;
90
91 let row = sqlx::query!(
92 "SELECT id, user_name as name, email, user_role as role, photo, verified, \
93 quarantined_at, created_at, updated_at FROM users WHERE id = $1",
94 user_id
95 )
96 .fetch_optional(&mut *conn)
97 .await?;
98
99 Ok(row.map(|row| Account {
100 refusal: match (row.quarantined_at, row.verified) {
101 (_, false) => Some(Refusal::Disabled),
102 (Some(_), _) => Some(Refusal::Quarantined),
103 _ => None,
104 },
105 id: row.id,
106 name: row.name,
107 email: row.email,
108 role: row.role,
109 photo: row.photo,
110 verified: row.verified,
111 created_at: row.created_at,
112 updated_at: row.updated_at,
113 }))
114}
115
116pub async fn email_taken(email: &str) -> Result<bool, ConfigError> {
121 let mut conn = get_connection().await.map_err(|err| {
122 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
123 ConfigError::DB
124 })?;
125
126 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
127 .bind(email.to_ascii_lowercase())
128 .fetch_one(&mut *conn)
129 .await
130 .map_err(Into::into)
131}
132
133pub struct NewUser<'a> {
135 pub id: Uuid,
136 pub name: &'a str,
137 pub email: &'a str,
138 pub password_hash: &'a str,
139 pub database_name: &'a str,
140 pub jwt_public_key: &'a str,
141}
142
143pub async fn create_user(new: NewUser<'_>) -> Result<Account, ConfigError> {
155 let mut conn = get_connection().await.map_err(|err| {
156 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
157 ConfigError::DB
158 })?;
159
160 let row = sqlx::query!(
161 "INSERT INTO users (id,user_name,email,user_password,database_name,jwt_public_key,verified) \
162 VALUES ($1, $2, $3, $4, $5, $6, TRUE) \
163 RETURNING id, user_name as name, email, user_role as role, photo, verified, created_at, updated_at",
164 new.id,
165 new.name,
166 new.email.to_ascii_lowercase(),
167 new.password_hash,
168 new.database_name,
169 new.jwt_public_key
170 )
171 .fetch_one(&mut *conn)
172 .await?;
173
174 Ok(Account {
175 id: row.id,
176 name: row.name,
177 email: row.email,
178 role: row.role,
179 photo: row.photo,
180 verified: row.verified,
181 created_at: row.created_at,
182 updated_at: row.updated_at,
183 refusal: None,
184 })
185}
186
187pub async fn verify_user_password(
197 email: &str,
198 password: &str,
199) -> Result<Option<VerifiedUser>, ConfigError> {
200 let mut conn = get_connection().await.map_err(|err| {
201 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
202 ConfigError::DB
203 })?;
204 let Some(row) = sqlx::query_file!("sql/select/users/by_email.sql", email.to_ascii_lowercase())
205 .fetch_optional(&mut *conn)
206 .await?
207 else {
208 return Ok(None);
209 };
210 let Ok(parsed_hash) = PasswordHash::new(&row.password_hash) else {
211 return Ok(None);
212 };
213 let is_valid = Argon2::default()
214 .verify_password(password.as_bytes(), &parsed_hash)
215 .is_ok();
216 if !is_valid || !row.verified {
219 return Ok(None);
220 }
221 Ok(Some(VerifiedUser {
222 user_id: row.id,
223 ssh_enabled: row.ssh_enabled,
224 generation: row.session_generation,
225 }))
226}
227
228pub async fn change_password(user_id: Uuid, current: &str, new: &str) -> Result<(), CmdError> {
235 let hash = Argon2::default()
243 .hash_password(new.as_bytes())
244 .map_err(|_| CmdError::Server(ServerError::Creation))?
245 .to_string();
246
247 let mut conn = get_connection().await.map_err(|err| {
248 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
249 ConfigError::DB
250 })?;
251 let mut tx = conn.begin().await?;
252 let row = sqlx::query_file!("sql/select/users/by_id_password.sql", user_id)
253 .fetch_optional(&mut *tx)
254 .await?
255 .ok_or_else(|| CmdError::Args(t!("Invalid email or password").to_string()))?;
256
257 let verified = PasswordHash::new(&row.password_hash).is_ok_and(|parsed| {
258 Argon2::default()
259 .verify_password(current.as_bytes(), &parsed)
260 .is_ok()
261 });
262 if !verified || !row.verified {
263 return Err(CmdError::Args(t!("Invalid email or password").to_string()));
264 }
265
266 sqlx::query_file!("sql/update/users/set_password.sql", user_id, hash)
267 .execute(&mut *tx)
268 .await?;
269 tx.commit().await?;
270 Ok(())
271}
272
273#[cfg(test)]
274mod command_tests {
275 use super::*;
276 use crate::db::DB_POOL;
277 use argon2::{Argon2, PasswordHasher};
278 use sqlx::PgPool;
279 use supp_macro::local_db_sqlx_test;
280 use tokio::sync::OnceCell;
281
282 static CONTEXT: OnceCell<()> = OnceCell::const_new();
283
284 const CLI_GENERATED_HASH: &str = "$argon2id$v=19$m=4096,t=3,p=1$\
294aTltTkFWR2paUHY5Y2pYbVNlR2Rsdz09$QKPc9TsnwNUm7fgdz/JqzHyrYyo4/x2pMvehw5upb8g";
295
296 #[test]
297 fn a_hash_with_foreign_cost_parameters_still_verifies() {
298 let parsed = PasswordHash::new(CLI_GENERATED_HASH).expect("PHC string parses");
299
300 assert!(
301 Argon2::default()
302 .verify_password(b"correct horse", &parsed)
303 .is_ok(),
304 "the promotion runbook depends on a hash made with other parameters verifying"
305 );
306 assert!(
307 Argon2::default()
308 .verify_password(b"wrong horse", &parsed)
309 .is_err()
310 );
311 }
312
313 async fn setup() {
314 CONTEXT
315 .get_or_init(|| async {
316 #[cfg(feature = "testlog")]
317 let _ = env_logger::builder()
318 .is_test(true)
319 .filter_level(log::LevelFilter::Trace)
320 .try_init();
321 })
322 .await;
323 }
324
325 async fn insert_user_with_password(
326 id: Uuid,
327 email: &str,
328 plaintext: &str,
329 ) -> anyhow::Result<()> {
330 let hash = Argon2::default()
331 .hash_password(plaintext.as_bytes())
332 .unwrap()
333 .to_string();
334 let mut conn = get_connection().await.unwrap();
335 sqlx::query!(
336 "INSERT INTO users (
337 id, user_name, email, photo, verified,
338 user_password, user_role, database_name, created_at
339 ) VALUES (
340 $1, 'Test', $2, 'default.png', TRUE,
341 $3, 'user', 'db', NOW()
342 )",
343 id,
344 email,
345 hash,
346 )
347 .execute(&mut *conn)
348 .await?;
349 Ok(())
350 }
351
352 #[local_db_sqlx_test]
353 async fn verify_returns_user_on_correct_password(pool: PgPool) -> anyhow::Result<()> {
354 let uid = Uuid::new_v4();
355 insert_user_with_password(uid, "good@example.com", "hunter2").await?;
356 let v = verify_user_password("good@example.com", "hunter2")
357 .await?
358 .expect("match");
359 assert_eq!(v.user_id, uid);
360 assert!(!v.ssh_enabled);
361 }
362
363 #[local_db_sqlx_test]
364 async fn verify_returns_none_on_wrong_password(pool: PgPool) -> anyhow::Result<()> {
365 let uid = Uuid::new_v4();
366 insert_user_with_password(uid, "wrong@example.com", "hunter2").await?;
367 let v = verify_user_password("wrong@example.com", "nope").await?;
368 assert!(v.is_none());
369 }
370
371 #[local_db_sqlx_test]
372 async fn verify_returns_none_for_unknown_email(pool: PgPool) -> anyhow::Result<()> {
373 let _ = pool;
374 let v = verify_user_password("nobody@example.com", "whatever").await?;
375 assert!(v.is_none());
376 }
377
378 #[local_db_sqlx_test]
382 async fn verify_returns_none_for_a_disabled_user(pool: PgPool) -> anyhow::Result<()> {
383 let uid = Uuid::new_v4();
384 insert_user_with_password(uid, "off@example.com", "hunter2").await?;
385 assert!(
386 verify_user_password("off@example.com", "hunter2")
387 .await?
388 .is_some()
389 );
390
391 sqlx::query("UPDATE users SET verified = FALSE WHERE id = $1")
392 .bind(uid)
393 .execute(&pool)
394 .await?;
395
396 assert!(
397 verify_user_password("off@example.com", "hunter2")
398 .await?
399 .is_none()
400 );
401 }
402
403 #[local_db_sqlx_test]
407 async fn change_password_bumps_the_generation(pool: PgPool) -> anyhow::Result<()> {
408 let uid = Uuid::new_v4();
409 insert_user_with_password(uid, "bump@example.com", "hunter2").await?;
410 assert_eq!(generation_of(&pool, uid).await?, 0);
411
412 change_password(uid, "hunter2", "hunter3").await?;
413
414 assert_eq!(generation_of(&pool, uid).await?, 1);
415 assert!(
416 verify_user_password("bump@example.com", "hunter3")
417 .await?
418 .is_some(),
419 "the new password must work"
420 );
421 assert!(
422 verify_user_password("bump@example.com", "hunter2")
423 .await?
424 .is_none(),
425 "the old password must not"
426 );
427 }
428
429 #[local_db_sqlx_test]
432 async fn change_password_refuses_a_wrong_current(pool: PgPool) -> anyhow::Result<()> {
433 let uid = Uuid::new_v4();
434 insert_user_with_password(uid, "wrongcur@example.com", "hunter2").await?;
435
436 assert!(change_password(uid, "not-it", "hunter3").await.is_err());
437
438 assert_eq!(generation_of(&pool, uid).await?, 0);
439 assert!(
440 verify_user_password("wrongcur@example.com", "hunter2")
441 .await?
442 .is_some(),
443 "the original password must still work"
444 );
445 }
446
447 #[local_db_sqlx_test]
450 async fn change_password_refuses_a_disabled_user(pool: PgPool) -> anyhow::Result<()> {
451 let uid = Uuid::new_v4();
452 insert_user_with_password(uid, "offpw@example.com", "hunter2").await?;
453 sqlx::query("UPDATE users SET verified = FALSE WHERE id = $1")
454 .bind(uid)
455 .execute(&pool)
456 .await?;
457
458 assert!(change_password(uid, "hunter2", "hunter3").await.is_err());
459 assert_eq!(generation_of(&pool, uid).await?, 0);
460 }
461
462 #[local_db_sqlx_test]
467 async fn change_password_serialises_a_concurrent_change(pool: PgPool) -> anyhow::Result<()> {
468 let uid = Uuid::new_v4();
469 insert_user_with_password(uid, "race@example.com", "hunter2").await?;
470
471 let mut blocker = pool.begin().await?;
473 sqlx::query("SELECT user_password FROM users WHERE id = $1 FOR UPDATE")
474 .bind(uid)
475 .fetch_one(&mut *blocker)
476 .await?;
477
478 let racing = tokio::spawn(async move { change_password(uid, "hunter2", "loser").await });
479
480 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
481 let winner = Argon2::default()
482 .hash_password(b"winner")
483 .expect("hash")
484 .to_string();
485 sqlx::query(
486 "UPDATE users SET user_password = $2, session_generation = session_generation + 1 \
487 WHERE id = $1",
488 )
489 .bind(uid)
490 .bind(&winner)
491 .execute(&mut *blocker)
492 .await?;
493 blocker.commit().await?;
494
495 assert!(
496 racing.await?.is_err(),
497 "the old password must not authorise a write once another change has committed"
498 );
499 assert!(
500 verify_user_password("race@example.com", "winner")
501 .await?
502 .is_some(),
503 "the committed change must survive"
504 );
505 assert!(
506 verify_user_password("race@example.com", "loser")
507 .await?
508 .is_none()
509 );
510 }
511
512 async fn generation_of(pool: &PgPool, user_id: Uuid) -> anyhow::Result<i32> {
513 Ok(
514 sqlx::query_scalar("SELECT session_generation FROM users WHERE id = $1")
515 .bind(user_id)
516 .fetch_one(pool)
517 .await?,
518 )
519 }
520
521 #[local_db_sqlx_test]
522 async fn verify_is_case_insensitive_on_email(pool: PgPool) -> anyhow::Result<()> {
523 let uid = Uuid::new_v4();
524 insert_user_with_password(uid, "mixed@example.com", "pw").await?;
525 let v = verify_user_password("MIXED@EXAMPLE.COM", "pw")
526 .await?
527 .expect("match");
528 assert_eq!(v.user_id, uid);
529 }
530}